Skip to content
Zoran Juric
LinkedInTwitter

Shockwave Animation in Pure Dart

Flutter, UI Gems8 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.

mykola-harmash-youtube

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.

@override
Widget 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.

@override
void 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 origin
Offset _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 the widget.waveOrigin to the center of this cell (_cellCoords).
  • normalizedDistance: The _originDistance normalized with respect to maxGridDistance. If maxGridDistance is 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 _originDistance by 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.

@override
Widget 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!

preview 📢 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, and DecoratedBox. While Flutter is efficient, this overhead can add up for significantly larger grids. Can we refactor the grid to use a single CustomPaint widget and a single AnimationController?
  • In _CellWidgetState, _setupAnimations is called in didUpdateWidget every time the trigger or waveOrigin changes. This recreates TweenSequence objects along with their TweenSequenceItems and 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!

© 2025 by Coffee Break Ideas, LLC - 30 N Gould St Ste R, WY 82801, USA. All rights reserved.