Creating Staggered Animation in Flutter: A Micro-Interactions Guide

cover
4 Jun 2024

A staggered animation consists of sequential or overlapping animations. The animation might be purely sequential, with one change occurring after the next, or it might partially or completely overlap. When the animation is sequential, the elements are animated sequentially with a slight delay between the start times of each element. This creates a cascading or ripple effect, where the animation appears to move through the elements in stages rather than all at once.

Staggered animations are considered a type of micro-interaction because they enhance the user experience by providing subtle, interactive feedback that guides the user through an interface. In Flutter, you can build micro-interactions by crafting subtle animations using either the implicit or explicit animation.

For context, implicit animations are designed to be simple and easy to use because the animation details are abstracted away while explicit animations are more suitable for complex animations because they offer complete control of the animation process. This is especially used when you need to have more fine-grained control over the animation.

In this article, we will be delving into the concept of micro-interactions, then for a micro-interaction use case, we will use explicit animation to create a staggered animation that animates the children contained in a column widget.

What Is the Concept of Micro-Interactions?

Great products are products that deliver well on both features and detail. Features bring people to your products, but details keep them. These details are things that make your app stand out from others. With micro-interactions, you can create these details by providing delightful feedback to your users.

The concept of micro-interactions is based on the idea that small details can have a big impact on the overall user experience. Micro-interactions can be used to serve essential functions like communicating feedback or the result of an action. Examples of micro-interactions include:

  • Button animations: The button changes color or size when hovered or pressed.

  • Loading indicators: Animations that indicate to a user that a process is in progress.

  • Swipe gestures: Animations that respond to swipe gestures.

  • Navigation transitions: Smooth animations when transitioning between screens.

Micro-Interactions in Real-Life Applications

Below, we can see real-life applications of micro-interactions, these subtle animations are created in Flutter, to elevate user experience. 👇🏽

Design references for these apps were gotten from Dribbble

How To Create Staggered Animation in Flutter

In this example, we will be creating a staggered animation that animates the children in a column widget when that page is swiped. This will be built using the explicit animation approach because in this case, we will need to have full control of how we want the animation to run.

Here is how the animation will look like when we are completed 👇🏽

Prerequisite

To make the most of this tutorial, you should have the following:

  • A basic understanding of how animations are created in Flutter
  • A good grasp of Flutter & Dart fundamentals
  • A code editor, either VScode or Android Studio
  • An emulator or device to build on

Once, you’ve checked out all the project’s prerequisites, let’s dive in.

First, we will build the main screen that contains these two pages. These two pages will be wrapped in a pageview widget that controls which page is displayed on swipe. Also, at the bottom of the main screen, we have an indicator that shows us the page that we are currently on.

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

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen>{
  final controller = PageController(keepPage: true);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: PageView(
          controller: controller,
          children: [
            Page1(pageController: controller,),
            Page2(pageController: controller,),
          ],
        ),
      ),
      bottomNavigationBar: Container(
        alignment: Alignment.topCenter,
        padding: const EdgeInsets.only(top: 10),
        height: 30,
        child:  SmoothPageIndicator(
          controller: controller,
          count: 2,
          effect: const JumpingDotEffect(
            dotHeight: 10,
            dotWidth: 10,
            activeDotColor: Colors.grey,
            dotColor: Colors.black12,
          ),
        ),
      )
    );
  }
}

The SmoothPageIndicator used in the code snippet above can be found on pub.dev. Here, the SmoothPageIndicator is used to show the current page in view. You can add the package to your pubspec.yaml like so 👇🏽

dependencies:
  flutter:
    sdk: flutter
  smooth_page_indicator: ^1.1.0

In the two pages, we will have a column widget with several empty card widgets. These empty card widgets will be used to populate the column so that we can fully see and appreciate the staggered animation. We will create the empty card widget as a re-useable component so that we don’t have to build it from scratch at every place we use it.

class EmptyCard extends StatelessWidget {
  const EmptyCard({super.key, required this.width, required this.height});

  final double width;
  final double height;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: height,
      width: width,
      decoration: BoxDecoration(
        borderRadius: const BorderRadius.all(Radius.circular(10)),
        color: Colors.blue.shade200,
        boxShadow: const [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 4,
            offset: Offset(0,4),
          )
        ]
      ),
    );
  }
}

With all these boilerplate codes out of the way, we will now begin building the animation. First, we will create a new stateful widget which we will call AnimateWidget. It will have several parameters to control how the animation is going to run.

class AnimateWidget extends StatefulWidget {
  const AnimateWidget({super.key,
    required this.duration,
    required this.position,
    required this.horizontalOffset,
    required this.child,
    required this.controller,
  });

  final Duration duration;
  final int position;
  final double? horizontalOffset;
  final Widget child;
  final PageController controller;

  @override
  State<AnimateWidget> createState() => _AnimateWidgetState();
}

With the params of AnimateWidget as seen in the snippet above, we can successfully control:

  • Duration of the animation.
  • The pageController , causing it to trigger the animation when the page is swiped.
  • The amount of horizontal offset of the animated element.

Next, in the AnimateWiget, we will define the following:

  • AnimationController: Used to control the animation sequence.

  • Current page: will be used to hold current page data.

  • Timer: Will be used to trigger the animation after some delay; this is what will bring about the staggered animation cascading effect.

    class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{
      @override
      bool get wantKeepAlive => true;
    
      late AnimationController animationController;
      int currentPage = 0;
      Timer? _timer;
    
      @override
      void initState() {
        super.initState();
        animationController = AnimationController(vsync: this, duration: widget.duration);
        _timer = Timer(getDelay(), animationController.forward);
    
        widget.controller.addListener(() {
          currentPage = widget.controller.page!.round();
          if(currentPage == widget.controller.page){
            _timer = Timer(getDelay(), animationController.forward);
          }
    
        });
      }
    
      @override
      void dispose() {
        _timer?.cancel();
        animationController.dispose();
        super.dispose();
      }
    
      Duration getDelay(){
        var delayInMilliSec = widget.duration.inMilliseconds ~/ 6;
    
        int getStaggeredListDuration(){
          return widget.position * delayInMilliSec;
        }
    
        return Duration(milliseconds: getStaggeredListDuration());
      }
    
    

Breaking down the code snippet above, you’ll notice that:

  • We used AnimationController to control the animation sequence, because of this, we introduced the SingleTickerProviderStateMixin.

  • In the init state method, we initialized the AnimationController, then triggered the animation using animationController.forward, on page entry, and page swipe.

  • We used the Timer to control the triggering of the animation based on the delay.

  • In the dispose method, we cleaned up resources.

  • We used the AutomaticKeepAliveClientMixin to preserve the state of the widget. This is to prevent the disposal of the AnimationController, when a page is swiped and is no longer visible.

  • In the getDelay function, we calculated the delay before the animation was triggered for each element. To achieve this, we divided the duration in milliseconds by 6 and approximated the results, then multiplied it by the position. This is the position (in this case, the index) of the element.

In the Build method, we return an AnimatedBuilder. In this animated builder, we will return a widget function called _slideAnimation that returns a Transform.translate widget. In the _slideAnimation function, we have the offsetAnimation function.

The offsetAnimation function returns the Animation property which is used in the Transform.translate widget. The Transform.translate widget animates the child widget, using the value from the animation.

@override
  Widget build(BuildContext context) {
    super.build(context);

    return AnimatedBuilder(
        animation: animationController,
        builder: (context, child){
          return _slideAnimation(animationController);
        }
    );
  }

 Widget _slideAnimation(Animation<double> animationController){
    Animation<double> offsetAnimation(double offset, Animation<double> animationController) {
      return Tween<double>(begin: offset, end: 0.0).animate(
        CurvedAnimation(
          parent: animationController,
          curve: const Interval(0.0, 1.0, curve: Curves.ease),
        ),
      );
    }

    return Transform.translate(
      offset: Offset(
        widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value,
        0.0,
      ),
      child: widget.child,
    );
  }

This is the full code for the AnimateWidget Class 👇🏽

class AnimateWidget extends StatefulWidget {
  const AnimateWidget({super.key,
    required this.duration,
    required this.position,
    required this.horizontalOffset,
    required this.child,
    required this.controller,
  });

  final Duration duration;
  final int position;
  final double? horizontalOffset;
  final Widget child;
  final PageController controller;

  @override
  State<AnimateWidget> createState() => _AnimateWidgetState();
}

class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{
  @override
  bool get wantKeepAlive => true;

  late AnimationController animationController;
  int currentPage = 0;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    animationController = AnimationController(vsync: this, duration: widget.duration);
    _timer = Timer(getDelay(), animationController.forward);

    widget.controller.addListener(() {
      currentPage = widget.controller.page!.round();
      if(currentPage == widget.controller.page){
        _timer = Timer(getDelay(), animationController.forward);
      }

    });
  }

  @override
  void dispose() {
    _timer?.cancel();
    animationController.dispose();
    super.dispose();
  }

  Duration getDelay(){
    var delayInMilliSec = widget.duration.inMilliseconds ~/ 6;

    int getStaggeredListDuration(){
      return widget.position * delayInMilliSec;
    }

    return Duration(milliseconds: getStaggeredListDuration());
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return AnimatedBuilder(
        animation: animationController,
        builder: (context, child){
          return _slideAnimation(animationController);
        }
    );
  }

  Widget _slideAnimation(Animation<double> animationController){
    Animation<double> offsetAnimation(double offset, Animation<double> animationController) {
      return Tween<double>(begin: offset, end: 0.0).animate(
        CurvedAnimation(
          parent: animationController,
          curve: const Interval(0.0, 1.0, curve: Curves.ease),
        ),
      );
    }

    return Transform.translate(
      offset: Offset(
        widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value,
        0.0,
      ),
      child: widget.child,
    );

  }
}

Next, to use this AnimateWidget class in a column widget, we will create a class with a static method called toStaggeredList that returns a list, In this method, we pass all the needed parameters, including a list children. The children parameter is where we would pass the list of elements that we will be animating.

Next, we will map children, wrapping each child with the AnimateWidget.

class AnimateList{

  static List<Widget>toStaggeredList({
    required Duration duration,
    double? horizontalOffset,
    required PageController controller,
    required List<Widget>children,
})=> children
      .asMap()
      .map((index, widget){
        return MapEntry(
            index,
            AnimateWidget(
              duration: duration,
              position: index,
              horizontalOffset: horizontalOffset,
              controller: controller,
              child: widget,
            )
        );
  })
      .values
      .toList();

}

In the AnimateWidget, we pass in the required parameters to successfully animate each child in the list. Using the AnimateList.toStaggeredList method, we can now implement it on the two pages that we are working on.

class Page1 extends StatelessWidget {
  const Page1({super.key, required this.pageController});

  final PageController pageController;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
          children: AnimateList.toStaggeredList(
              duration: const Duration(milliseconds: 375),
              controller: pageController,
              horizontalOffset: MediaQuery.of(context).size.width / 2,
              children: [
                const EmptyCard(width: 250, height: 50,),
                const Padding(
                  padding: EdgeInsets.only(top: 20),
                  child: EmptyCard(width: 180, height: 80,),
                ),
                const Padding(
                  padding: EdgeInsets.only(top: 20),
                  child: EmptyCard(width: 270, height: 50,),
                ),
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 20),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      EmptyCard(height: 50, width: 70),
                      EmptyCard(height: 50, width: 70),
                      EmptyCard(height: 50, width: 70),
                    ],
                  ),
                ),
                const EmptyCard(width: 250, height: 50,),
                const Padding(
                  padding: EdgeInsets.only(top: 20),
                  child: EmptyCard(width: 180, height: 80,),
                ),
                const Padding(
                  padding: EdgeInsets.only(top: 20),
                  child: EmptyCard(width: 270, height: 50,),
                ),
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 20),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      EmptyCard(height: 50, width: 70),
                      EmptyCard(height: 50, width: 70),
                      EmptyCard(height: 50, width: 70),
                    ],
                  ),
                ),
            ],
          ),

      ),
    );
  }
}

class Page2 extends StatelessWidget {
  const Page2({super.key, required this.pageController});

  final PageController pageController;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: AnimateList.toStaggeredList(
          duration: const Duration(milliseconds: 375),
          controller: pageController,
          horizontalOffset: MediaQuery.of(context).size.width / 2,
          children: [
            const EmptyCard(width: 220, height: 70,),
            const Padding(
              padding: EdgeInsets.only(top: 20),
              child: EmptyCard(width: 300, height: 70,),
            ),
            const Padding(
              padding: EdgeInsets.only(top: 20),
              child: EmptyCard(width: 200, height: 50,),
            ),
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 20),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  EmptyCard(height: 70, width: 70),
                  EmptyCard(height: 70, width: 70),
                ],
              ),
            ),
            const EmptyCard(width: 220, height: 70,),
            const Padding(
              padding: EdgeInsets.only(top: 20),
              child: EmptyCard(width: 300, height: 70,),
            ),
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 20),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  EmptyCard(height: 70, width: 70),
                  EmptyCard(height: 70, width: 70),
                ],
              ),
            ),
          ],
        ),

      ),
    );
  }
}

In the column widget’s children, we will pass the AnimateList.toStaggeredList and pass the parameters needed, including the widgets that are to be displayed in the column. With this, we have successfully created a staggered animation triggered on swipe. You can check out the full code here.

This is our final result:

Conclusion

We have come to the end of this tutorial. At this point, we covered the concept of micro-interactions & it’s impact on user experience. We also went through the process of building staggered animation of items in a column widget triggered on page swipe.

There are so many kinds of micro-interactions you can add to your app to improve the user experience of your project; you can experiment by building more interactive animations in Flutter.

If you found this article helpful, you can support it by leaving a like or comment. You can also follow me for more related articles.

References

Flutter Staggered Animation Package