Flutter Lesson 14: Animations and Transitions



This content originally appeared on DEV Community and was authored by Ge Ji

Animations are crucial for enhancing user experience, making app interfaces more lively, intuitive, and engaging. Flutter provides a powerful animation system that supports the implementation of various complex animation effects. This lesson will detailedly introduce the types and implementation methods of animations in Flutter, from basic implicit animations to complex custom explicit animations, helping you master the skills of adding smooth animation effects to your applications.

I. Flutter Animation Basics

Flutter’s animation system is based on the following core concepts:

  • Animation: An object that generates values between 0.0 and 1.0. It doesn’t contain rendering content itself but only provides animation values.
  • Curve: Defines the speed change of animation progress, such as acceleration and deceleration.
  • Controller: Controls the playback, pause, reverse, etc., of the animation and manages the animation life cycle.
  • Tween: Defines the start and end values of the animation, mapping the 0-1 values provided by Animation to the actual required value range.
  • Animatable: An object that can generate Tweens, supporting more complex value mapping.

Flutter animations are mainly divided into two categories:

  • Implicit animations: Animations automatically managed by Flutter, where you only need to define the start and end states.
  • Explicit animations: Animations that need to be manually controlled, providing more customization options and control capabilities.

II. Implicit Animations

Implicit animations are the simplest way to implement animations. Flutter provides a series of encapsulated implicit animation widgets that automatically transition smoothly from old values to new values when certain properties of the widget change.

1. AnimatedContainer

AnimatedContainer is the most commonly used implicit animation widget. When its properties (such as size, color, margin, etc.) change, it automatically produces transition animations.

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

  @override
  State<AnimatedContainerDemo> createState() => _AnimatedContainerDemoState();
}

class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {
  // State variable to control container properties
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedContainer Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedContainer(
              // Animation duration
              duration: const Duration(seconds: 1),
              // Animation curve
              curve: Curves.easeInOut,
              // Dynamically changing properties
              width: _isExpanded ? 300 : 100,
              height: _isExpanded ? 300 : 100,
              color: _isExpanded ? Colors.blue : Colors.red,
              padding: _isExpanded 
                  ? const EdgeInsets.all(20) 
                  : const EdgeInsets.all(10),
              // Content inside the container
              child: const Center(
                child: Text(
                  'Animate me!',
                  style: TextStyle(color: Colors.white, fontSize: 20),
                ),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // Toggle state when button is clicked to trigger animation
                setState(() {
                  _isExpanded = !_isExpanded;
                });
              },
              child: const Text('Toggle Animation'),
            )
          ],
        ),
      ),
    );
  }
}

2. Other Common Implicit Animation Widgets

Flutter also provides other implicit animation widgets for specific purposes:

AnimatedOpacity

Animation that controls the transparency change of a widget:

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

  @override
  State<AnimatedOpacityDemo> createState() => _AnimatedOpacityDemoState();
}

class _AnimatedOpacityDemoState extends State<AnimatedOpacityDemo> {
  double _opacity = 1.0;

  void _toggleOpacity() {
    setState(() {
      _opacity = _opacity == 1.0 ? 0.0 : 1.0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedOpacity Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedOpacity(
              opacity: _opacity,
              duration: const Duration(seconds: 1),
              curve: Curves.fastOutSlowIn,
              child: Container(
                width: 200,
                height: 200,
                color: Colors.green,
                child: const Center(
                  child: Text(
                    'Fade me!',
                    style: TextStyle(color: Colors.white, fontSize: 24),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _toggleOpacity,
              child: const Text('Toggle Opacity'),
            )
          ],
        ),
      ),
    );
  }
}

AnimatedPositioned

Used in Stack to animate the position change of child widgets:

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

  @override
  State<AnimatedPositionedDemo> createState() => _AnimatedPositionedDemoState();
}

class _AnimatedPositionedDemoState extends State<AnimatedPositionedDemo> {
  bool _isMoved = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedPositioned Demo')),
      body: Stack(
        children: [
          AnimatedPositioned(
            duration: const Duration(seconds: 1),
            curve: Curves.bounceOut,
            left: _isMoved ? 200 : 50,
            top: _isMoved ? 300 : 100,
            width: 100,
            height: 100,
            child: Container(
              color: Colors.purple,
              child: const Center(
                child: Text(
                  'Move me!',
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
          ),
          Positioned(
            bottom: 50,
            left: 0,
            right: 0,
            child: Center(
              child: ElevatedButton(
                onPressed: () {
                  setState(() {
                    _isMoved = !_isMoved;
                  });
                },
                child: const Text('Move the Box'),
              ),
            ),
          )
        ],
      ),
    );
  }
}

AnimatedPadding, AnimatedSize, etc.

There are other similar implicit animation widgets with similar usage:

  • AnimatedPadding: Controls the change of padding.
  • AnimatedSize: Automatically adjusts according to the size of the child widget and produces animation.
  • AnimatedTransform: Controls the animation of transformation effects.
  • AnimatedDefaultTextStyle: Controls the animation of text style changes.

3. Advantages and Disadvantages of Implicit Animations

Advantages:

  • Easy to use, just modify the state to trigger the animation.
  • No need to manually manage the animation controller.
  • Suitable for implementing simple transition effects.

Disadvantages:

  • Low customization, unable to implement complex animations.
  • Lack of fine control capabilities (such as pause, reverse playback).
  • Difficult to coordinate when multiple properties are animated at the same time.

III. Explicit Animations

Explicit animations require manual creation and management of animation controllers, providing more precise control and greater flexibility, suitable for implementing complex animation effects.

1. AnimationController and Animation

AnimationController is the core of explicit animations, responsible for controlling the time and state of the animation:

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

  @override
  State<BasicExplicitAnimation> createState() => _BasicExplicitAnimationState();
}

class _BasicExplicitAnimationState extends State<BasicExplicitAnimation> 
    with SingleTickerProviderStateMixin {
  // Animation controller
  late AnimationController _controller;
  // Animation object
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    // Initialize the animation controller
    _controller = AnimationController(
      vsync: this, // Associated with the current widget's lifecycle
      duration: const Duration(seconds: 2), // Animation duration
    );

    // Create an animation from 0 to 300
    _animation = Tween<double>(begin: 0, end: 300).animate(_controller)
      // Listen for animation value changes to trigger reconstruction
      ..addListener(() {
        setState(() {});
      })
      // Listen for animation state changes
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          // Reverse playback after animation completion
          _controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          // Forward playback after animation returns to the starting point
          _controller.forward();
        }
      });

    // Start the animation
    _controller.forward();
  }

  @override
  void dispose() {
    // Release the animation controller resources
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Basic Explicit Animation')),
      body: Center(
        child: Container(
          width: _animation.value, // Use the animation value
          height: _animation.value,
          color: Colors.orange,
          child: const Center(
            child: Text(
              'Growing Box',
              style: TextStyle(color: Colors.white, fontSize: 18),
            ),
          ),
        ),
      ),
    );
  }
}

Note: SingleTickerProviderStateMixin provides a vsync callback to prevent the animation from continuing to consume resources when the widget is invisible. If multiple animation controllers are needed, TickerProviderStateMixin should be used.

2. CurvedAnimation

Using CurvedAnimation can add non-linear speed changes to the animation:

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

  @override
  State<CurvedAnimationDemo> createState() => _CurvedAnimationDemoState();
}

class _CurvedAnimationDemoState extends State<CurvedAnimationDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

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

    // Create a curved animation
    final curve = CurvedAnimation(
      parent: _controller,
      curve: Curves.bounceOut, // Bounce effect
      reverseCurve: Curves.bounceIn, // Curve for reverse direction
    );

    // Use the curved animation
    _animation = Tween<double>(begin: 50, end: 300).animate(curve)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          _controller.forward();
        }
      });

    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Curved Animation')),
      body: Center(
        child: Container(
          width: _animation.value,
          height: 100,
          color: Colors.pink,
          child: const Center(
            child: Text(
              'Bouncy!',
              style: TextStyle(color: Colors.white, fontSize: 20),
            ),
          ),
        ),
      ),
    );
  }
}

Flutter provides a variety of predefined curves, such as:

  • Curves.linear: Linear animation
  • Curves.easeIn, Curves.easeOut, Curves.easeInOut: Ease in, ease out, ease in and out
  • Curves.bounceIn, Curves.bounceOut: Bounce effect
  • Curves.elasticIn, Curves.elasticOut: Elastic effect

3. Tween and Multi-property Animations

Tween defines the value range of the animation, which can be any type. Multiple Tweens can be combined to implement multi-property animations:

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

  @override
  State<MultiPropertyAnimation> createState() => _MultiPropertyAnimationState();
}

class _MultiPropertyAnimationState extends State<MultiPropertyAnimation> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<double> _opacityAnimation;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();

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

    // Size animation
    _sizeAnimation = Tween<double>(begin: 50, end: 250).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut)
    );

    // Opacity animation
    _opacityAnimation = Tween<double>(begin: 0.2, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: const Interval(0.3, 1.0)) // Start with delay
    );

    // Color animation
    _colorAnimation = ColorTween(begin: Colors.blue, end: Colors.green).animate(
      CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.7)) // End early
    );

    // Listen for animation value changes
    _controller.addListener(() {
      setState(() {});
    });

    // Play in a loop
    _controller.repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multi-property Animation')),
      body: Center(
        child: Opacity(
          opacity: _opacityAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            decoration: BoxDecoration(
              color: _colorAnimation.value,
              // Using borderRadius property
              borderRadius: BorderRadius.circular(_sizeAnimation.value / 10)
            ),
            child: const Center(
              child: Text(
                'Fancy!',
                style: TextStyle(color: Colors.white, fontSize: 24),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Interval can set different time intervals for different animation properties, enabling more complex animation choreography.

4. AnimatedBuilder for Optimizing Reconstruction Performance

Using addListener with setState will cause the entire widget to rebuild. Using AnimatedBuilder can only rebuild the part that needs animation, improving performance:

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

  @override
  State<AnimatedBuilderDemo> createState() => _AnimatedBuilderDemoState();
}

class _AnimatedBuilderDemoState extends State<AnimatedBuilderDemo> 
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;

  @override
  void initState() {
    super.initState();

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

    _rotationAnimation = Tween<double>(begin: 0, end: 2 * 3.14159).animate(
      CurvedAnimation(parent: _controller, curve: Curves.linear)
    );

    _controller.repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedBuilder Demo')),
      body: Center(
        // Wrap the part that needs animation with AnimatedBuilder
        child: AnimatedBuilder(
          animation: _rotationAnimation,
          // The builder method only rebuilds the animation-related part
          builder: (context, child) {
            return Transform.rotate(
              angle: _rotationAnimation.value,
              child: child, // Pass in the static child widget to avoid repeated construction
            );
          },
          // Static child widget, which will be built only once
          child: Container(
            width: 200,
            height: 200,
            decoration: BoxDecoration(
              color: Colors.amber,
              // Using borderRadius property
              borderRadius: BorderRadius.circular(20)
            ),
            child: const Center(
              child: Text(
                'Spinning!',
                style: TextStyle(color: Colors.black, fontSize: 24),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

IV. Page Transition Animations

Flutter allows custom transition animations between pages. Various transition effects can be achieved through PageRouteBuilder.

1. Fade Transition

class FadeTransitionDemo extends StatelessWidget {
  const FadeTransitionDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Fade Transition Demo')),
      body: Center(
        child: ElevatedButton(
          child: const Text('Go to Second Screen'),
          onPressed: () {
            // Navigate to the second page with custom transition animation
            Navigator.push(
              context,
              PageRouteBuilder(
                transitionDuration: const Duration(seconds: 1), // Transition time
                // Build page content
                pageBuilder: (context, animation, secondaryAnimation) {
                  return const SecondScreen();
                },
                // Build transition effect
                transitionsBuilder: (context, animation, secondaryAnimation, child) {
                  // Fade transition
                  return FadeTransition(
                    opacity: animation,
                    child: child,
                  );
                },
              ),
            );
          },
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Screen'),
        backgroundColor: Colors.green,
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('Go Back'),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
      ),
    );
  }
}

2. Slide Transition

// Use this transition when navigating
PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 500),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // Slide transition - slide in from the right
    const begin = Offset(1.0, 0.0); // Starting position (right side)
    const end = Offset.zero; // Ending position (inside the screen)
    const curve = Curves.easeInOut;

    // Create an animation from begin to end
    var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
    var offsetAnimation = animation.drive(tween);

    return SlideTransition(
      position: offsetAnimation,
      child: child,
    );
  },
)

3. Scale Transition

PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 500),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // Scale transition
    return ScaleTransition(
      scale: animation.drive(
        Tween(begin: 0.0, end: 1.0).chain(
          CurveTween(curve: Curves.bounceOut)
        )
      ),
      child: child,
    );
  },
)

4. Combined Transition Effects

Multiple transition effects can be combined:

PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 700),
  pageBuilder: (context, animation, secondaryAnimation) {
    return const SecondScreen();
  },
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    // Combine scale and fade effects
    return ScaleTransition(
      scale: animation.drive(
        Tween(begin: 0.8, end: 1.0).chain(
          CurveTween(curve: Curves.easeInOut)
        )
      ),
      child: FadeTransition(
        opacity: animation.drive(
          Tween(begin: 0.0, end: 1.0).chain(
            CurveTween(curve: const Interval(0.2, 1.0))
          )
        ),
        child: child,
      ),
    );
  },
)

V. Example: Button Click Scale Effect

Implement a button with click feedback that produces a complete scale effect when clicked:

class BounceButton extends StatefulWidget {
  final Widget child;
  final VoidCallback onPressed;
  final double scaleFactor;
  final Duration duration;

  const BounceButton({
    super.key,
    required this.child,
    required this.onPressed,
    this.scaleFactor = 0.8,
    this.duration = const Duration(milliseconds: 300),
  });

  @override
  State<BounceButton> createState() => _BounceButtonState();
}

class _BounceButtonState extends State<BounceButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(vsync: this, duration: widget.duration);

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

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

  // Handle press - do not execute forward directly, controlled uniformly by onTapUp
  void _onTapDown(TapDownDetails details) {
    print("Pressed: Prepare to start animation");
  }

  // Handle release - control the animation process uniformly to ensure complete execution
  void _onTapUp(TapUpDetails details) {
    print("Released: Start animation process");
    // First execute the complete forward animation, then the reverse animation, and finally trigger the callback
    _controller.forward().then((_) {
      _controller.reverse().then((_) {
        widget.onPressed();
      });
    });
  }

  // Handle cancel
  void _onTapCancel() {
    print("Cancelled: Reverse animation");
    _controller.reverse();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _scaleAnimation,
      builder: (context, child) {
        return Transform.scale(scale: _scaleAnimation.value, child: child);
      },
      child: GestureDetector(
        onTapDown: _onTapDown,
        onTapUp: _onTapUp,
        onTapCancel: _onTapCancel,
        behavior: HitTestBehavior.opaque,
        child: widget.child,
      ),
    );
  }
}

// Usage example
class BounceButtonDemo extends StatelessWidget {
  const BounceButtonDemo({super.key});

  void _handlePress() {
    print('Button pressed!');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Bounce Button Demo')),
      body: Center(
        child: BounceButton(
          onPressed: _handlePress,
          scaleFactor: 0.7,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
            decoration: BoxDecoration(
              color: Colors.blue,
              borderRadius: BorderRadius.circular(12),
              boxShadow: const [
                BoxShadow(
                  color: Colors.black26,
                  blurRadius: 10,
                  offset: Offset(0, 4),
                ),
              ],
            ),
            child: const Text(
              'Click Me',
              style: TextStyle(
                color: Colors.white,
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Optimization Description:

  • Do not directly execute _controller.forward() in _onTapDown, only mark the button as pressed.
  • Uniformly control the animation process in _onTapUp, first execute the complete forward animation (button shrinks), then the reverse animation (button recovers), and finally trigger the business callback.

VII. Animation Performance Optimization

Animation performance is crucial for user experience. Here are some optimization suggestions:

  1. Use AnimatedBuilder to reduce reconstruction scope: Only rebuild the part that needs animation, avoiding the entire page reconstruction.
  2. Avoid complex calculations during animation: Animation callbacks should be as concise as possible; complex calculations will cause stuttering.
  3. Use hardware acceleration: Most animations will automatically use hardware acceleration, but operations that trigger software rendering (such as using saveLayer) should be avoided.
  4. Control animation frame rate: In most cases, 60fps is sufficient; a higher frame rate will consume more resources.
  5. Set reasonable animation duration: Generally, the animation duration is between 200-300ms; a longer duration will make users feel delayed.
  6. Use RepaintBoundary to isolate redraw areas: For animation widgets that are frequently redrawn, wrap them with RepaintBoundary to avoid affecting other widgets.
RepaintBoundary(
  child: AnimatedWidget(...),
)

7 . Avoid using opacity animation and shadow at the same time: Using these two effects together will reduce performance; shadows can be temporarily removed during animation.


This content originally appeared on DEV Community and was authored by Ge Ji