Flutter (67): AnimatedSwitcher

Time: Column:Mobile & Frontend views:257

67.1 AnimatedSwitcher

1. Introduction

In practical development, we often encounter scenarios where UI elements need to be switched, such as tab switches or route transitions. To enhance the user experience, an animation is typically specified during these transitions to make them feel smooth. The Flutter SDK provides several commonly used transition components, such as PageView and TabView, but these components do not cover all use cases. Therefore, the Flutter SDK offers an AnimatedSwitcher component, which defines a general abstraction for UI transitions.

AnimatedSwitcher can simultaneously add show and hide animations to its new and old child elements. In other words, when the child elements of AnimatedSwitcher change, it animates the old element out and the new element in. Let’s take a look at the definition of AnimatedSwitcher:

const AnimatedSwitcher({
  Key? key,
  this.child,
  required this.duration, // Duration of the new child's show animation
  this.reverseDuration, // Duration of the old child's hide animation
  this.switchInCurve = Curves.linear, // Animation curve for the new child's show
  this.switchOutCurve = Curves.linear, // Animation curve for the old child's hide
  this.transitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, // Animation builder
  this.layoutBuilder = AnimatedSwitcher.defaultLayoutBuilder, // Layout builder
})

When the child of AnimatedSwitcher changes (due to a different type or key), the old child will perform a hide animation while the new child will perform a show animation. The type of animation executed is determined by the transitionBuilder parameter, which accepts an AnimatedSwitcherTransitionBuilder type builder defined as follows:

typedef AnimatedSwitcherTransitionBuilder = Widget Function(Widget child, Animation<double> animation);

This builder binds animations to the old and new child elements during the switch:

  • The animation bound to the old child will run in reverse.

  • The animation bound to the new child will run forward.

This effectively binds animations to both the new and old children. The default value of AnimatedSwitcher is AnimatedSwitcher.defaultTransitionBuilder:

Widget defaultTransitionBuilder(Widget child, Animation<double> animation) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
}

This means that by default, AnimatedSwitcher will apply “fade out” and “fade in” animations to the old and new children, respectively.

2. Example

Let’s look at an example: implementing a counter where, during each increment, the old number shrinks and hides while the new number grows and shows. Here’s the code:

import 'package:flutter/material.dart';

class AnimatedSwitcherCounterRoute extends StatefulWidget {
   const AnimatedSwitcherCounterRoute({Key? key}) : super(key: key);

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

 class _AnimatedSwitcherCounterRouteState extends State<AnimatedSwitcherCounterRoute> {
   int _count = 0;

   @override
   Widget build(BuildContext context) {
     return Center(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           AnimatedSwitcher(
             duration: const Duration(milliseconds: 500),
             transitionBuilder: (Widget child, Animation<double> animation) {
               // Execute scale animation
               return ScaleTransition(child: child, scale: animation);
             },
             child: Text(
               '$_count',
               // Use specified key, different keys are considered different Texts to trigger the animation
               key: ValueKey<int>(_count),
               style: Theme.of(context).textTheme.headline4,
             ),
           ),
           ElevatedButton(
             child: const Text('+1',),
             onPressed: () {
               setState(() {
                 _count += 1;
               });
             },
           ),
         ],
       ),
     );
   }
 }

When the example code runs, clicking the “+1” button will cause the original number to gradually shrink until it hides, while the new number will gradually enlarge, as shown in Figure.

Flutter (67): AnimatedSwitcher

In the figure above, after the first click of the “+1” button, “0” is gradually shrinking while “1” is gradually enlarging.

Note: The new and old children of AnimatedSwitcher must have different keys if they are of the same type.

3. Implementation Principles of AnimatedSwitcher

In fact, the implementation principles of AnimatedSwitcher are relatively straightforward. Based on how AnimatedSwitcher is used, we can infer its basic functionality. To achieve the animation of switching between new and old children, we need to clarify two questions:

  1. When does the animation occur?

  2. How do we execute animations for the new and old children?

From the usage of AnimatedSwitcher, we see that when the child changes (i.e., when the key or type of the child widget differs), the build method is called again, and the animation begins.

We can implement AnimatedSwitcher by inheriting from StatefulWidget. The specific approach involves checking in the didUpdateWidget callback whether the new and old children have changed. If they have changed, we perform a reverse animation for the old child and a forward animation for the new child. Here’s a portion of the core pseudo-code for the implementation of AnimatedSwitcher:

Widget _widget; 
void didUpdateWidget(AnimatedSwitcher oldWidget) {
  super.didUpdateWidget(oldWidget);
  // Check if the new and old children have changed (if keys and types are equal, return true, meaning no change)
  if (Widget.canUpdate(widget.child, oldWidget.child)) {
    // No change in child...
  } else {
    // Child has changed, create a Stack to apply animations to both the new and old children
    _widget = Stack(
      alignment: Alignment.center,
      children: [
        // Old child applies FadeTransition
        FadeTransition(
         opacity: _controllerOldAnimation,
         child: oldWidget.child,
        ),
        // New child applies FadeTransition
        FadeTransition(
         opacity: _controllerNewAnimation,
         child: widget.child,
        ),
      ]
    );
    // Execute reverse exit animation for old child
    _controllerOldAnimation.reverse();
    // Execute forward enter animation for new child
    _controllerNewAnimation.forward();
  }
}

// Build method
Widget build(BuildContext context) {
  return _widget;
}

The pseudo-code above shows the core logic behind the implementation of AnimatedSwitcher. Of course, the actual implementation is more complex, allowing for custom entrance and exit transition animations, as well as layout changes during animations. Here, we simplify the explanation to give readers a clear view of the main implementation ideas. For the specific implementation, readers can refer to the source code of AnimatedSwitcher.

Additionally, the Flutter SDK also provides an AnimatedCrossFade component, which can also switch between two child elements, executing fade in and fade out animations. Unlike AnimatedSwitcher, which switches between new and old values of a single child element, AnimatedCrossFade is designed for two separate child elements. The implementation principle of AnimatedCrossFade is also straightforward and similar to that of AnimatedSwitcher, so it will not be elaborated further. Interested readers can view its source code.


67.2 Advanced Usage of AnimatedSwitcher

Suppose we want to implement an animation similar to route transitions: the old page slides out to the left, while the new page slides in from the right. If we try to use AnimatedSwitcher, we quickly realize a problem: it doesn’t work! We might write code like the following:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
    return SlideTransition(
      child: child,
      position: tween.animate(animation),
    );
  },
  // ... // omitted
)

What’s wrong with the above code? As we mentioned earlier, when the child of AnimatedSwitcher switches, the new child performs a forward animation, while the old child performs a reverse animation. So, the actual effect is that the new child slides in from the right, but the old child exits to the right instead of the left. This makes sense because, without special handling, the forward and reverse animations of the same animation are simply opposite (symmetric).

So, does this mean we can’t use AnimatedSwitcher? Certainly not! If we think about the issue, the root cause is that the forward and reverse animations of the same Animation are symmetric. Therefore, if we can break this symmetry, we can achieve the desired effect. Let’s encapsulate a MySlideTransition, which customizes the reverse animation to slide out from the left. Here’s the code:

class MySlideTransition extends AnimatedWidget {
  const MySlideTransition({
    Key? key,
    required Animation<Offset> position,
    this.transformHitTests = true,
    required this.child,
  }) : super(key: key, listenable: position);

  final bool transformHitTests;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final position = listenable as Animation<Offset>;
    Offset offset = position.value;
    if (position.status == AnimationStatus.reverse) {
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

When calling it, simply replace SlideTransition with MySlideTransition:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
    return MySlideTransition(
      child: child,
      position: tween.animate(animation),
    );
  },
  // ... // omitted
)

In the figure, "0" slides out from the left while "1" slides in from the right. We can see that we have achieved an animation similar to route transitions in a clever way. In fact, Flutter’s route transitions are also implemented using AnimatedSwitcher.


67.3 SlideTransitionX

In the above example, we implemented a "left out, right in" animation. What if we want to achieve "left in, right out," "top in, bottom out," or "bottom in, top out"? Of course, we could modify the code individually for each animation, but this would require defining a separate "Transition" for each, which is cumbersome. This section will encapsulate a general SlideTransitionX to implement these "in and out animations," as shown below:

class SlideTransitionX extends AnimatedWidget {
  SlideTransitionX({
    Key? key,
    required Animation<double> position,
    this.transformHitTests = true,
    this.direction = AxisDirection.down,
    required this.child,
  }) : super(key: key, listenable: position) {
    switch (direction) {
      case AxisDirection.up:
        _tween = Tween(begin: const Offset(0, 1), end: const Offset(0, 0));
        break;
      case AxisDirection.right:
        _tween = Tween(begin: const Offset(-1, 0), end: const Offset(0, 0));
        break;
      case AxisDirection.down:
        _tween = Tween(begin: const Offset(0, -1), end: const Offset(0, 0));
        break;
      case AxisDirection.left:
        _tween = Tween(begin: const Offset(1, 0), end: const Offset(0, 0));
        break;
    }
  }

  final bool transformHitTests;
  final Widget child;
  final AxisDirection direction;
  late final Tween<Offset> _tween;

  @override
  Widget build(BuildContext context) {
    final position = listenable as Animation<double>;
    Offset offset = _tween.evaluate(position);
    if (position.status == AnimationStatus.reverse) {
      switch (direction) {
        case AxisDirection.up:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.right:
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.down:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.left:
          offset = Offset(-offset.dx, offset.dy);
          break;
      }
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

Now, if we want to implement various "slide in and out animations," it becomes very easy—just pass different direction values. For example, to achieve "top in, bottom out," we would do:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
    return SlideTransitionX(
      child: child,
      direction: AxisDirection.down, // top in, bottom out
      position: animation,
    );
  },
  // ... // omitted remaining code
)

After running this, as shown in Figure :

Flutter (67): AnimatedSwitcher

In the figure, "0" slides out from the bottom while "1" slides in from the top. Readers can try different values for the direction in SlideTransitionX to see the effects.


67.4 Summary

In this section, we learned about the detailed usage of AnimatedSwitcher and introduced methods to break the symmetry of AnimatedSwitcher animations. We can see that AnimatedSwitcher is very useful in scenarios where new and old UI elements need to be switched.