Mastering Dynamic UI: Crafting Custom Animated Gestures in Flutter
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
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:
- Forgetting
acceptGesture: Without the custom recognizer’srejectGesturelogic, scrolling will almost always win. - Ignoring Animation Controllers: Driving the visual change directly from
onUpdatewithout anAnimationControllerleads to janky, non-interruptible animations. The controller allows smooth snapping and interpolation. - 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
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.
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.
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.