DartFlutter

Custom Animations with AnimationController & Tween

Custom Animations With AnimationController Tween

Introduction

Animations make Flutter apps feel smooth, responsive, and delightful to use. When implemented well, they help users understand what’s happening on screen, provide feedback for interactions, and guide attention naturally. While Flutter provides many ready-made animated widgets like AnimatedContainer and Hero, custom animations using AnimationController and Tween give you precise control over timing, curves, sequences, and complex coordinated motion.

In this comprehensive guide, you’ll learn how these animation primitives work together, how to build performant custom animations from scratch, and advanced patterns for staggered animations, gesture-driven interactions, and physics-based motion.

Why Custom Animations Matter in Flutter

Animations aren’t just about visual appeal—they’re a fundamental part of user experience that communicates state changes, guides navigation, and provides feedback.

  • Clarify transitions: Show users where content comes from and goes to.
  • Improve perceived performance: Smooth loading states feel faster than sudden changes.
  • Guide attention: Draw focus to important elements naturally.
  • Provide feedback: Confirm that interactions were registered.
  • Express brand identity: Custom motion can differentiate your app.

Flutter Animation Fundamentals

Understanding the core concepts is essential for building effective custom animations.

// The animation system hierarchy
//
// AnimationController - Produces values from 0.0 to 1.0 over duration
//        ↓
// CurvedAnimation - Applies easing curves to the raw values
//        ↓
// Tween - Maps 0.0-1.0 to any range (colors, offsets, sizes, etc.)
//        ↓
// Animation<T> - The final animated value used by widgets

AnimationController Deep Dive

The AnimationController is the engine that drives all explicit animations. It produces values over time and provides control methods.

import 'package:flutter/material.dart';

class AnimatedLogo extends StatefulWidget {
  const AnimatedLogo({super.key});

  @override
  State<AnimatedLogo> createState() => _AnimatedLogoState();
}

class _AnimatedLogoState extends State<AnimatedLogo>
    with SingleTickerProviderStateMixin {
  // Controller manages animation timing and state
  late AnimationController _controller;
  
  // Animation holds the interpolated value
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    
    // Create controller with vsync for efficiency
    // vsync prevents animation work when widget is off-screen
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );
    
    // Create tween and connect to controller
    _animation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(_controller);
    
    // Start the animation
    _controller.forward();
  }

  @override
  void dispose() {
    // CRITICAL: Always dispose controllers to prevent memory leaks
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Opacity(
          opacity: _animation.value,
          child: child,
        );
      },
      // child is not rebuilt during animation - performance optimization
      child: const FlutterLogo(size: 100),
    );
  }
}

Controller Methods and Properties

class AnimationControllerDemo extends StatefulWidget {
  const AnimationControllerDemo({super.key});

  @override
  State<AnimationControllerDemo> createState() => _AnimationControllerDemoState();
}

class _AnimationControllerDemoState extends State<AnimationControllerDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
      // Optional: Set bounds other than 0.0-1.0
      lowerBound: 0.0,
      upperBound: 1.0,
    );
    
    // Listen to animation value changes
    _controller.addListener(() {
      print('Value: ${_controller.value}');
    });
    
    // Listen to animation status changes
    _controller.addStatusListener((status) {
      switch (status) {
        case AnimationStatus.dismissed:
          print('Animation at start');
          break;
        case AnimationStatus.forward:
          print('Animation running forward');
          break;
        case AnimationStatus.reverse:
          print('Animation running reverse');
          break;
        case AnimationStatus.completed:
          print('Animation completed');
          break;
      }
    });
  }

  void _playAnimations() {
    // Play forward from current position
    _controller.forward();
    
    // Play forward from beginning
    _controller.forward(from: 0.0);
    
    // Play reverse from current position
    _controller.reverse();
    
    // Play reverse from end
    _controller.reverse(from: 1.0);
    
    // Reset to beginning without animation
    _controller.reset();
    
    // Jump to specific value without animation
    _controller.value = 0.5;
    
    // Animate to specific value
    _controller.animateTo(0.75);
    
    // Repeat animation
    _controller.repeat();
    
    // Repeat with reverse
    _controller.repeat(reverse: true);
    
    // Stop animation
    _controller.stop();
  }

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

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

Tweens: Value Interpolation

A Tween defines how values change from a start point to an end point. It maps the controller’s 0.0-1.0 range to any value type.

class TweenExamples extends StatefulWidget {
  const TweenExamples({super.key});

  @override
  State<TweenExamples> createState() => _TweenExamplesState();
}

class _TweenExamplesState extends State<TweenExamples>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  // Different tween types
  late Animation<double> _opacityAnimation;
  late Animation<double> _sizeAnimation;
  late Animation<Offset> _slideAnimation;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _rotationAnimation;
  late Animation<BorderRadius?> _borderRadiusAnimation;

  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );
    
    // Double tween for opacity
    _opacityAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(_controller);
    
    // Double tween for size
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 150.0,
    ).animate(_controller);
    
    // Offset tween for slide transitions
    _slideAnimation = Tween<Offset>(
      begin: const Offset(0.0, 1.0), // Start below
      end: Offset.zero,              // End at normal position
    ).animate(_controller);
    
    // Color tween
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);
    
    // Rotation in radians
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * 3.14159, // Full rotation
    ).animate(_controller);
    
    // Border radius tween
    _borderRadiusAnimation = BorderRadiusTween(
      begin: BorderRadius.circular(0),
      end: BorderRadius.circular(50),
    ).animate(_controller);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          width: _sizeAnimation.value,
          height: _sizeAnimation.value,
          decoration: BoxDecoration(
            color: _colorAnimation.value,
            borderRadius: _borderRadiusAnimation.value,
          ),
          child: Transform.rotate(
            angle: _rotationAnimation.value,
            child: Opacity(
              opacity: _opacityAnimation.value,
              child: const Icon(Icons.star, color: Colors.white),
            ),
          ),
        );
      },
    );
  }
}

Curves for Natural Motion

Linear animations feel mechanical. Curves add easing that makes motion feel natural and polished.

class CurvedAnimationExample extends StatefulWidget {
  const CurvedAnimationExample({super.key});

  @override
  State<CurvedAnimationExample> createState() => _CurvedAnimationExampleState();
}

class _CurvedAnimationExampleState extends State<CurvedAnimationExample>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 600),
    );
    
    // Apply curve to animation
    final curvedAnimation = CurvedAnimation(
      parent: _controller,
      // Different curves for forward and reverse
      curve: Curves.easeOutCubic,
      reverseCurve: Curves.easeInCubic,
    );
    
    _animation = Tween<double>(
      begin: 0.0,
      end: 200.0,
    ).animate(curvedAnimation);
  }

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

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

// Common curves and when to use them:
//
// Curves.linear          - Constant speed, rarely used
// Curves.easeIn          - Slow start, fast end - exiting elements
// Curves.easeOut         - Fast start, slow end - entering elements
// Curves.easeInOut       - Slow start and end - general purpose
// Curves.easeOutCubic    - Smooth deceleration - recommended default
// Curves.easeOutBack     - Overshoots slightly - playful feel
// Curves.bounceOut       - Bouncy ending - fun, attention-grabbing
// Curves.elasticOut      - Spring effect - dynamic, energetic
// Curves.fastOutSlowIn   - Material Design standard curve

AnimatedBuilder vs AnimatedWidget

// Option 1: AnimatedBuilder - inline, flexible
class UsingAnimatedBuilder extends StatefulWidget {
  const UsingAnimatedBuilder({super.key});

  @override
  State<UsingAnimatedBuilder> createState() => _UsingAnimatedBuilderState();
}

class _UsingAnimatedBuilderState extends State<UsingAnimatedBuilder>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );
    _animation = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      // child is cached and not rebuilt during animation
      child: const Text('Hello'),
      builder: (context, child) {
        return Transform.scale(
          scale: _animation.value,
          child: child, // Reuse cached child
        );
      },
    );
  }
}

// Option 2: AnimatedWidget - reusable, cleaner
class ScaleAnimatedWidget extends AnimatedWidget {
  final Widget child;

  const ScaleAnimatedWidget({
    super.key,
    required Animation<double> animation,
    required this.child,
  }) : super(listenable: animation);

  Animation<double> get _animation => listenable as Animation<double>;

  @override
  Widget build(BuildContext context) {
    return Transform.scale(
      scale: _animation.value,
      child: child,
    );
  }
}

// Usage
class UsingAnimatedWidget extends StatefulWidget {
  const UsingAnimatedWidget({super.key});

  @override
  State<UsingAnimatedWidget> createState() => _UsingAnimatedWidgetState();
}

class _UsingAnimatedWidgetState extends State<UsingAnimatedWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOutCubic,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return ScaleAnimatedWidget(
      animation: _animation,
      child: const FlutterLogo(size: 100),
    );
  }
}

Staggered Animations

Staggered animations create sequences where different elements animate at different times, creating a flowing effect.

class StaggeredAnimation extends StatefulWidget {
  const StaggeredAnimation({super.key});

  @override
  State<StaggeredAnimation> createState() => _StaggeredAnimationState();
}

class _StaggeredAnimationState extends State<StaggeredAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  // Staggered intervals
  late Animation<double> _fadeAnimation;
  late Animation<Offset> _slideAnimation;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1500),
    );
    
    // First: Fade in (0% - 30% of total duration)
    _fadeAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(
          0.0,
          0.3,
          curve: Curves.easeOut,
        ),
      ),
    );
    
    // Second: Slide up (20% - 60% of total duration)
    _slideAnimation = Tween<Offset>(
      begin: const Offset(0, 0.5),
      end: Offset.zero,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(
          0.2,
          0.6,
          curve: Curves.easeOutCubic,
        ),
      ),
    );
    
    // Third: Scale up (50% - 100% of total duration)
    _scaleAnimation = Tween<double>(
      begin: 0.8,
      end: 1.0,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(
          0.5,
          1.0,
          curve: Curves.easeOutBack,
        ),
      ),
    );
    
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return FadeTransition(
          opacity: _fadeAnimation,
          child: SlideTransition(
            position: _slideAnimation,
            child: ScaleTransition(
              scale: _scaleAnimation,
              child: child,
            ),
          ),
        );
      },
      child: Container(
        width: 200,
        height: 200,
        decoration: BoxDecoration(
          color: Colors.blue,
          borderRadius: BorderRadius.circular(16),
        ),
        child: const Center(
          child: Text(
            'Staggered',
            style: TextStyle(color: Colors.white, fontSize: 24),
          ),
        ),
      ),
    );
  }
}

// Staggered list animation
class StaggeredListAnimation extends StatefulWidget {
  final List<String> items;
  
  const StaggeredListAnimation({super.key, required this.items});

  @override
  State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}

class _StaggeredListAnimationState extends State<StaggeredListAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late List<Animation<double>> _itemAnimations;

  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 200 * widget.items.length + 500),
    );
    
    // Create staggered animations for each item
    _itemAnimations = List.generate(widget.items.length, (index) {
      final start = index * 0.1; // 10% delay between each item
      final end = start + 0.4;   // Each animation lasts 40% of total
      
      return Tween<double>(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: _controller,
          curve: Interval(
            start.clamp(0.0, 1.0),
            end.clamp(0.0, 1.0),
            curve: Curves.easeOutCubic,
          ),
        ),
      );
    });
    
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        return AnimatedBuilder(
          animation: _itemAnimations[index],
          builder: (context, child) {
            return Transform.translate(
              offset: Offset(0, 50 * (1 - _itemAnimations[index].value)),
              child: Opacity(
                opacity: _itemAnimations[index].value,
                child: child,
              ),
            );
          },
          child: ListTile(
            title: Text(widget.items[index]),
          ),
        );
      },
    );
  }
}

Gesture-Driven Animations

class DraggableCard extends StatefulWidget {
  const DraggableCard({super.key});

  @override
  State<DraggableCard> createState() => _DraggableCardState();
}

class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  Offset _dragOffset = Offset.zero;
  Offset _startOffset = Offset.zero;

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

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

  void _onPanStart(DragStartDetails details) {
    _controller.stop();
    _startOffset = _dragOffset;
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      _dragOffset += details.delta;
    });
  }

  void _onPanEnd(DragEndDetails details) {
    // Animate back to center
    final animation = Tween<Offset>(
      begin: _dragOffset,
      end: Offset.zero,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Curves.easeOutCubic,
      ),
    );
    
    animation.addListener(() {
      setState(() {
        _dragOffset = animation.value;
      });
    });
    
    _controller.forward(from: 0.0);
  }

  @override
  Widget build(BuildContext context) {
    final rotation = _dragOffset.dx / 500; // Rotate based on horizontal drag
    
    return GestureDetector(
      onPanStart: _onPanStart,
      onPanUpdate: _onPanUpdate,
      onPanEnd: _onPanEnd,
      child: Transform(
        transform: Matrix4.identity()
          ..translate(_dragOffset.dx, _dragOffset.dy)
          ..rotateZ(rotation),
        alignment: Alignment.center,
        child: Container(
          width: 300,
          height: 400,
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withOpacity(0.2),
                blurRadius: 20,
                offset: const Offset(0, 10),
              ),
            ],
          ),
          child: const Center(
            child: Text('Drag me!'),
          ),
        ),
      ),
    );
  }
}

Multiple Animation Controllers

class MultipleAnimations extends StatefulWidget {
  const MultipleAnimations({super.key});

  @override
  State<MultipleAnimations> createState() => _MultipleAnimationsState();
}

class _MultipleAnimationsState extends State<MultipleAnimations>
    with TickerProviderStateMixin { // Note: TickerProviderStateMixin for multiple
  late AnimationController _rotationController;
  late AnimationController _scaleController;
  late AnimationController _colorController;

  @override
  void initState() {
    super.initState();
    
    // Independent animation controllers
    _rotationController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat(); // Continuous rotation
    
    _scaleController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    )..repeat(reverse: true); // Pulsing scale
    
    _colorController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
    )..repeat(reverse: true); // Color cycling
  }

  @override
  void dispose() {
    _rotationController.dispose();
    _scaleController.dispose();
    _colorController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      // Combine multiple animations with Listenable.merge
      animation: Listenable.merge([
        _rotationController,
        _scaleController,
        _colorController,
      ]),
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationController.value * 2 * 3.14159,
          child: Transform.scale(
            scale: 0.8 + (_scaleController.value * 0.4),
            child: Container(
              width: 100,
              height: 100,
              decoration: BoxDecoration(
                color: ColorTween(
                  begin: Colors.blue,
                  end: Colors.purple,
                ).evaluate(_colorController),
                borderRadius: BorderRadius.circular(16),
              ),
            ),
          ),
        );
      },
    );
  }
}

Common Mistakes to Avoid

1. Not Disposing Controllers

// WRONG - Memory leak
class BadAnimation extends StatefulWidget {
  // ...
}

class _BadAnimationState extends State<BadAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);
  }
  
  // Missing dispose()!
}

// CORRECT - Always dispose
class GoodAnimation extends StatefulWidget {
  const GoodAnimation({super.key});

  @override
  State<GoodAnimation> createState() => _GoodAnimationState();
}

class _GoodAnimationState extends State<GoodAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

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

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

  @override
  Widget build(BuildContext context) => const Placeholder();
}

2. Rebuilding the Entire Widget Tree

// WRONG - Rebuilds everything including static child
@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animation,
    builder: (context, _) {
      return Opacity(
        opacity: _animation.value,
        child: ExpensiveWidget(), // Rebuilt every frame!
      );
    },
  );
}

// CORRECT - Child is cached
@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animation,
    child: const ExpensiveWidget(), // Built once, cached
    builder: (context, child) {
      return Opacity(
        opacity: _animation.value,
        child: child, // Reused
      );
    },
  );
}

3. Using setState Instead of AnimatedBuilder

// WRONG - Causes unnecessary rebuilds
_controller.addListener(() {
  setState(() {}); // Rebuilds entire widget
});

// CORRECT - Use AnimatedBuilder for targeted rebuilds
AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    // Only this subtree rebuilds
    return Transform.scale(
      scale: _animation.value,
      child: child,
    );
  },
  child: const StaticContent(),
)

Best Practices Summary

  • Always dispose controllers: Prevent memory leaks in dispose() method.
  • Use vsync: Prevents animation work when widget is off-screen.
  • Cache static children: Pass child parameter to AnimatedBuilder.
  • Apply curves: Easing makes motion feel natural and polished.
  • Keep animations short: 200-500ms for UI transitions, 800-1200ms for emphasis.
  • Respect accessibility: Check MediaQuery.disableAnimations.
  • Test on real devices: Animations may perform differently than simulators.

Conclusion

Custom animations with AnimationController and Tween give Flutter developers precise control over motion. By combining controllers, tweens, curves, and intervals, you can build smooth transitions, staggered sequences, and gesture-driven interactions that improve clarity and usability. Understanding when to use AnimatedBuilder versus implicit animations helps you choose the right tool for each situation.

If you want to create scalable UI systems, read Building Reusable UI Components in Flutter: Best Practices. For performance-focused guidance, see Flutter Performance Optimization Tips: Build Faster, Run Smoother. For official documentation, explore the Flutter animation documentation and the AnimationController API reference.

Leave a Comment