← Back to posts Cover image for Mastering Dynamic UI: Crafting Custom Animated Gestures in Flutter

Mastering Dynamic UI: Crafting Custom Animated Gestures in Flutter

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Beyond Scale: Creating Custom Pinch Gestures in Flutter

Have you ever wanted to break free from the default gestures in Flutter? We all know the standard pinch-to-zoom: you pinch, your content scales. It’s useful, but what if your UI needs something more imaginative? What if a pinch could reveal hidden details, expand a summary, or navigate contextually instead of just scaling?

This is the realm of custom animated gestures. The challenge isn’t just detecting the pinch—it’s about creating a seamless, intuitive experience that feels native. You must handle gesture conflicts (like battling a scrollable parent), drive smooth animations based on gesture data, and design a logical mapping between finger movement and UI change.

Let’s build a practical example: a PinchToReveal widget. Pinching out will smoothly transition a text block from a short title, to a medium summary, and finally to its full content.

The Core Challenge: Winning the Gesture Arena

The first hurdle is the gesture arena. If your widget is inside a ListView or a SingleChildScrollView, the scrolling gesture recognizer will compete with your pinch recognizer. Often, the scroll wins, and your custom pinch never fires.

The solution is to use a RawGestureDetector to create a custom gesture recognizer that can declare victory in the arena under the right conditions. We’ll extend ScaleGestureRecognizer.

class _RevealScaleGestureRecognizer extends ScaleGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    // Critical: Accept the gesture if we have a valid scale,
    // preventing the scroll from stealing it.
    if (scale != 1.0) {
      acceptGesture(pointer);
    } else {
      super.rejectGesture(pointer);
    }
  }
}

This override is key. When the arena tries to reject our recognizer (often in favor of a scroll), we check: if (scale != 1.0). This means if the user’s fingers have moved apart or together enough to register a scale change, we accept the gesture anyway, claiming it for our pinch interaction. Normal vertical scrolling, which has no scale change, is unaffected.

Building the PinchToReveal Widget

Now, let’s integrate this recognizer and build the widget logic.

class PinchToReveal extends StatefulWidget {
  final String title;
  final String summary;
  final String fullText;

  const PinchToReveal({
    super.key,
    required this.title,
    required this.summary,
    required this.fullText,
  });

  @override
  State<PinchToReveal> createState() => _PinchToRevealState();
}

class _PinchToRevealState extends State<PinchToReveal>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  double _gestureScaleStart = 1.0; // Tracks scale at gesture start
  double _visualProgress = 0.0; // 0: Title, xxx: Summary, 1.0: FullText

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _controller.value = _visualProgress;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  // Maps a scale factor to a progress value
  double _scaleToProgress(double scale) {
    // Example: Scale of 1.0 -> progress 0.0, Scale of 2.0 -> progress 1.0
    // You can adjust this curve for different sensitivity.
    return (scale - 1.0).clamp(0.0, 1.0);
  }

  String _getTextToDisplay() {
    if (_visualProgress < 0.5) {
      // Blend from Title to Summary
      return _visualProgress < 0.25 ? widget.title : widget.summary;
    } else {
      // Blend from Summary to FullText
      return _visualProgress < 0.75 ? widget.summary : widget.fullText;
    }
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        _RevealScaleGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<
                _RevealScaleGestureRecognizer>(
          () => _RevealScaleGestureRecognizer(),
          (instance) {
            instance
              ..onStart = (details) {
                _gestureScaleStart = details.scale;
                _controller.stop(); // Stop any ongoing animation
              }
              ..onUpdate = (details) {
                // Calculate progress change based on gesture
                double deltaProgress = _scaleToProgress(details.scale) -
                    _scaleToProgress(_gestureScaleStart);
                double newProgress = (_visualProgress + deltaProgress)
                    .clamp(0.0, 1.0);

                setState(() {
                  _visualProgress = newProgress;
                  _controller.value = newProgress;
                });
              }
              ..onEnd = (details) {
                // Snap to the nearest stage (Title, Summary, or FullText)
                double target;
                if (_visualProgress < 0.33) {
                  target = 0.0;
                } else if (_visualProgress < 0.66) {
                  target = 0.5;
                } else {
                  target = 1.0;
                }
                _controller.animateTo(target,
                    duration: const Duration(milliseconds: 150));
                // Update state after animation
                _controller.addListener(() {
                  setState(() {
                    _visualProgress = _controller.value;
                  });
                });
              };
          },
        ),
      },
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              border: Border.all(color: Colors.grey),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Text(
              _getTextToDisplay(),
              style: TextStyle(
                fontSize: 16 + (8 * _controller.value), // Subtle size change
              ),
            ),
          );
        },
      ),
    );
  }
}

How to Use It & Common Pitfalls

// Usage is simple
PinchToReveal(
  title: 'The Meeting',
  summary: 'Discussed Q3 goals and new project timelines.',
  fullText: 'The weekly team meeting commenced at 10 AM. We reviewed the Q3 objectives in detail, highlighting the need for increased collaboration between design and engineering. The new project, "Aurora", was greenlit with a tentative launch date of December 15th.',
)

Common Mistakes to Avoid:

  1. Forgetting acceptGesture: Without the custom recognizer’s rejectGesture logic, scrolling will almost always win.
  2. Ignoring Animation Controllers: Driving the visual change directly from onUpdate without an AnimationController leads to janky, non-interruptible animations. The controller allows smooth snapping and interpolation.
  3. Overcomplicating the Progress Mapping: Keep the mapping from physical scale (details.scale) to your visual progress (_visualProgress) simple and clamped. A linear map within a 0.0-1.0 range is often perfect.

By mastering these techniques—controlling the gesture arena, using RawGestureDetector, and linking gesture data to an AnimationController—you can transform any standard gesture into a unique, delightful UI interaction. The PinchToReveal widget is just a starting point; you can adapt this pattern to rotate, morph, or filter content based on user touch.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.