Custom Flutter Animations: How to Build High-Performance UIs Beyond Hero Transitions
We recently optimized the transaction visualization engine in Vitta—our personal finance app that parses banking SMS data in India—to use highly optimized custom Flutter animations. While basic transitions like Hero widgets or implicit AnimatedContainers are fine for simple page routing, they fall short when you need to render complex, high-fidelity micro-interactions or real-time data visualizations without dropping frames.
In production, animating layout properties like width, height, or padding forces Flutter to perform a full layout pass (performLayout) on every single frame. On low-end Android devices, this layout thrashing immediately drops your frame rate from a smooth 60 or 120 Hz down to a stuttering 30 FPS.
To build interfaces that feel truly premium, you must bypass the layout phase entirely and animate directly in the paint phase.
How do you build custom Flutter animations without dropping frames?
To animate without dropping frames, you must understand the Flutter rendering pipeline: Layout → Paint → Composition.
If you change a widget's size during an animation, you trigger the entire pipeline. If you only change how a widget is painted or transformed, you skip the expensive Layout phase.
[Your Animation State]
│
▼ (Skip Layout Phase)
[Paint Phase] ──► [Composition Phase] ──► [Raster Thread]
To achieve this separation of concerns, we use explicit animations combined with CustomPainter and AnimatedBuilder. By passing your animation controller to the Listenable of an AnimatedBuilder and returning a CustomPaint widget that uses the animation value, Flutter only executes the paint routine of your painter. The rest of the widget tree remains completely untouched.
Here is a production-ready implementation of a pulsing, organic wave visualizer. This pattern is used in Vitta to represent budget limits and real-time transaction processing.
import 'dart:math' as math;
import 'package:flutter/material.dart';
class BudgetPulseVisualizer extends StatefulWidget {
final double limitPercentage;
const BudgetPulseVisualizer({
super.key,
required this.limitPercentage,
});
@override
State<BudgetPulseVisualizer> createState() => _BudgetPulseVisualizerState();
}
class _BudgetPulseVisualizerState extends State<BudgetPulseVisualizer>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
size: const Size(double.infinity, 200),
painter: WavePainter(
animationValue: _controller.value,
fillPercentage: widget.limitPercentage,
waveColor: Theme.of(context).colorScheme.primary,
),
);
},
),
);
}
}
class WavePainter extends CustomPainter {
final double animationValue;
final double fillPercentage;
final Color waveColor;
WavePainter({
required this.animationValue,
required this.fillPercentage,
required this.waveColor,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = waveColor.withOpacity(0.15)
..style = PaintingStyle.fill;
final path = Path();
final double midHeight = size.height * (1.0 - fillPercentage);
final double amplitude = 12.0; // Wave height
final double wavelength = size.width;
path.moveTo(0, size.height);
path.lineTo(0, midHeight);
for (double x = 0; x <= size.width; x++) {
final double normalizedX = x / wavelength;
// Introduce phase shift using the animation value
final double y = midHeight +
amplitude *
math.sin((normalizedX * 2 * math.pi) + (animationValue * 2 * math.pi));
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
// Draw a second, offset wave for depth
final secondPaint = Paint()
..color = waveColor.withOpacity(0.25)
..style = PaintingStyle.fill;
final secondPath = Path();
secondPath.moveTo(0, size.height);
secondPath.lineTo(0, midHeight);
for (double x = 0; x <= size.width; x++) {
final double normalizedX = x / wavelength;
final double y = midHeight +
amplitude *
math.cos((normalizedX * 2 * math.pi) - (animationValue * 2 * math.pi));
secondPath.lineTo(x, y);
}
secondPath.lineTo(size.width, size.height);
secondPath.close();
canvas.drawPath(secondPath, secondPaint);
}
@override
bool shouldRepaint(covariant WavePainter oldDelegate) {
return oldDelegate.animationValue != animationValue ||
oldDelegate.fillPercentage != fillPercentage ||
oldDelegate.waveColor != waveColor;
}
}
Physics-Based Interactions: Moving Beyond Linear Curves
Linear or standard cubic easing curves often look artificial. Real objects in the physical world do not start and stop instantly; they have mass, friction, and springiness. To make your custom Flutter animations feel tactile, you should use physics-based simulations.
Instead of defining a fixed duration, a physics-based animation calculates its trajectory using physical forces. Flutter provides the PhysicsCardSimulation and SpringSimulation classes to handle this logic natively.
When we designed the swipe-to-dismiss and interactive transaction cards in Vitta, we used a SpringDescription to configure the stiffness, damping, and mass of the card's response to user gestures:
import 'package:flutter/physics.dart';
final SpringDescription spring = const SpringDescription(
mass: 1.0,
stiffness: 180.0, // High stiffness for a snappy return
damping: 15.0, // Low damping for a slight overshoot/bounce
);
// To run the simulation:
final simulation = SpringSimulation(spring, startPosition, endPosition, startVelocity);
_controller.animateWith(simulation);
Using animateWith on your AnimationController hands control over to the physics engine. The controller will tick at the device's native refresh rate until the spring system reaches equilibrium, ensuring a smooth transition regardless of the gesture's release velocity.
Three Optimization Rules for Smooth Painting
Writing a CustomPainter does not guarantee high performance by default. If your paint code is inefficient, you will bottleneck the raster thread. Follow these three rules to keep your draw calls fast:
- Leverage
RepaintBoundary: Wrap your animated widgets in aRepaintBoundary. This creates a separate display list for the subtree, preventing Flutter from repainting the entire screen when only your custom animation changes. - Never Instantiate Objects Inside
paint(): Avoid allocatingPaint,Path, or