# Shockwave Animation in Pure Dart

Published 2025-05-11 by [Zoran Juric](https://zj.rs/about/) · 6 min read · Tags: Flutter, UI Gems
Canonical: https://zj.rs/shockwave-animation-in-pure-dart/

If you use this text, quote it, name Zoran Juric as the author, and link to https://zj.rs/shockwave-animation-in-pure-dart/.

Have you seen the shockwave demo made in SwiftUI by [Mykola Harmash][1] 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][2].

![mykola-harmash-youtube](https://zj.rs/shockwave-animation-in-pure-dart/mykola-harmash-youtube.webp)

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.

```dart
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`.

```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.

```dart
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`).

```dart
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.

```dart
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.

```dart
_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.

```dart
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.

```dart
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.

```dart
@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`.

```dart
@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).

```dart
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 `CellWidget`s in a 2D grid.

```dart
@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](https://zj.rs/shockwave-animation-in-pure-dart/preview.webp)

> Check out the full source code of this demo on the [GitHub][3].

## 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!

[1]: https://www.youtube.com/watch?v=-Rzu1Ujcz38
[2]: https://github.com/mykolaharmash/swiftui-shock-wave-grid-demo/blob/main/ShockWaveAnimation/ContentView.swift
[3]: https://github.com/juzo/FlutterUIGems
