DEV Community

Cover image for Flutter Custom Animations: From Basic to Production-Grade
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Flutter Custom Animations: From Basic to Production-Grade

A step-by-step path from your first Tween to animations that ship to millions without dropping a frame.

The first animation I shipped to a real audience looked great in a demo and stuttered on production phones. The profile header slide-in was buttery on my test device and janky on a mid-range Android. I did what everyone does: made the animation shorter and hoped. It did not work. The frame drops were not about the duration — they were about what I was animating, how I was rebuilding, and where the work happened.

That is the difference between a Flutter animation that plays and one that is production-grade. This article walks the full path — from the basic building blocks to the techniques that keep animations at 60 fps on cheap hardware. Each step is a working piece you can extend.

Step 1: Understand the Two Families — Implicit and Explicit

Before writing any animation, you have to know which family you are in, because the tools are different:

Implicit animations are the lazy path that works for simple cases. You give a widget a target value and a duration, and the framework animates the change for you. AnimatedContainer, AnimatedOpacity, AnimatedSwitcher, TweenAnimationBuilder. No controller, no listener, no lifecycle management. This is the right choice when you want a one-off transition driven by a state change.

Explicit animations are the real machinery: AnimationController, Animation, and a Tween. You control the value, the timing, the curve, the repetition, and what rebuilds when the value changes. Any non-trivial or composed animation lives here.

Rule of thumb I use: if it changes a single property in response to a simple state change, use an implicit animation. If you need timing control, sequencing, user-dragging, physics, or repetition — go explicit from the start. Rewriting an implicit animation into an explicit one mid-project is wasted work.

A word on mental models before the code. An Animation<double> is just a value that changes over time according to its parent controller and curve. The widget tree does not animate itself; the controller drives a value, and your build method reads that value and paints a different frame. Internalize "controller drives value, value drives paint" and every example in this article — including the ones with physics and gestures — is just a variation on that loop.

Step 2: The Controller — Your Time Source

Explicit animations start with AnimationController. It is a Ticker under the hood: it asks the render tree for a new frame on every vsync, and your animation value updates each frame. Three requirements to internalize:

  • It takes a vsync, so you mix in TickerProviderStateMixin (or SingleTickerProviderStateMixin) on your State.
  • You must dispose() it.
  • Its value runs 0.0 → 1.0 by default, and you map that to real values with a Tween.
class FadeSlideDemo extends StatefulWidget {
  const FadeSlideDemo({super.key});
  @override
  State<FadeSlideDemo> createState() => _FadeSlideDemoState();
}

class _FadeSlideDemoState extends State<FadeSlideDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 600),
  );

  @override
  void initState() {
    super.initState();
    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

A controller that runs 0 to 1 does nothing by itself. You combine it with a Tween to make a meaningful Animation<T>. The animation is a listenable value — when it changes, listeners fire, and you rebuild the smallest widget that needs the new value.

Step 3: Rebuild the Right Widget — AnimatedBuilder

The single most common production mistake is rebuilding too much. If your build method listens to the controller and returns your entire screen, every animation frame rebuilds the whole subtree. On a complex screen, that is where the jank comes from.

AnimatedBuilder scopes the rebuild to exactly the widget that needs the animated value:

class FadeSlideDemo extends StatefulWidget {
  const FadeSlideDemo({super.key});
  @override
  State<FadeSlideDemo> createState() => _FadeSlideDemoState();
}

class _FadeSlideDemoState extends State<FadeSlideDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 600),
  );
  late final Animation<Offset> _slide = Tween<Offset>(
    begin: const Offset(0, 0.25),
    end: Offset.zero,
  ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));

  @override
  void initState() {
    super.initState();
    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _slide,
      builder: (context, child) => Transform.translate(
        offset: _slide.value * 200,
        child: child,
      ),
      child: Container(
        width: 120,
        height: 120,
        color: Theme.of(context).colorScheme.primary,
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the child parameter: the static Container is built once and passed in, so the builder only runs the cheap Transform.translate each frame instead of rebuilding the container. That pattern — static widget in child, cheap transform in the builder — is the core of efficient Flutter animation. Offload anything expensive to child, keep only the transforming widget in the builder.

Also prefer Transform over animating layout properties like Padding or margin. Transform works in the paint phase and never triggers a re-layout; changing layout values forces layout on every frame, which is exactly how you eat frame budget.

The child parameter is a caching optimization that pays off immediately on a row of animated list items, where the builder runs for every item on every frame. If the child subtree is identical across frames — and it usually is — passing it in means the framework skips rebuilding it entirely. The builder is then just "read the animated value, apply a transform, done." That single habit has fixed more janky lists in my code than any other change, because lists are exactly where full-subtree rebuilds add up.

One controller per widget is the rule, not a guess. If a widget needs two simultaneous animations — say a logo that both fades in and scales — use a single controller and drive both with separate CurvedAnimations, or drive the scale off the same Tween. Two controllers fighting over the same state is how you get visual glitches where one animation finishes and the other is still catching up. When you genuinely need independent timelines, give each its own widget subtree rather than piling controllers into one State.

Step 4: Sequencing and Orchestration — Making It Feel Designed

A single ease-out is not a design; it is a building block. Production animations are sequences — multiple properties moving with overlapping timing. The tools:

  • CurvedAnimation applies an easing curve per animation (easeOutCubic for a natural settle, easeInOutBack for a springy overshoot).
  • Interval staggers animations within the controller's timeline. Interval(0.0, 0.5) runs only during the first half, letting you chain without a second controller.
final _in = CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.0, 0.5, curve: Curves.easeOutCubic),
);
final _out = CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.5, 1.0, curve: Curves.easeInCubic),
);

// Use _controller.repeat(reverse: true) for a looping pulse,
// or forward() → then reverse() for a full in/out cycle.
Enter fullscreen mode Exit fullscreen mode

For genuinely parallel tracks — a hero that scales up while a backdrop fades in — you either use multiple controllers or multiple CurvedAnimations over one controller. Prefer one controller per timing timeline. The single-controller Interval approach keeps the whole sequence in one place, which is far easier to tune than four controllers fighting each other.

Step 5: Physics and Springs — When You Want It to Feel Alive

For drag-to-dismiss, pull-to-refresh, or any interaction where the user's finger drives the animation, a fixed curve is wrong. The user's velocity should matter. That is what Simulation is for: it models a physical system.

import 'package:flutter/physics.dart';

void flingRelease(DragEndDetails details, AnimationController controller) {
  const spring = SpringDescription(
    mass: 1,
    stiffness: 180,
    damping: 16,
  );
  final simulation = SpringSimulation(
    spring,
    0,            // start
    1,            // end
    details.velocity.pixelsPerSecond.dy,  // initial velocity from the gesture
  );
  controller.animateWith(simulation);
}
Enter fullscreen mode Exit fullscreen mode

The numbers are the physics: stiffness controls how aggressively it snaps home, damping controls the settle. For production, keep the settle fast — a spring that wobbles for a second feels luxurious in a demo and sluggish in a real product. Users forgive a quick snap; they notice a laggy dismissal.

Transitions Between States and Screens

So far everything has been a single widget animating in place. Production apps also need to animate between states — a loading spinner turning into content, a list item appearing, a screen entering. Flutter has purpose-built tools for each:

AnimatedSwitcher cross-fades between two children when the child changes. Give each child a distinct Key and the switcher animates the old one out and the new one in:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 250),
  transitionBuilder: (child, animation) =>
      FadeTransition(opacity: animation, child: child),
  child: isLoading
      ? const CircularProgressIndicator(key: ValueKey('loading'))
      : const ResultsView(key: ValueKey('results')),
)
Enter fullscreen mode Exit fullscreen mode

Custom route transitions replace the default page slide. A PageRouteBuilder gives you a full Animation<double> to drive anything — a shared-axis transition, a scale-in, a wipe:

MaterialPageRoute(
  builder: (_) => const DetailScreen(),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    final t = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
    return FadeTransition(
      opacity: t,
      child: ScaleTransition(scale: Tween(begin: 0.96, end: 1.0).animate(t), child: child),
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

Two production notes on transitions. First, keep cross-screen transitions short (200–350 ms) — every screen change is a moment the user is waiting. Second, respect platform conventions: iOS users expect a horizontal slide, Android users a fade-from-bottom. A beautiful custom transition that fights the platform's muscle memory is a UX bug wearing nice clothes.

Step 6: Going Production-Grade — The Checklist That Stops Jank

Everything above is craft. This step is engineering. These are the specific techniques that keep animations smooth at scale:

  1. RepaintBoundary on heavy animated siblings. If an animation forces a repaint, isolate it so the framework does not repaint the whole layer tree. Wrap cards, images, and complex content in RepaintBoundary so a sliding overlay does not redraw everything beneath it.
  2. Never setState from a build or a listener. Drive changes through the AnimationController/ValueNotifier, never by calling setState inside build or inside an addListener that triggers another rebuild — that is a guaranteed re-entrant rebuild loop.
  3. Prefer Transform and Opacity over layout-affecting properties. Animate transform/scale/translation in the paint phase. Animating width, height, padding, or Align forces layout every frame.
  4. Avoid expensive work in the builder. If you need to filter a list or parse data, do it before the animation and cache the result in child. The animation frame is not the place for computation.
  5. RepaintBoundary + Opacity over AnimatedOpacity for large subtrees. If you are fading an entire screen, use FadeTransition (explicit) or wrap in RepaintBoundary and animate opacity directly — it can save an expensive layer composition per frame.
  6. Consider Rive or Lottie for complex, authored animations. For character animation, logo intros, or anything a designer hand-authored, do not rebuild it in Flutter code. Ship the asset and play it: RiveAnimation or Lottie files. They render efficiently off the widget tree and keep their own timelines. The rule: code animation for the UI you own, asset animation for the art you were given.
  7. Profile on the target hardware, not the emulator. Run the animation in release mode on the cheapest device your users actually own. The Flutter performance overlay (debugShowPerformanceOverlay) and DevTools' timeline view tell you whether you are hitting the frame budget. If your worst device holds 60 fps there, ship it.

Pitfalls That Have Bitten Me (In Order of Damage)

  • Not disposing the controller. A leaked Ticker keeps your widget's frame callbacks alive forever. One leaked animation on a scrollable list and the whole list stutters.
  • MediaQuery.of(context) inside the animation builder. If your builder reads inherited widgets that can change (text scale, theme), you defeat the child caching optimization. Read them outside and pass them in.
  • Animating with setState on the parent. The parent rebuilds, all children rebuild, the animation is a side effect instead of a source of truth. Keep the controller's rebuild scope inside AnimatedBuilder.
  • Frames per second, not per animation. Three smooth animations each forcing a full-screen repaint on an old GPU is a janky screen. Budget repaint area, not just count.
  • Forgetting the reduced-motion story. If the platform reports reduced motion (MediaQuery.disableAnimations), respect it — skip the slide, keep the fade. It is an accessibility requirement, and it is also a production signal that you think about real users.
  • Measuring on the wrong build. Debug builds run at a fraction of release performance. If you are tuning in flutter run debug mode, you are tuning the wrong numbers. Profile with --profile (or release) on device.
  • Using setState where a ValueNotifier would do. For a single animated scalar that does not belong to a widget's full state, a ValueNotifier<double> rebuilt through ValueListenableBuilder scopes the rebuild even tighter than setState — and it keeps your State clean.

The Method, As a Decision Rule

When I write a custom animation now, I run through this fixed order:

  1. Implicit first — does the framework have a widget that already does this?
  2. If explicit, one AnimationController per timeline, SingleTickerProviderStateMixin, disposed.
  3. TweenCurvedAnimationInterval for sequencing; everything mapped through Animation<T>.
  4. Rebuild scope contained with AnimatedBuilder; static content in child.
  5. Layout stays untouched; movement happens in Transform, fading in Opacity.
  6. Physics (SpringSimulation) for gesture-driven motion; asset animations (Rive/Lottie) for authored art.
  7. RepaintBoundary around heavy siblings, reduced-motion respected, and a release-mode profile on the slowest target device before it ships.

Follow that order and your animations will feel designed instead of demoed — and they will hold 60 fps on hardware that does not flatter anyone. That is the difference between a widget that plays and a product that ships.


*Gulshan Yad

Top comments (0)