Ahmet Balaman LogoAhmet Balaman

Flutter: Full Control with Explicit Animations - AnimationController Guide

personAhmet Balaman
calendar_today
FlutterAnimationAnimationControllerExplicit AnimationTween

Implicit animations are great, but sometimes you need more control. If you want to start, stop, reverse, or change the speed of an animation at will, Explicit Animations come into play.

AnimationController is the heart of Flutter's animation engine. It's like an orchestra conductor that produces values for every frame, which you can direct as you wish.

Implicit vs Explicit: Which One When?

A quick reminder:

Feature Implicit Explicit
Usage Automatic with setState Manual control
Code Amount Little (3-5 lines) More (15-30 lines)
Control Limited Full control
Start/Stop Automatic Manual
Use Case Simple transitions Complex animations
Example AnimatedContainer AnimationController

When Should You Use Explicit?

  • When you want to start the animation on button press
  • When you want to run the animation in an infinite loop
  • When you want to synchronize multiple animations
  • When you want to control the animation based on user input (e.g., based on scroll distance)
  • When you want to reverse the animation

AnimationController Basics

AnimationController produces a continuously increasing value from 0.0 to 1.0 (or any range you want).

Basic Usage

class BasicAnimationDemo extends StatefulWidget {
  @override
  _BasicAnimationDemoState createState() => _BasicAnimationDemoState();
}

class _BasicAnimationDemoState extends State<BasicAnimationDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this, // TickerProvider required
    );
  }

  @override
  void dispose() {
    _controller.dispose(); // To prevent memory leaks!
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Transform.rotate(
              angle: _controller.value * 2 * 3.14159, // 360 degrees
              child: child,
            );
          },
          child: Icon(Icons.star, size: 100, color: Colors.amber),
        ),
        SizedBox(height: 40),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => _controller.forward(),
              child: Text('Start'),
            ),
            SizedBox(width: 10),
            ElevatedButton(
              onPressed: () => _controller.reverse(),
              child: Text('Reverse'),
            ),
            SizedBox(width: 10),
            ElevatedButton(
              onPressed: () => _controller.reset(),
              child: Text('Reset'),
            ),
          ],
        ),
      ],
    );
  }
}

Live Demo

You can try explicit animations in the interactive example below:

πŸ’‘ If the example above doesn't load, click DartPad to run it in a new tab.

What is TickerProvider?

The vsync parameter is required. So what does this TickerProvider do?

Why Is It Needed?

Flutter tries to draw 60 frames per second (60 FPS). AnimationController produces a new value for each frame. However, if the widget is not visible on screen (e.g., you navigated to another page), the animation unnecessarily consumes CPU.

vsync automatically stops the animation when the widget is not on screen. This results in:

  • Battery savings
  • CPU savings
  • Performance improvement

Which Mixin Should You Use?

// If there's a single animation
class _MyWidgetState extends State<MyWidget> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
}

// If there are multiple animations
class _MyWidgetState extends State<MyWidget> 
    with TickerProviderStateMixin {
  late AnimationController _controller1;
  late AnimationController _controller2;
  late AnimationController _controller3;
}

AnimationController Methods

Think of AnimationController like a VCR (video cassette player):

1. forward() - Play

Runs the animation from start to end (0.0 β†’ 1.0):

_controller.forward();

// Start from a specific value
_controller.forward(from: 0.5); // Start from 0.5

2. reverse() - Rewind

Runs the animation from end to start (1.0 β†’ 0.0):

_controller.reverse();

// Reverse from a specific value
_controller.reverse(from: 0.8);

3. reset() - Reset

Returns the animation to 0.0 (animation doesn't run):

_controller.reset();

4. stop() - Stop

Stops the animation at its current value:

_controller.stop();

5. repeat() - Infinite Loop

Repeats the animation continuously:

// Infinite loop (0 β†’ 1 β†’ 0 β†’ 1 ...)
_controller.repeat();

// With reverse (0 β†’ 1 β†’ 0 β†’ 1 ...)
_controller.repeat(reverse: true);

// Repeat a specific number of times
_controller.repeat(reverse: true, period: Duration(seconds: 1));

6. animateTo() - Go to Specific Value

Animates from current value to a specific value:

_controller.animateTo(0.7); // Go from current value to 0.7

// With custom duration
_controller.animateTo(
  0.5,
  duration: Duration(milliseconds: 500),
  curve: Curves.easeInOut,
);

7. animateBack() - Go Back

Animates backward from current value to a specific value:

_controller.animateBack(0.3);

Tween: Value Transformation

AnimationController only produces values between 0.0 - 1.0. What if we want values between 0 - 360 (degrees) or 50 - 200 (pixels)?

Tween (short for between) transforms values.

Basic Tween Usage

class TweenDemo extends StatefulWidget {
  @override
  _TweenDemoState createState() => _TweenDemoState();
}

class _TweenDemoState extends State<TweenDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

    // Size animation: 50 β†’ 200
    _sizeAnimation = Tween<double>(
      begin: 50,
      end: 200,
    ).animate(_controller);

    // Color animation: Red β†’ Blue
    _colorAnimation = ColorTween(
      begin: Colors.red,
      end: Colors.blue,
    ).animate(_controller);
  }

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Container(
              width: _sizeAnimation.value,
              height: _sizeAnimation.value,
              decoration: BoxDecoration(
                color: _colorAnimation.value,
                borderRadius: BorderRadius.circular(20),
              ),
            );
          },
        ),
        SizedBox(height: 40),
        ElevatedButton(
          onPressed: () {
            if (_controller.status == AnimationStatus.completed) {
              _controller.reverse();
            } else {
              _controller.forward();
            }
          },
          child: Text('Run Animation'),
        ),
      ],
    );
  }
}

Common Tween Types

// Number (double)
Tween<double>(begin: 0, end: 100)

// Number (int)
IntTween(begin: 0, end: 255)

// Color
ColorTween(begin: Colors.red, end: Colors.blue)

// Offset (position)
Tween<Offset>(begin: Offset.zero, end: Offset(1.0, 0.0))

// Size
Tween<Size>(begin: Size(50, 50), end: Size(200, 200))

// BorderRadius
Tween<BorderRadius>(
  begin: BorderRadius.circular(0),
  end: BorderRadius.circular(50),
)

// TextStyle
TextStyleTween(
  begin: TextStyle(fontSize: 14),
  end: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
)

CurvedAnimation: Adding Character to Animation

Tween transforms values, CurvedAnimation determines the character of the animation:

_controller = AnimationController(
  duration: Duration(seconds: 2),
  vsync: this,
);

// Apply curve
final curvedAnimation = CurvedAnimation(
  parent: _controller,
  curve: Curves.elasticOut, // Elastic effect
);

// Use with Tween
_sizeAnimation = Tween<double>(
  begin: 50,
  end: 200,
).animate(curvedAnimation);

Different Curves

// Smooth in-out
Curves.easeInOut

// Elastic (like a spring)
Curves.elasticOut

// Bounce
Curves.bounceOut

// Overshoot
Curves.anticipate

// Acceleration
Curves.accelerate

// Deceleration
Curves.decelerate

AnimatedBuilder: Performance-Friendly Rebuild

AnimatedBuilder rebuilds only the necessary widget:

AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    // This part rebuilds every frame
    return Transform.rotate(
      angle: _controller.value * 2 * 3.14159,
      child: child, // This doesn't rebuild!
    );
  },
  child: Icon(Icons.star, size: 100), // Static, cached
)

Performance Tip: The child parameter is used for widgets that don't change during animation. Flutter caches it and doesn't recreate it every frame.

Animation Status: Listening to State

You can track the status of the animation:

@override
void initState() {
  super.initState();
  _controller = AnimationController(
    duration: Duration(seconds: 2),
    vsync: this,
  );

  _controller.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      print('Animation completed!');
      _controller.reverse(); // Auto reverse
    } else if (status == AnimationStatus.dismissed) {
      print('Animation back to start!');
    }
  });
}

Status Types

AnimationStatus.forward    // Going forward (0 β†’ 1)
AnimationStatus.reverse    // Going backward (1 β†’ 0)
AnimationStatus.completed  // Completed (at 1.0)
AnimationStatus.dismissed  // Back to start (at 0.0)

Real World Examples

1. Infinite Rotation (Loading Spinner)

class LoadingSpinner extends StatefulWidget {
  @override
  _LoadingSpinnerState createState() => _LoadingSpinnerState();
}

class _LoadingSpinnerState extends State<LoadingSpinner> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..repeat(); // Infinite loop
  }

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.rotate(
            angle: _controller.value * 2 * 3.14159,
            child: child,
          );
        },
        child: Icon(Icons.refresh, size: 50, color: Colors.blue),
      ),
    );
  }
}

2. Pulsing Heart Animation

class PulsingHeart extends StatefulWidget {
  @override
  _PulsingHeartState createState() => _PulsingHeartState();
}

class _PulsingHeartState extends State<PulsingHeart> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 800),
      vsync: this,
    )..repeat(reverse: true); // Back and forth

    _scaleAnimation = Tween<double>(
      begin: 0.8,
      end: 1.2,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
  }

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _scaleAnimation,
        builder: (context, child) {
          return Transform.scale(
            scale: _scaleAnimation.value,
            child: child,
          );
        },
        child: Icon(Icons.favorite, size: 100, color: Colors.red),
      ),
    );
  }
}

3. Fade In and Slide Up Combination

class FadeSlideIn extends StatefulWidget {
  final Widget child;

  FadeSlideIn({required this.child});

  @override
  _FadeSlideInState createState() => _FadeSlideInState();
}

class _FadeSlideInState extends State<FadeSlideIn> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _opacityAnimation;
  late Animation<Offset> _slideAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 600),
      vsync: this,
    );

    _opacityAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));

    _slideAnimation = Tween<Offset>(
      begin: Offset(0, 0.3), // From below
      end: Offset.zero,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));

    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return FadeTransition(
          opacity: _opacityAnimation,
          child: SlideTransition(
            position: _slideAnimation,
            child: child,
          ),
        );
      },
      child: widget.child,
    );
  }
}

// Usage:
FadeSlideIn(
  child: Text('Hello World!', style: TextStyle(fontSize: 32)),
)

4. Progress Bar Animation

class AnimatedProgressBar extends StatefulWidget {
  final double progress; // 0.0 - 1.0

  AnimatedProgressBar({required this.progress});

  @override
  _AnimatedProgressBarState createState() => _AnimatedProgressBarState();
}

class _AnimatedProgressBarState extends State<AnimatedProgressBar> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _progressAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 500),
      vsync: this,
    );

    _progressAnimation = Tween<double>(
      begin: 0,
      end: widget.progress,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));

    _controller.forward();
  }

  @override
  void didUpdateWidget(AnimatedProgressBar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.progress != oldWidget.progress) {
      _progressAnimation = Tween<double>(
        begin: _progressAnimation.value,
        end: widget.progress,
      ).animate(CurvedAnimation(
        parent: _controller,
        curve: Curves.easeOut,
      ));
      _controller.forward(from: 0);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      height: 20,
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(10),
      ),
      child: AnimatedBuilder(
        animation: _progressAnimation,
        builder: (context, child) {
          return FractionallySizedBox(
            alignment: Alignment.centerLeft,
            widthFactor: _progressAnimation.value,
            child: Container(
              decoration: BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.circular(10),
              ),
            ),
          );
        },
      ),
    );
  }
}

5. Shake Animation

class ShakeWidget extends StatefulWidget {
  final Widget child;
  final bool shake;

  ShakeWidget({required this.child, this.shake = false});

  @override
  _ShakeWidgetState createState() => _ShakeWidgetState();
}

class _ShakeWidgetState extends State<ShakeWidget> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _shakeAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 500),
      vsync: this,
    );

    _shakeAnimation = Tween<double>(
      begin: 0,
      end: 10,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.elasticIn,
    ));
  }

  @override
  void didUpdateWidget(ShakeWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.shake && !oldWidget.shake) {
      _controller.forward(from: 0).then((_) => _controller.reverse());
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _shakeAnimation,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(_shakeAnimation.value * (_controller.value > 0.5 ? -1 : 1), 0),
          child: child,
        );
      },
      child: widget.child,
    );
  }
}

// Usage:
class LoginScreen extends StatefulWidget {
  @override
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  bool _showError = false;

  void _login() {
    // Failed login
    setState(() => _showError = true);
    Future.delayed(Duration(milliseconds: 500), () {
      setState(() => _showError = false);
    });
  }

  @override
  Widget build(BuildContext context) {
    return ShakeWidget(
      shake: _showError,
      child: TextField(
        decoration: InputDecoration(
          labelText: 'Password',
          errorText: _showError ? 'Wrong password!' : null,
        ),
      ),
    );
  }
}

Synchronizing Multiple Animations

Multiple animations with a single controller:

class MultiAnimationDemo extends StatefulWidget {
  @override
  _MultiAnimationDemoState createState() => _MultiAnimationDemoState();
}

class _MultiAnimationDemoState extends State<MultiAnimationDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;
  late Animation<double> _scaleAnimation;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 3),
      vsync: this,
    );

    // Rotation during 0.0 - 0.5
    _rotationAnimation = Tween<double>(
      begin: 0,
      end: 2 * 3.14159,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.0, 0.5, curve: Curves.easeInOut),
    ));

    // Scaling during 0.5 - 1.0
    _scaleAnimation = Tween<double>(
      begin: 1.0,
      end: 2.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.5, 1.0, curve: Curves.easeOut),
    ));

    // Color change during 0.0 - 1.0
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);
  }

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Transform.rotate(
              angle: _rotationAnimation.value,
              child: Transform.scale(
                scale: _scaleAnimation.value,
                child: Container(
                  width: 100,
                  height: 100,
                  decoration: BoxDecoration(
                    color: _colorAnimation.value,
                    borderRadius: BorderRadius.circular(20),
                  ),
                ),
              ),
            );
          },
        ),
        SizedBox(height: 40),
        ElevatedButton(
          onPressed: () {
            if (_controller.status == AnimationStatus.completed) {
              _controller.reverse();
            } else {
              _controller.forward();
            }
          },
          child: Text('Start Animation'),
        ),
      ],
    );
  }
}

TweenSequence: Step-by-Step Animation

Different values at different stages:

final _colorAnimation = TweenSequence<Color?>([
  TweenSequenceItem(
    tween: ColorTween(begin: Colors.red, end: Colors.blue),
    weight: 33.3, // 33.3% portion
  ),
  TweenSequenceItem(
    tween: ColorTween(begin: Colors.blue, end: Colors.green),
    weight: 33.3,
  ),
  TweenSequenceItem(
    tween: ColorTween(begin: Colors.green, end: Colors.red),
    weight: 33.4,
  ),
]).animate(_controller);

Performance Tips

1. Don't Forget dispose()

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

If you forget, you'll have a memory leak.

2. Use AnimatedBuilder

// ❌ Bad - Entire widget rebuilds
@override
Widget build(BuildContext context) {
  return Transform.rotate(
    angle: _controller.value * 2 * 3.14159,
    child: ExpensiveWidget(),
  );
}

// βœ… Good - Only necessary part rebuilds
@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _controller,
    builder: (context, child) {
      return Transform.rotate(
        angle: _controller.value * 2 * 3.14159,
        child: child,
      );
    },
    child: ExpensiveWidget(), // Cached
  );
}

3. Don't Animate Unnecessarily

// ❌ 100 widgets animating at once
ListView.builder(
  itemCount: 100,
  itemBuilder: (context, index) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.scale(
          scale: 1.0 + _controller.value * 0.2,
          child: ListTile(title: Text('Item $index')),
        );
      },
    );
  },
)

// βœ… Good - Only visible ones should animate (lazy loading)

4. Cache const Widgets

AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2 * 3.14159,
      child: child,
    );
  },
  child: const Icon(Icons.star, size: 100), // const!
)

Common Mistakes and Solutions

Error 1: Forgetting vsync

// ❌ Error: vsync required
_controller = AnimationController(
  duration: Duration(seconds: 2),
);

// βœ… Correct
class _MyWidgetState extends State<MyWidget> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this, // this is TickerProvider
    );
  }
}

Error 2: Forgetting dispose()

// ❌ Memory leak!
@override
void dispose() {
  super.dispose();
}

// βœ… Correct
@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

Error 3: Wrong Mixin Choice

// ❌ 3 animations but Single is used
class _MyWidgetState extends State<MyWidget> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller1;
  late AnimationController _controller2;
  late AnimationController _controller3; // Error!
}

// βœ… Correct
class _MyWidgetState extends State<MyWidget> 
    with TickerProviderStateMixin {
  late AnimationController _controller1;
  late AnimationController _controller2;
  late AnimationController _controller3;
}

Error 4: Duration Too Long

// ❌ 10 seconds is too long
AnimationController(
  duration: Duration(seconds: 10),
  vsync: this,
)

// βœ… 2-3 seconds is ideal
AnimationController(
  duration: Duration(seconds: 2),
  vsync: this,
)

Summary

  • AnimationController: Control mechanism that produces values between 0.0 - 1.0
  • vsync: Automatically stops animations not on screen (battery saving)
  • Tween: Value transformation (0-1 β†’ 50-200, etc.)
  • CurvedAnimation: Adds character to animation (easeInOut, elasticOut, etc.)
  • AnimatedBuilder: Rebuilds only necessary widget for performance
  • Status Listener: Listens to animation state (completed, dismissed, etc.)
  • Methods: forward(), reverse(), repeat(), reset(), stop()
  • Mixin: SingleTickerProviderStateMixin (1 animation) or TickerProviderStateMixin (multiple)
  • dispose(): Mandatory to prevent memory leaks
  • Performance: Use const child, AnimatedBuilder

Explicit animations give you full control over animations in Flutter. Although the learning curve is a bit steeper, the impressive animations you'll create are worth it!