Skip to main content
Start your own AI-powered blog — freeGet started →

Flutter Custom Animations: From Basic to Production-Grade

Podcast episode2 voices
3:06
Flutter Custom Animations: From Basic to Production-Grade
Photo by Fahim Muntashir on unsplash

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.
dart
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();
  }
}

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:

dart
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,
      ),
    );
  }
}

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

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.

dart
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);
}

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:

dart
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')),
)

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:

dart
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),
    );
  },
)

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

Advanced Animation Techniques

When creating complex animations, it's essential to understand how to use advanced animation techniques. One such technique is the use of a StoryBoard class to create a sequence of animations and manage their timing.

Creating a StoryBoard

To create a StoryBoard, you can use the following code:

dart
class StoryBoard extends StatefulWidget {
  @override
  _StoryBoardState createState() => _StoryBoardState();
}

class _StoryBoardState extends State<StoryBoard> {
  final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 2000),
  );

  final Animation<double> _animation = Tween<double>(
    begin: 0.0,
    end: 1.0,
  ).animate(_controller);

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

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Center(
          child: Opacity(
            opacity: _animation.value,
            child: Text('Hello, World!'),
          ),
        );
      },
    );
  }
}

Sequencing Animations

To sequence animations, you can use the StoryBoard class to create a sequence of animations and manage their timing. The following code demonstrates how to create a sequence of animations:

dart
class StoryBoard extends StatefulWidget {
  @override
  _StoryBoardState createState() => _StoryBoardState();
}

class _StoryBoardState extends State<StoryBoard> {
  final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 2000),
  );

  final Animation<double> _animation = Tween<double>(
    begin: 0.0,
    end: 1.0,
  ).animate(_controller);

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

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        if (_animation.value < 0.5) {
          return Center(
            child: Opacity(
              opacity: _animation.value,
              child: Text('Hello, World!'),
            ),
          );
        } else {
          return Center(
            child: Opacity(
              opacity: 1.0,
              child: Text('Goodbye, World!'),
            ),
          );
        }
      },
    );
  }
}

Handling Animation Errors

When creating animations, it's essential to handle any errors that may occur during animation execution. You can use a try-catch block to handle any errors that may occur:

dart
class StoryBoard extends StatefulWidget {
  @override
  _StoryBoardState createState() => _StoryBoardState();
}

class _StoryBoardState extends State<StoryBoard> {
  final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 2000),
  );

  final Animation<double> _animation = Tween<double>(
    begin: 0.0,
    end: 1.0,
  ).animate(_controller);

  @override
  void initState() {
    super.initState();
    try {
      _controller.forward();
    } catch (e) {
      print(e);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Center(
          child: Opacity(
            opacity: _animation.value,
            child: Text('Hello, World!'),
          ),
        );
      },
    );
  }
}

Best Practices for Creating Animations

When creating animations, there are several best practices to keep in mind. These include:

  • Use the AnimatedBuilder widget to rebuild a widget when the animation changes.
  • Use the TweenAnimationBuilder widget to create a smooth transition between states.
  • Use the AnimationController class to manage animation state and trigger animations.
  • Use the StoryBoard class to create a sequence of animations and manage their timing.
  • Handle any errors that may occur during animation execution using a try-catch block.
  • Consider using a state management library like Provider or BLoC to manage animation state.
  • Test animations thoroughly to ensure they work as expected in different scenarios.
  • Optimize animations for performance by minimizing unnecessary computations and using efficient animation techniques.
  • Consider using a animation library like AutoAnimate or Animated to simplify animation creation and management.

Optimizing Animations for Performance

When creating animations, it's essential to optimize them for performance. There are several techniques you can use to optimize animations, including:

  • Minimizing unnecessary computations: Avoid unnecessary computations by using efficient animation techniques and minimizing the number of computations required to animate a widget.
  • Using efficient animation techniques: Use efficient animation techniques such as TweenAnimationBuilder and AnimatedBuilder to create smooth animations.
  • Using a animation library: Consider using a animation library like AutoAnimate or Animated to simplify animation creation and management.
  • Testing animations: Test animations thoroughly to ensure they work as expected in different scenarios.
  • Optimizing animation timing: Optimize animation timing by using techniques such as easing functions and animation curve to create smooth animations.
  • Reducing animation overhead: Reduce animation overhead by minimizing the number of animations used and using efficient animation techniques.
  • Using a state management library: Consider using a state management library like Provider or BLoC to manage animation state and reduce animation overhead.

Animation Best Practices for Different Devices

When creating animations, it's essential to consider the device on which the animation will be displayed. Different devices have different capabilities and limitations, and animations should be optimized for each device. Here are some animation best practices for different devices:

  • Mobile devices: Use efficient animation techniques such as TweenAnimationBuilder and AnimatedBuilder to create smooth animations on mobile devices.
  • Desktop devices: Use more complex animation techniques such as StoryBoard and AnimationController to create more complex animations on desktop devices.
  • Tablets: Use a combination of efficient and complex animation techniques to create animations that are suitable for tablets.
  • Wearables: Use simple animation techniques such as TweenAnimationBuilder and AnimatedBuilder to create simple animations on wearables.
  • TVs: Use complex animation techniques such as StoryBoard and AnimationController to create complex animations on TVs.

Conclusion

In conclusion, creating custom animations in Flutter can be a complex task, but with the right techniques and best practices, you can create smooth and engaging animations that enhance the user experience. By following the techniques and best practices outlined in this article, you can create animations that are optimized for different devices and scenarios, and that provide a seamless and engaging user experience.

Key Takeaways

  • Implement custom animations by extending the AnimatedBuilder widget and using TweenAnimationBuilder to create a smooth transition between states.
  • Use the AnimationController class to manage animation state and trigger animations using the animateTo method.
  • Custom animations can be achieved by creating a custom Animation class, allowing for complex, custom animation behaviors.
  • For more complex animations, utilize the StoryBoard class to create a sequence of animations and manage their timing.
  • To implement a production-grade animation system, consider using a state management library like Provider or BLoC to manage animation state.

Frequently Asked Questions

How do I create a custom animation in Flutter?

Create a custom animation by extending the AnimatedBuilder widget and using TweenAnimationBuilder to create a smooth transition between states.

How do I manage animation state in Flutter?

Use the AnimationController class to manage animation state and trigger animations using the animateTo method.

Can I create complex animations in Flutter?

Yes, create a custom Animation class to achieve complex, custom animation behaviors.

How do I sequence animations in Flutter?

Utilize the StoryBoard class to create a sequence of animations and manage their timing.

What is the best way to manage animation state in a production-grade application?

Consider using a state management library like Provider or BLoC to manage animation state.

How do I handle animation errors in Flutter?

Use a try-catch block to handle any errors that may occur during animation execution.

Can I animate non-widget properties in Flutter?

No, animations in Flutter are limited to widget properties and cannot be applied to non-widget properties.

G
Gulshan Yadav

1 followers

AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com

Comments

Sign in to join the conversation

No comments yet. Be the first to share your thoughts!

More from Gulshan Yadav

Recommended for you