Create a staggered menu animation

A single app screen might contain multiple animations. Playing all of the animations at the same time can be overwhelming. Playing the animations one after the other can take too long. A better option is to stagger the animations. Each animation begins at a different time, but the animations overlap to create a shorter duration. In this recipe, you build a drawer menu with animated content that is staggered and has a button that pops in at the bottom.

The following animation shows the app's behavior:

Staggered Menu Animation Example

The drawer menu displays a list of titles, followed by a Get started button at the bottom of the menu.

Define a stateful widget called Menu that displays the list and button in static locations.

                          class              Menu              extends              StatefulWidget              {              @override              _MenuState              createState              ()              =>              _MenuState              ();              }              class              _MenuState              extends              State              <              Menu              >              {              static              const              _menuTitles              =              [              'Declarative Style'              ,              'Premade Widgets'              ,              'Stateful Hot Reload'              ,              'Native Performance'              ,              'Great Community'              ,              ];              @override              Widget              build              (              BuildContext              context              )              {              return              Container              (              color:              Colors              .              white              ,              child:              Stack              (              fit:              StackFit              .              expand              ,              children:              [              _buildFlutterLogo              (),              _buildContent              (),              ],              ),              );              }              Widget              _buildFlutterLogo              ()              {...}              Widget              _buildContent              ()              {              return              Column              (              crossAxisAlignment:              CrossAxisAlignment              .              start              ,              children:              [              const              SizedBox              (              height:              16              ),              ...              _buildListItems              (),              const              Spacer              (),              _buildGetStartedButton              (),              ],              );              }              List              <              Widget              >              _buildListItems              ()              {              final              listItems              =              <              Widget              >[];              for              (              var              i              =              0              ;              i              <              _menuTitles              .              length              ;              ++              i              )              {              listItems              .              add              (              Padding              (              padding:              const              EdgeInsets              .              symmetric              (              horizontal:              36.0              ,              vertical:              16              ),              child:              Text              (              _menuTitles              [              i              ],              textAlign:              TextAlign              .              left              ,              style:              const              TextStyle              (              fontSize:              24              ,              fontWeight:              FontWeight              .              w500              ,              ),              ),              ),              );              }              return              listItems              ;              }              Widget              _buildGetStartedButton              ()              {              return              SizedBox              (              width:              double              .              infinity              ,              child:              Padding              (              padding:              const              EdgeInsets              .              all              (              24.0              ),              child:              RaisedButton              (              shape:              const              StadiumBorder              (),              color:              Colors              .              blue              ,              padding:              const              EdgeInsets              .              symmetric              (              horizontal:              48              ,              vertical:              14              ),              onPressed:              ()              {},              child:              const              Text              (              'Get Started'              ,              style:              TextStyle              (              color:              Colors              .              white              ,              fontSize:              22              ,              ),              ),              ),              ),              );              }              }                      

Prepare for animations

Control of the animation timing requires an AnimationController.

Add the SingleTickerProviderStateMixin to the MenuState class. Then, declare and instantiate an AnimationController.

                          class              _MenuState              extends              State              <              Menu              >              with              SingleTickerProviderStateMixin              {              late              AnimationController              _staggeredController              ;              @override              void              initState              ()              {              super              .              initState              ();              _staggeredController              =              AnimationController              (              vsync:              this              ,              );              }              @override              void              dispose              ()              {              _staggeredController              .              dispose              ();              super              .              dispose              ();              }              }                      

The length of the delay before every animation is up to you. Define the animation delays, individual animation durations, and the total animation duration.

                          class              _MenuState              extends              State              <              Menu              >              with              SingleTickerProviderStateMixin              {              static              const              _initialDelayTime              =              Duration              (              milliseconds:              50              );              static              const              _itemSlideTime              =              Duration              (              milliseconds:              250              );              static              const              _staggerTime              =              Duration              (              milliseconds:              50              );              static              const              _buttonDelayTime              =              Duration              (              milliseconds:              150              );              static              const              _buttonTime              =              Duration              (              milliseconds:              500              );              final              _animationDuration              =              _initialDelayTime              +              (              _staggerTime              *              _menuTitles              .              length              )              +              _buttonDelayTime              +              _buttonTime              ;              }                      

In this case, all the animations are delayed by 50 ms. After that, list items begin to appear. Each list item's appearance is delayed by 50 ms after the previous list item begins to slide in. Each list item takes 250 ms to slide from right to left. After the last list item begins to slide in, the button at the bottom waits another 150 ms to pop in. The button animation takes 500 ms.

With each delay and animation duration defined, the total duration is calculated so that it can be used to calculate the individual animation times.

The desired animation times are shown in the following diagram:

Animation Timing Diagram

To animate a value during a subsection of a larger animation, Flutter provides the Interval class. An Interval takes a start time percentage and an end time percentage. That Interval can then be used to animate a value between those start and end times, instead of using the entire animation's start and end times. For example, given an animation that takes 1 second, an interval from 0.2 to 0.5 would start at 200 ms (20%) and end at 500 ms (50%).

Declare and calculate each list item's Interval and the bottom button Interval.

                          class              _MenuState              extends              State              <              Menu              >              with              SingleTickerProviderStateMixin              {              final              List              <              Interval              >              _itemSlideIntervals              =              [];              late              Interval              _buttonInterval              ;              @override              void              initState              ()              {              super              .              initState              ();              _createAnimationIntervals              ();              _staggeredController              =              AnimationController              (              vsync:              this              ,              duration:              _animationDuration              ,              );              }              void              _createAnimationIntervals              ()              {              for              (              var              i              =              0              ;              i              <              _menuTitles              .              length              ;              ++              i              )              {              final              startTime              =              _initialDelayTime              +              (              _staggerTime              *              i              );              final              endTime              =              startTime              +              _itemSlideTime              ;              _itemSlideIntervals              .              add              (              Interval              (              startTime              .              inMilliseconds              /              _animationDuration              .              inMilliseconds              ,              endTime              .              inMilliseconds              /              _animationDuration              .              inMilliseconds              ,              ),              );              }              final              buttonStartTime              =              Duration              (              milliseconds:              (              _menuTitles              .              length              *              50              ))              +              _buttonDelayTime              ;              final              buttonEndTime              =              buttonStartTime              +              _buttonTime              ;              _buttonInterval              =              Interval              (              buttonStartTime              .              inMilliseconds              /              _animationDuration              .              inMilliseconds              ,              buttonEndTime              .              inMilliseconds              /              _animationDuration              .              inMilliseconds              ,              );              }              }                      

Animate the list items and button

The staggered animation plays as soon as the menu becomes visible.

Start the animation in initState().

                          @override              void              initState              (              )              {              super              .              initState              ();              _createAnimationIntervals              ();              _staggeredController              =              AnimationController              (              vsync:              this              ,              duration:              _animationDuration              ,              )..              forward              ();              }                      

Each list item slides from right to left and fades in at the same time.

Use the list item's Interval and an easeOut curve to animate the opacity and translation values for each list item.

                          List              <              Widget              >              _buildListItems              ()              {              final              listItems              =              <              Widget              >[];              for              (              var              i              =              0              ;              i              <              _menuTitles              .              length              ;              ++              i              )              {              listItems              .              add              (              AnimatedBuilder              (              animation:              _staggeredController              ,              builder:              (              context              ,              child              )              {              final              animationPercent              =              Curves              .              easeOut              .              transform              (              _itemSlideIntervals              [              i              ].              transform              (              _staggeredController              .              value              ),              );              final              opacity              =              animationPercent              ;              final              slideDistance              =              (              1.0              -              animationPercent              )              *              150              ;              return              Opacity              (              opacity:              opacity              ,              child:              Transform              .              translate              (              offset:              Offset              (              slideDistance              ,              0              ),              child:              child              ,              ),              );              },              child:              Padding              (              padding:              const              EdgeInsets              .              symmetric              (              horizontal:              36.0              ,              vertical:              16              ),              child:              Text              (              _menuTitles              [              i              ],              textAlign:              TextAlign              .              left              ,              style:              const              TextStyle              (              fontSize:              24              ,              fontWeight:              FontWeight              .              w500              ,              ),              ),              ),              ),              );              }              return              listItems              ;              }                      

Use the same approach to animate the opacity and scale of the bottom button. This time, use an elasticOut curve to give the button a springy effect.

                          Widget              _buildGetStartedButton              (              )              {              return              SizedBox              (              width:              double              .              infinity              ,              child:              Padding              (              padding:              const              EdgeInsets              .              all              (              24.0              ),              child:              AnimatedBuilder              (              animation:              _staggeredController              ,              builder:              (              context              ,              child              )              {              final              animationPercent              =              Curves              .              elasticOut              .              transform              (              _buttonInterval              .              transform              (              _staggeredController              .              value              ));              final              opacity              =              animationPercent              .              clamp              (              0.0              ,              1.0              );              final              scale              =              (              animationPercent              *              0.5              )              +              0.5              ;              return              Opacity              (              opacity:              opacity              ,              child:              Transform              .              scale              (              scale:              scale              ,              child:              child              ,              ),              );              },              child:              RaisedButton              (              shape:              const              StadiumBorder              (),              color:              Colors              .              blue              ,              padding:              const              EdgeInsets              .              symmetric              (              horizontal:              48              ,              vertical:              14              ),              onPressed:              ()              {},              child:              const              Text              (              'Get Started'              ,              style:              TextStyle              (              color:              Colors              .              white              ,              fontSize:              22              ,              ),              ),              ),              ),              ),              );              }                      

Congratulations! You have an animated menu where the appearance of each list item is staggered, followed by a bottom button that pops into place.

Interactive example

          import 'package:flutter/material.dart';  void main() {   runApp(const MaterialApp(       home: const ExampleStaggeredAnimations(),       debugShowCheckedModeBanner: false,     ),   ); }  class ExampleStaggeredAnimations extends StatefulWidget {   const ExampleStaggeredAnimations({     Key? key,   }) : super(key: key);    @override   _ExampleStaggeredAnimationsState createState() =>       _ExampleStaggeredAnimationsState(); }  class _ExampleStaggeredAnimationsState extends State<ExampleStaggeredAnimations>     with SingleTickerProviderStateMixin {   late AnimationController _drawerSlideController;    @override   void initState() {     super.initState();      _drawerSlideController = AnimationController(       vsync: this,       duration: const Duration(milliseconds: 150),     );   }    @override   void dispose() {     _drawerSlideController.dispose();     super.dispose();   }    bool _isDrawerOpen() {     return _drawerSlideController.value == 1.0;   }    bool _isDrawerOpening() {     return _drawerSlideController.status == AnimationStatus.forward;   }    bool _isDrawerClosed() {     return _drawerSlideController.value == 0.0;   }    void _toggleDrawer() {     if (_isDrawerOpen() || _isDrawerOpening()) {       _drawerSlideController.reverse();     } else {       _drawerSlideController.forward();     }   }    @override   Widget build(BuildContext context) {     return Scaffold(       backgroundColor: Colors.white,       appBar: _buildAppBar(),       body: Stack(         children: [           _buildContent(),           _buildDrawer(),         ],       ),     );   }    PreferredSizeWidget _buildAppBar() {     return AppBar(       title: const Text(         'Flutter Menu',         style: TextStyle(           color: Colors.black,         ),       ),       backgroundColor: Colors.transparent,       elevation: 0.0,       automaticallyImplyLeading: false,       actions: [         AnimatedBuilder(           animation: _drawerSlideController,           builder: (context, child) {             return IconButton(               onPressed: _toggleDrawer,               icon: _isDrawerOpen() || _isDrawerOpening()                   ? const Icon(                       Icons.clear,                       color: Colors.black,                     )                   : const Icon(                       Icons.menu,                       color: Colors.black,                     ),             );           },         ),       ],     );   }    Widget _buildContent() {     // Put page content here.     return const SizedBox();   }    Widget _buildDrawer() {     return AnimatedBuilder(       animation: _drawerSlideController,       builder: (context, child) {         return FractionalTranslation(           translation: Offset(1.0 - _drawerSlideController.value, 0.0),           child: _isDrawerClosed() ? const SizedBox() : Menu(),         );       },     );   } }  class Menu extends StatefulWidget {   @override   _MenuState createState() => _MenuState(); }  class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {   static const _menuTitles = [     'Declarative style',     'Premade widgets',     'Stateful hot reload',     'Native performance',     'Great community',   ];    static const _initialDelayTime = Duration(milliseconds: 50);   static const _itemSlideTime = Duration(milliseconds: 250);   static const _staggerTime = Duration(milliseconds: 50);   static const _buttonDelayTime = Duration(milliseconds: 150);   static const _buttonTime = Duration(milliseconds: 500);   final _animationDuration = _initialDelayTime +       (_staggerTime * _menuTitles.length) +       _buttonDelayTime +       _buttonTime;    late AnimationController _staggeredController;   final List<Interval> _itemSlideIntervals = [];   late Interval _buttonInterval;    @override   void initState() {     super.initState();      _createAnimationIntervals();      _staggeredController = AnimationController(       vsync: this,       duration: _animationDuration,     )..forward();   }    void _createAnimationIntervals() {     for (var i = 0; i < _menuTitles.length; ++i) {       final startTime = _initialDelayTime + (_staggerTime * i);       final endTime = startTime + _itemSlideTime;       _itemSlideIntervals.add(         Interval(           startTime.inMilliseconds / _animationDuration.inMilliseconds,           endTime.inMilliseconds / _animationDuration.inMilliseconds,         ),       );     }      final buttonStartTime =         Duration(milliseconds: (_menuTitles.length * 50)) + _buttonDelayTime;     final buttonEndTime = buttonStartTime + _buttonTime;     _buttonInterval = Interval(       buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,       buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,     );   }    @override   void dispose() {     _staggeredController.dispose();     super.dispose();   }    @override   Widget build(BuildContext context) {     return Container(       color: Colors.white,       child: Stack(         fit: StackFit.expand,         children: [           _buildFlutterLogo(),           _buildContent(),         ],       ),     );   }    Widget _buildFlutterLogo() {     return const Positioned(       right: -100,       bottom: -30,       child: Opacity(         opacity: 0.2,         child: FlutterLogo(           size: 400,         ),       ),     );   }    Widget _buildContent() {     return Column(       crossAxisAlignment: CrossAxisAlignment.start,       children: [         const SizedBox(height: 16),         ..._buildListItems(),         const Spacer(),         _buildGetStartedButton(),       ],     );   }    List<Widget> _buildListItems() {     final listItems = <Widget>[];     for (var i = 0; i < _menuTitles.length; ++i) {       listItems.add(         AnimatedBuilder(           animation: _staggeredController,           builder: (context, child) {             final animationPercent = Curves.easeOut.transform(               _itemSlideIntervals[i].transform(_staggeredController.value),             );             final opacity = animationPercent;             final slideDistance = (1.0 - animationPercent) * 150;              return Opacity(               opacity: opacity,               child: Transform.translate(                 offset: Offset(slideDistance, 0),                 child: child,               ),             );           },           child: Padding(             padding: const EdgeInsets.symmetric(horizontal: 36.0, vertical: 16),             child: Text(               _menuTitles[i],               textAlign: TextAlign.left,               style: const TextStyle(                 fontSize: 24,                 fontWeight: FontWeight.w500,               ),             ),           ),         ),       );     }     return listItems;   }    Widget _buildGetStartedButton() {     return SizedBox(       width: double.infinity,       child: Padding(         padding: const EdgeInsets.all(24.0),         child: AnimatedBuilder(           animation: _staggeredController,           builder: (context, child) {             final animationPercent = Curves.elasticOut.transform(                 _buttonInterval.transform(_staggeredController.value));             final opacity = animationPercent.clamp(0.0, 1.0);             final scale = (animationPercent * 0.5) + 0.5;              return Opacity(               opacity: opacity,               child: Transform.scale(                 scale: scale,                 child: child,               ),             );           },           child: ElevatedButton(             style: ElevatedButton.styleFrom(               shape: const StadiumBorder(),               primary: Colors.blue,               padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),             ),             onPressed: () {},             child: const Text(               'Get started',               style: TextStyle(                 color: Colors.white,                 fontSize: 22,               ),             ),           ),         ),       ),     );   } }