Shockwave Animation in Pure Dart
— Flutter, UI Gems — 8 min read
Have you seen the shockwave demo made in SwiftUI by Mykola Harmash on his YouTube channel? If not, go check it out. The tutorial is presented very nicely, demonstrating step by step how he achieved this beautiful animation, progressing from animating a single cell to creating a ripple effect on the entire cluster. Everything fits into approximately 100 lines of SwiftUI code.
I wondered if I could copy this same effect in Flutter. After playing around with AnimationController, I came up with something very similar. It's not even close to 100 lines, but the final effect is there ✨.
Let me explain how I built it.
Step 1: The Idea
The goal is to create a grid of cells that, when tapped, trigger a ripple effect or a "shockwave", spreading outwards. Cells compress, then expand, and settle back, with the effect diminishing and delaying based on distance from the tap.
Step 2: Project Setup
Before we animate, we need a Flutter project and some core definitions.
void main() { runApp(const App());}
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return const MaterialApp( title: 'Shockwave Animation Demo', home: Scaffold( body: Center(child: ShockwaveGrid()), ), ); }}Step 3: Constants, Enums, and Helper Functions
Grid will be adjustable through several parameters defined in the constants.dart.
const int kColumnCount = 11;const int kRowCount = 9;const double kCellSize = 18;const double kCellSpacing = 6;
const Color kBaseColor = Colors.blue;The values are copied from the SwiftUI source code to match the animation as much as possible.
enum CellAnimationPhase { identity, compress, expand;
double get scaleAdjustment => switch (this) { CellAnimationPhase.identity => 0, CellAnimationPhase.compress => -0.25, CellAnimationPhase.expand => 0.2, };
double get brightnessAdjustment => switch (this) { CellAnimationPhase.identity => 0, CellAnimationPhase.compress => 0, CellAnimationPhase.expand => -0.2, };}This enum is crucial. It directly mirrors the SwiftUI version and defines the distinct stages of our animation.
The scaleAdjustment and brightnessAdjustment getters provide the modification values for each phase.
On to the helper functions.
calculateDistance calculates the Euclidean distance between two Offset points (Flutter's equivalent of CGPoint).
double calculateDistance( Offset point1, Offset point2,) { return math.sqrt( math.pow(point2.dx - point1.dx, 2) + math.pow(point2.dy - point1.dy, 2), );}adjustBrightness changes a color's brightness.
Color adjustBrightness( Color color, double adjustment,) { final hslColor = HSLColor.fromColor(color);
/// Adjust lightness: positive makes it lighter, negative makes it darker. /// Clamp between 0.0 (black) and 1.0 (white). final newLightness = (hslColor.lightness + adjustment).clamp(0.0, 1.0);
return hslColor.withLightness(newLightness).toColor();}Step 4: Single Cell Animation
Now we can focus on making one cell (CellWidget) animate through its phases.
CellWidget is a StatefulWidget because its appearance (scale, color) changes over time due to animation.
The heart of explicit animations in Flutter is the AnimationController.
A special animation object generates a new value on every frame while an animation is active.
_controller = AnimationController( vsync: this, duration: const Duration( milliseconds: _compressDurationMs + _expandDurationMs + _settleDurationMs, ),);
_controller.addStatusListener((status) { if (status == AnimationStatus.completed) { widget.onAnimationComplete(); }});The controller's total duration depends on _waveImpact.
This means cells less impacted by the wave might have a slightly shorter settling phase.
void _calculateAnimationParameters() { _originDistance = calculateDistance(widget.waveOrigin, _cellCoords); final normalizedDistance = (maxGridDistance > 0.001) ? (_originDistance / maxGridDistance) : 0.0;
// Subtracting the normalized distance from one for the fading effect _waveImpact = (1.0 - normalizedDistance).clamp(0.0, 1.0);
// More efficient delay calculation _delayDuration = Duration( milliseconds: (_originDistance * 70).round(), // 0.07 * 1000 );}An AnimationController produces values from 0.0 to 1.0.
We'll use Tweens to animate specific properties like scale or color.
For a sequence of animations, TweenSequence is perfect.
void _setupAnimations() { // Update controller duration just in case _controller.duration = Duration( milliseconds: (_compressDurationMs + _expandDurationMs + _settleDurationMs * (1.0 - _waveImpact * 0.5)) .round(), );
final identityScale = 1.0 + CellAnimationPhase.identity.scaleAdjustment * _waveImpact; // Should be 1.0 final compressScaleTarget = 1.0 + CellAnimationPhase.compress.scaleAdjustment * _waveImpact; final expandScaleTarget = 1.0 + CellAnimationPhase.expand.scaleAdjustment * _waveImpact;
_scaleAnimation = TweenSequence<double>([ TweenSequenceItem( tween: Tween<double>( begin: identityScale, end: compressScaleTarget, ).chain(CurveTween(curve: Curves.easeInOut)), weight: _compressDurationMs.toDouble(), ), TweenSequenceItem( tween: Tween<double>( begin: compressScaleTarget, end: expandScaleTarget, ).chain(CurveTween(curve: Curves.easeInOut)), weight: _expandDurationMs.toDouble(), ), TweenSequenceItem( tween: Tween<double>( begin: expandScaleTarget, end: identityScale, ).chain(CurveTween(curve: Curves.easeOutBack)), weight: _settleDurationMs.toDouble(), ), ]).animate(_controller);
final identityColor = adjustBrightness( kBaseColor, CellAnimationPhase.identity.brightnessAdjustment * _waveImpact, ); final compressColorTarget = adjustBrightness( kBaseColor, CellAnimationPhase.compress.brightnessAdjustment * _waveImpact, ); final expandColorTarget = adjustBrightness( kBaseColor, CellAnimationPhase.expand.brightnessAdjustment * _waveImpact, );
_colorAnimation = TweenSequence<Color?>([ TweenSequenceItem( tween: ColorTween(begin: identityColor, end: compressColorTarget), weight: _compressDurationMs.toDouble(), ), TweenSequenceItem( tween: ColorTween(begin: compressColorTarget, end: expandColorTarget), weight: _expandDurationMs.toDouble(), ), TweenSequenceItem( tween: ColorTween(begin: expandColorTarget, end: identityColor), weight: _settleDurationMs.toDouble(), ), ]).animate(_controller);}Each item (TweenSequenceItem) defines a part of the animation sequence: its tween (what values to animate between and with what Curve) and its weight (how much of the total controller duration this part takes).
Curves.easeInOut provides a smooth acceleration and deceleration.
Curves.easeOutBack is used for the settle, giving it a slight overshoot before settling.
AnimatedBuilder is an efficient widget for rebuilding part of the UI when an animation's value changes.
It scales (_scaleAnimation.value) and changes the color (_colorAnimation.value) of a DecoratedBox child based on an animation sequence.
@overrideWidget build(BuildContext context) { return GestureDetector( onTap: _controller.isAnimating ? null : () => widget.onTap(_cellCoords), child: AnimatedBuilder( animation: _controller, child: const SizedBox( width: kCellSize, height: kCellSize, ), builder: (context, staticChild) { return Transform.scale( scale: _scaleAnimation.value, // The DecoratedBox applies the changing decoration // to the staticChild. child: DecoratedBox( decoration: BoxDecoration( color: _colorAnimation.value ?? kBaseColor, borderRadius: BorderRadius.circular(4), ), child: staticChild, // The SizedBox ), ); }, ), );}The animation starts when _controller.forward() is called.
This will be managed by didUpdateWidget based on the parent's trigger.
@overridevoid didUpdateWidget(CellWidget oldWidget) { super.didUpdateWidget(oldWidget); if (widget.trigger != oldWidget.trigger || widget.waveOrigin != oldWidget.waveOrigin) { _calculateAnimationParameters(); // Re-configure tweens because waveImpact might have changed _setupAnimations(); _controller.reset(); if (mounted) { Future.delayed(_delayDuration, () { if (mounted) { _controller.forward(); } }); } }}Step 5: The Wave
Now, let's arrange the cells and make the animation spread like a wave from the tapped cell.
This involves ShockwaveGrid and how CellWidget reacts to changes.
ShockwaveGrid is a StatefulWidget holding the _waveOrigin (where the tap occurred) and _trigger (an integer that increments to signal a new animation).
int _trigger = 0;bool _isAnimating = false;
/// Default originOffset _waveOrigin = Offset.zero;
void _handleCellTap(Offset cellCoords) { if (_isAnimating) return;
setState(() { _isAnimating = true; _waveOrigin = cellCoords; _trigger++; });}When a cell is tapped, _handleCellTap updates the state, causing ShockwaveGrid to rebuild and pass new waveOrigin and trigger values to its CellWidget children.
The didUpdateWidget lifecycle method is called when the widget receives new properties from its parent.
We use it to detect if a new shockwave has been triggered.
_calculateAnimationParameters calculates animation parameters based on the cell's position relative to the wave origin.
It determines:
_originDistance: The Euclidean distance from thewidget.waveOriginto the center of this cell (_cellCoords).normalizedDistance: The_originDistancenormalized with respect tomaxGridDistance. IfmaxGridDistanceis very small (close to zero), this is treated as 0.0 to prevent division by zero or excessively large values._waveImpact: A value between 0.0 and 1.0 representing the intensity of the wave's effect on this cell. It's calculated as(1.0 - normalizedDistance), meaning cells closer to the origin experience a stronger impact (closer to 1.0), and cells further away experience a weaker impact (closer to 0.0). The result is clamped to ensure it stays within the [0.0, 1.0] range._delayDuration: The animation delay for this cell. It's calculated by multiplying the_originDistanceby 70 milliseconds. This creates a ripple effect where cells further from the origin start their animation later.
Step 6: The Full Grid
ShockwaveGrid lays out all the CellWidgets in a 2D grid.
@overrideWidget build(BuildContext context) { /// Define EdgeInsets objects once if kCellSpacing > 0, /// or use EdgeInsets.zero const rowPadding = (kCellSpacing > 0) ? EdgeInsets.only(bottom: kCellSpacing) : EdgeInsets.zero; const columnPadding = (kCellSpacing > 0) ? EdgeInsets.only(right: kCellSpacing) : EdgeInsets.zero; const noPadding = EdgeInsets.zero;
return Column( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(kRowCount, (rowIndex) { final isLastRow = rowIndex == kRowCount - 1; return Padding( padding: isLastRow ? noPadding : rowPadding, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(kColumnCount, (columnIndex) { final isLastColumn = columnIndex == kColumnCount - 1; return Padding( padding: isLastColumn ? noPadding : columnPadding, child: CellWidget( key: ValueKey('$columnIndex-$rowIndex'), columnIndex: columnIndex, rowIndex: rowIndex, waveOrigin: _waveOrigin, trigger: _trigger, onTap: _handleCellTap, onAnimationComplete: _onAnimationComplete, ), ); }), ), ); }), );}The brightness adjustment is already integrated into our _colorAnimation within _CellWidgetState's _setupAnimations.
It uses CellAnimationPhase.expand.brightnessAdjustment * _waveImpact and the adjustBrightness helper function, perfectly mirroring the scale adjustment logic for a cohesive effect.
Final Result
And there you go: shockwave/ripple effect in pure Dart that works on all platforms!
📢 Check out the full source code of this demo on the GitHub.
Challenge: Further Improvements
Now, for a fun challenge to all of you Flutter enthusiasts out there (and perhaps for my future self included), where could we squeeze out even more performance, reduce overhead, or refine the architecture?
Here are a few areas to explore:
- Currently, each cell is its own
CellWidget. For an 11x9 grid, that's 99 CellWidget instances, each with its own State object,AnimationController,AnimatedBuilder,GestureDetector,Transform, andDecoratedBox. While Flutter is efficient, this overhead can add up for significantly larger grids. Can we refactor the grid to use a singleCustomPaintwidget and a singleAnimationController? - In
_CellWidgetState,_setupAnimationsis called indidUpdateWidgetevery time thetriggerorwaveOriginchanges. This recreatesTweenSequenceobjects along with theirTweenSequenceItemsand ColorTween/CurveTween chains for each of the 99 cells. While these are lightweight objects, their frequent creation could be an area for micro-optimization.
Good luck to anyone who accepts the challenge!