Skip to content

Animate changes to IndexedStack #48217

Open

Description

@Delgan

Hi.

Use case

This is about two very useful widgets:

Intuitively, it seems they could be combined in order to preserve the state between two widgets while animating the transition from one to the other. However, the following code will not work as expected:

AnimatedSwitcher(
    transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder,
    duration: const Duration(milliseconds: 500),
    child: IndexedStack(
        index: condition ? 1 : 0,
        children: [
            WidgetOne(),
            WidgetTwo(),
        ],
    ),
)

This does not work for obvious reason (assuming AnimatedSwitcher is rebuilt with a new condition value): the IndexedStack widget itself did not change, so there is no animation (while widgets are correctly replaced).

Proposal

I would like to suggest the addition of a new parameter or some kind of workaround to facilitate the use of AnimatedSwitcher with IndexedStack. Unfortunately, I don't know what this workaround should look like.

This is related to this issue: #39398. It was closed for the reason that it is "related to organising of widgets". I would like to re-open it as a "feature request".

A Stackoverflow question has been open consequently. As a Flutter beginner, I'm not comfortable with the proposed answers as they require to completely re-implement IndexedStack and / or Animation which I find prone-to-error compared to simply combine AnimatedSwitcher with IndexedStack (that seemed so much more convenient!).

Is there a possibility to make these two widgets more "user-friendly" when they are used together?

Activity

  1. changed the title [-]Ease the combined use of "AnimatedSwitcher" with "IndexedStack"[/-] [+]Animate changes to IndexedStack[/+] on Jan 6, 2020
  2. added
    frameworkflutter/packages/flutter repository. See also f: labels.
    c: new featureNothing broken; request for a new capability
    on Jan 6, 2020
  3. hoshinokanade commented on Jan 6, 2020

    @hoshinokanade

    I understand you want the Flutter framework to provide your desired functionality out of the box.

    I have read your stackoverflow question, and I think using both AnimatedSwitcher and IndexedStack together should be able working properly for you after digging into the documentation.

    If the "new" child is the same widget type and key as the "old" child, but with different parameters, then AnimatedSwitcher will not do a transition between them, since as far as the framework is concerned, they are the same widget and the existing widget can be updated with the new parameters. To force the transition to occur, set a Key on each child widget that you wish to be considered unique (typically a ValueKey on the widget data that distinguishes this child from the others).

    AnimatedSwitcher(
      transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder,
      duration: const Duration(milliseconds: 500),
      child: IndexedStack(
        // This key causes the AnimatedSwitcher to interpret this as a "new"
        // child each time the count changes, so that it will begin its animation
        // when the count changes.
        key: ValueKey<int>(condition? 1: 0), // add this line
        index: condition? 1: 0,
        children:  [
          WidgetOne(),
          WidgetTwo(),
        ],
      )
    )
    

    Flutter prefer widgets for a single use-case. By combining different widgets you get a board spectrum of functionality. Welcome to give further suggestion for feature request if you still not comfortable because many developers may want this feature.

  4. Delgan commented on Jan 6, 2020

    @Delgan
    Author

    Hi @hoshinokanade, thanks for your answer.

    Correct me if I'm wrong, but I tested your workaround and it seems it doesn't work well with IndexedStack: WidgetOne() and WidgetTwo() states are not preserved, they are rebuilt each time a transition occurs. 😕

  5. jeroen-meijer commented on Jan 8, 2020

    @jeroen-meijer

    Can confirm, the state is not preserved.
    I believe this reason is because you change the IndexedStacks key every time a page change occurs. The framework will then (correctly) throw away and rebuild the IndexedStack, and as a result the state is not preserved.

  6. sondresorbye commented on Apr 12, 2020

    @sondresorbye

    I want to use IndexedStack with the FadeThrough animtion from the animation library: https://pub.dev/packages/animations, but I am not able to do this becuase of this

  7. diegoveloper commented on Jul 10, 2020

    @diegoveloper
    Member
  8. nt4f04uNd commented on Dec 1, 2020

    @nt4f04uNd
    Member

    @sondresorbye0111 checkout this #56662 (comment) works well for me

  9. JasCodes commented on Dec 28, 2020

    @JasCodes
  10. petro-i commented on Jan 29, 2021

    @petro-i

    None of the solutions provided corresponds to initial requirements!
    Please take into account that:

    AnimatedSwitcher smoothly animates between child's (animate-out-old-animate-in-new), IndexedStack maintains child's state. Exact this combination of properties is required.

    Still waiting for solution.

  11. p4-k4 commented on Nov 22, 2021

    @p4-k4
  12. TEGRAXD commented on Jan 9, 2022

    @TEGRAXD
  13. globalwebforce commented on Jun 24, 2022

    @globalwebforce
  14. globalwebforce commented on Jun 24, 2022

    @globalwebforce

    Here's the best workaround for now, it has 80,000 score in StackOverflow

    https://stackoverflow.com/a/59535289/10494346

  15. maheshj01 commented on Jul 19, 2022

    @maheshj01
    Member

    @twoco You can try using FadeTransition, You can read about the implementation in this blog post on point 4.

    demo: https://dartpad.dev/?id=894922ccb67f5fdc4ffb652e41916fa2

  16. akshdeep-singh commented on Oct 15, 2022

    @akshdeep-singh
    Contributor

    I am using this method:

    Though it is a workaround, but is very simple
    and it also has exit animation for the index we are switching from.

    code sample
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          home: MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage();
    
      @override
      State<MyHomePage> createState() => _StateHomePage();
    }
    
    class _StateHomePage extends State<MyHomePage> with SingleTickerProviderStateMixin {
      late final AnimationController _animationController;
      var _indexNav = 1;
      var _indexStack = 1;
    
      @override
      void initState() {
        super.initState();
        _animationController = AnimationController(
          duration: const Duration(milliseconds: 500),
          value: 1,
          vsync: this,
        );
      }
    
      @override
      void dispose() {
        _animationController.dispose();
        super.dispose();
      }
    
      void setIndex(int value) {
        setState(() {
          _indexNav = value;
        });
        _animationController.reverse().then((_) {
          setState(() {
            _indexStack = value;
          });
          _animationController.forward();
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.black,
          appBar: AppBar(
            title: const Text('IndexedStack Animation Demo'),
          ),
          body: FadeTransition(
            opacity: CurvedAnimation(
              parent: _animationController,
              curve: Curves.ease,
            ),
            child: IndexedStack(
              index: _indexStack,
              children: const [
                Center(
                  child: Text(
                    'RED',
                    style: TextStyle(color: Colors.red),
                  ),
                ),
                Center(
                  child: Text(
                    'GREEN',
                    style: TextStyle(color: Colors.green),
                  ),
                ),
                Center(
                  child: Text(
                    'BLUE',
                    style: TextStyle(color: Colors.blue),
                  ),
                ),
              ],
            ),
          ),
          bottomNavigationBar: NavigationBar(
            selectedIndex: _indexNav,
            onDestinationSelected: setIndex,
            destinations: const [
              NavigationDestination(
                icon: Icon(Icons.queue_music),
                label: 'Red',
              ),
              NavigationDestination(
                icon: Icon(Icons.music_note_outlined),
                selectedIcon: Icon(Icons.music_note),
                label: 'Green',
              ),
              NavigationDestination(
                icon: Icon(Icons.library_music_outlined),
                selectedIcon: Icon(Icons.library_music),
                label: 'Blue',
              ),
            ],
          ),
        );
      }
    }
  17. added
    P3Issues that are less important to the Flutter project
    on Nov 29, 2022
  18. clragon commented on Jul 16, 2023

    @clragon

    While we wait for a change in the framework, I have written my own solution based on IndexedStack and PageTransitionSwitcher.

    It uses internal ChildEntrys to keep track of children, just like PageTransitionSwitcher and it uses Visibility to offstage invisible widgets just like IndexedStack.

    The code depends on the animations package for its default values. However, it would be possible to copy those defaults and type declarations into the file itself to make it independant.

    To make sure all animations are in correct order and our state is kept alive, we internally use GlobalKeys assigned to each ChildEntry. Because of that, all our children should have a unique key. It does not have to be global, just a regular ValueKey will do for identification.

    The code can be found here:
    https://gist.github.com/clragon/f26fe2fc2cc1574243c3407277e2997e

    (Or in this section, in case I ever delete that gist:)

    Source Code
    import 'package:flutter/foundation.dart';
    import 'package:flutter/widgets.dart';
    
    /// An internal representation of a child widget subtree that is a child of
    /// the [AnimatedIndexedStack].
    ///
    /// This keeps track of animation controllers, keys, and the child widget.
    class _ChildEntry {
      _ChildEntry({
        required this.key,
        required this.primaryController,
        required this.secondaryController,
        required this.child,
      });
    
      /// The key of this entry.
      /// This is usually a [GlobalKey] to ensure that children do not lose their state.
      final Key key;
    
      /// The animation controller for the child's transition.
      final AnimationController primaryController;
    
      /// The (curved) animation being used to drive the transition.
      final AnimationController secondaryController;
      Widget child;
    
      /// Release the resources used by this object.
      ///
      /// The object is no longer usable after this method is called.
      void dispose() {
        primaryController.dispose();
        secondaryController.dispose();
      }
    
      @override
      String toString() => 'AnimatedIndexedStackEntry#${shortHash(this)}($child)';
    }
    
    enum _ChildAnimationDirection {
      primaryForward,
      primaryReverse,
      secondaryForward,
      secondaryReverse,
    }
    
    /// A Widget that shows a single child from a list of children.
    /// Changing the index will animate the change of widgets according to the [transitionBuilder].
    /// Removing the widget at the current index will also animate the change.
    ///
    /// Widgets which are not currently visible will be kept alive until they are removed.
    class AnimatedIndexedStack extends StatefulWidget {
      const AnimatedIndexedStack({
        super.key,
        this.index = 0,
        this.duration = const Duration(milliseconds: 300),
        this.reverse = false,
        required this.transitionBuilder,
        this.layoutBuilder = defaultLayoutBuilder,
        this.children = const [],
      });
    
      /// The index of the child to show.
      ///
      /// If this is null, none of the children will be shown.
      final int? index;
    
      /// The duration of the transition from the old [child] value to the new one.
      final Duration duration;
    
      /// Indicates whether the new [child] will visually appear on top of or
      /// underneath the old child.
      final bool reverse;
    
      /// A function that wraps a new [child] with a primary and secondary animation
      /// set define how the child appears and disappears.
      final Widget Function(
        Widget child,
        Animation<double> primaryAnimation,
        Animation<double> secondaryAnimation,
      ) transitionBuilder;
    
      /// A function that lays out all the children in this IndexedStack.
      /// This defaults to [PageTransitionSwitcher.defaultLayoutBuilder].
      final Widget Function(List<Widget> entries) layoutBuilder;
    
      /// The child widgets of the stack.
      /// Only the child at index [index] will be shown.
      /// To correctly keep track of the state of child widgets, they must be given unique keys.
      final List<Widget> children;
    
      /// The default layout builder for [AnimatedIndexedStack].
      /// Contains all the children in a [Stack].
      static Widget defaultLayoutBuilder(List<Widget> entries) {
        return Stack(
          alignment: Alignment.center,
          children: entries,
        );
      }
    
      @override
      State<AnimatedIndexedStack> createState() => _AnimatedIndexedStackState();
    }
    
    class _AnimatedIndexedStackState extends State<AnimatedIndexedStack>
        with TickerProviderStateMixin {
      /// All entries contained in this Stack.
      /// This is built from the children list, but may also contain entries which are animating out.
      List<_ChildEntry> _entries = [];
    
      /// The entry which is currently at the top of the stack.
      _ChildEntry? _currentEntry;
    
      @override
      void initState() {
        super.initState();
        _updateEntriesList();
      }
    
      @override
      void didUpdateWidget(AnimatedIndexedStack oldWidget) {
        super.didUpdateWidget(oldWidget);
        _updateEntriesList();
      }
    
      /// In place operation to shift a child entry to the end of the list (the visual front).
      ///
      /// If entry is null, this is a no-op.
      void _moveToEnd(List<_ChildEntry> entries, _ChildEntry? entry) {
        if (entry == null) return;
        entries.remove(entry);
        entries.add(entry);
      }
    
      /// Inserts an entry as last place in the list and animates it.
      ///
      /// If entry is null, this is a no-op.
      void _insertAndAnimate(
        List<_ChildEntry> entries,
        _ChildEntry? entry,
        _ChildAnimationDirection direction,
      ) {
        if (entry == null) return;
        _moveToEnd(entries, entry);
        switch (direction) {
          case _ChildAnimationDirection.primaryForward:
            entry.primaryController.forward(from: 0);
            entry.secondaryController.value = 0;
          case _ChildAnimationDirection.primaryReverse:
            entry.primaryController.reverse(from: 1);
            entry.secondaryController.value = 0;
          case _ChildAnimationDirection.secondaryForward:
            entry.primaryController.value = 1;
            entry.secondaryController.forward(from: 0);
          case _ChildAnimationDirection.secondaryReverse:
            entry.primaryController.value = 1;
            entry.secondaryController.reverse(from: 1);
        }
      }
    
      /// Updates the list of child entries.
      /// Ensures to order the list appropriately and animate entries in and out.
      void _updateEntriesList() {
        List<_ChildEntry> entries = [];
    
        _ChildEntry? previousEntry = _currentEntry;
        _ChildEntry? currentEntry;
    
        Widget? currentChild;
        if (widget.index != null && widget.children.isNotEmpty) {
          currentChild = widget.children[widget.index!];
        }
    
        for (final child in widget.children) {
          // We find the previous entry by looking for an identical child widget.
          // If the children of this Stack share widget types, they must be given unique keys.
          int existingIndex =
              _entries.indexWhere((entry) => Widget.canUpdate(entry.child, child));
    
          _ChildEntry? existingEntry;
          if (existingIndex != -1) {
            existingEntry = _entries[existingIndex];
          }
    
          _ChildEntry entry;
    
          if (existingEntry != null) {
            // If we find an existing entry, we update its child widget and reuse it.
            // This ensures it continues to use the same global key and animation controllers.
            existingEntry.child = child;
            existingEntry.primaryController.duration = widget.duration;
            existingEntry.secondaryController.duration = widget.duration;
            entry = existingEntry;
          } else {
            entry = _newEntry(child);
          }
    
          if (currentChild == child) {
            currentEntry = entry;
          }
    
          entries.add(entry);
        }
    
        bool hasChanged = previousEntry != currentEntry;
        bool previousWasRemoved =
            previousEntry != null && !entries.contains(previousEntry);
    
        if (hasChanged) {
          if (widget.reverse) {
            // When reverse is true, the new child will transition in below the
            // old child while its secondary animation and the primary
            // animation of the old child are running in reverse. This is similar to
            // the transition associated with popping a [PageRoute] to reveal a new
            // [PageRoute] below it.
            _insertAndAnimate(
              entries,
              currentEntry,
              _ChildAnimationDirection.secondaryReverse,
            );
            _insertAndAnimate(
              entries,
              previousEntry,
              _ChildAnimationDirection.primaryReverse,
            );
            if (previousWasRemoved) {
              previousEntry.primaryController.addStatusListener((status) {
                if (status == AnimationStatus.dismissed) {
                  setState(() {
                    _entries.remove(previousEntry);
                    previousEntry.dispose();
                  });
                }
              });
            }
          } else {
            // When reverse is false, the new child will transition in on top of the
            // old child while its primary animation and the secondary
            // animation of the old child are running forward. This is similar to
            // the transition associated with pushing a new [PageRoute] on top of
            // another.
            _insertAndAnimate(
              entries,
              previousEntry,
              _ChildAnimationDirection.secondaryForward,
            );
            _insertAndAnimate(
              entries,
              currentEntry,
              _ChildAnimationDirection.primaryForward,
            );
            if (previousWasRemoved) {
              previousEntry.secondaryController.addStatusListener((status) {
                if (status == AnimationStatus.completed) {
                  setState(() {
                    _entries.remove(previousEntry);
                    previousEntry.dispose();
                  });
                }
              });
            }
          }
        } else {
          if (widget.reverse) {
            _moveToEnd(entries, currentEntry);
            _moveToEnd(entries, previousEntry);
          } else {
            _moveToEnd(entries, previousEntry);
            _moveToEnd(entries, currentEntry);
          }
        }
    
        setState(() {
          _entries = entries;
          _currentEntry = currentEntry;
        });
      }
    
      _ChildEntry _newEntry(Widget child) => _ChildEntry(
            key: GlobalKey(),
            child: child,
            primaryController: AnimationController(
              duration: widget.duration,
              vsync: this,
            ),
            secondaryController: AnimationController(
              duration: widget.duration,
              vsync: this,
            ),
          );
    
      @override
      void dispose() {
        for (final entry in _entries) {
          entry.dispose();
        }
        super.dispose();
      }
    
      Widget _buildChild(_ChildEntry entry) => AnimatedBuilder(
            animation: Listenable.merge([
              entry.primaryController,
              entry.secondaryController,
            ]),
            builder: (context, child) {
              bool isVisible = entry.primaryController.isAnimating ||
                  entry.secondaryController.isAnimating ||
                  entry == _currentEntry;
    
              return Visibility(
                visible: isVisible,
                maintainState: true,
                child: widget.transitionBuilder(
                  KeyedSubtree(
                    key: entry.key,
                    child: child!,
                  ),
                  entry.primaryController,
                  entry.secondaryController,
                ),
              );
            },
            child: entry.child,
          );
    
      @override
      Widget build(BuildContext context) {
        return widget.layoutBuilder(_entries.map(_buildChild).toList());
      }
    }

    I also have a piece of demonstration code:

    Demo Code
    import 'dart:math';
    
    import 'package:animations/animations.dart';
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart';
    import 'package:tabbed/stack.dart';
    
    class BrowserTab {
      BrowserTab({
        required WidgetBuilder builder,
      })  : key = UniqueKey(),
            _builder = builder;
    
      final WidgetBuilder _builder;
      final Key key;
    
      Widget builder(BuildContext context) => KeyedSubtree(
            key: key,
            child: _builder(context),
          );
    }
    
    class BrowserTabManager extends StatefulWidget {
      const BrowserTabManager({Key? key}) : super(key: key);
    
      @override
      State<BrowserTabManager> createState() => _BrowserTabManagerState();
    }
    
    class _BrowserTabManagerState extends State<BrowserTabManager>
        with TickerProviderStateMixin {
      late TabController _tabController;
      final List<BrowserTab> _tabs = [];
      int _previousTab = 0;
    
      void _onTabChanged() {
        setState(() {
          _previousTab = _tabController.previousIndex;
        });
      }
    
      void _addTab() {
        setState(() {
          _previousTab = _tabs.length;
          _tabs.add(
            BrowserTab(
              builder: (context) => Builder(
                builder: (context) => _AppPage(
                  title: shortHash(
                    context.findAncestorWidgetOfExactType<KeyedSubtree>()!.key!,
                  ),
                ),
              ),
            ),
          );
          _tabController = TabController(
            length: _tabs.length,
            vsync: this,
            initialIndex: _tabs.length - 1,
          );
          _tabController.addListener(_onTabChanged);
        });
      }
    
      void _closeTab(int index) {
        setState(() {
          _previousTab = index;
          _tabs.removeAt(index);
          int newIndex = index.clamp(0, max(0, _tabs.length - 1));
          _tabController.removeListener(_onTabChanged);
          _tabController = TabController(
            length: _tabs.length,
            vsync: this,
            initialIndex: newIndex,
          );
          _tabController.addListener(_onTabChanged);
        });
      }
    
      @override
      void initState() {
        super.initState();
        _addTab();
      }
    
      @override
      void dispose() {
        _tabController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
          animation: _tabController,
          builder: (context, _) => Scaffold(
            appBar: AppBar(title: Text('Tabbed ${_tabController.index + 1}')),
            body: AnimatedIndexedStack(
              reverse: _tabController.index < _previousTab ||
                  (_tabController.index == 0 && _tabs.isEmpty),
              index: _tabController.index,
              duration: const Duration(milliseconds: 1000),
              transitionBuilder: (child, animation, secondaryAnimation) =>
                  SharedAxisTransition(
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                transitionType: SharedAxisTransitionType.horizontal,
                child: child,
              ),
              children: _tabs.map((tab) => tab.builder(context)).toList(),
            ),
            bottomNavigationBar: MediaQuery.of(context).size.width > 600
                ? TabBar(
                    controller: _tabController,
                    tabs: _tabs
                        .map(
                          (tab) => Tab(
                            text: 'Tab ${shortHash(tab.key)}',
                          ),
                        )
                        .toList(),
                  )
                : null,
            floatingActionButton: Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                if (_tabs.isNotEmpty)
                  FloatingActionButton(
                    child: const Icon(Icons.close),
                    onPressed: () => _closeTab(_tabController.index),
                  ),
                const SizedBox(width: 8),
                FloatingActionButton(
                  onPressed: _addTab,
                  child: const Icon(Icons.add),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class _AppPage extends StatelessWidget {
      const _AppPage({
        this.title,
      });
    
      final String? title;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                if (title != null)
                  Text(
                    title!,
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                const _TimeAliveWidget(),
              ],
            ),
          ),
        );
      }
    }
    
    class _TimeAliveWidget extends StatefulWidget {
      const _TimeAliveWidget();
    
      @override
      State<_TimeAliveWidget> createState() => _TimeAliveWidgetState();
    }
    
    class _TimeAliveWidgetState extends State<_TimeAliveWidget>
        with TickerProviderStateMixin {
      final DateTime _startTime = DateTime.now();
      Duration _elapsedTime = Duration.zero;
      late Ticker _ticker;
    
      @override
      void initState() {
        super.initState();
        _ticker = createTicker(_updateTime)..start();
      }
    
      void _updateTime(Duration duration) {
        setState(() {
          _elapsedTime = DateTime.now().difference(_startTime);
        });
      }
    
      @override
      void dispose() {
        _ticker.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        String twoDigits(int n) => n.toString().padLeft(2, "0");
        String threeDigits(int n) => n.toString().padLeft(3, "0");
        String hours = twoDigits(_elapsedTime.inHours.remainder(24));
        String minutes = twoDigits(_elapsedTime.inMinutes.remainder(60));
        String seconds = twoDigits(_elapsedTime.inSeconds.remainder(60));
        String millis = threeDigits(_elapsedTime.inMilliseconds.remainder(1000));
        return Center(
          child: Text('$hours:$minutes:$seconds.$millis'),
        );
      }
    }

    Which we can see in this video:

    ZN4El9wVeM.mp4

    Visible in the video are the most important points; our Timer in the pages does not reset when switching between tabs, as the State is kept alive. The transitions work correctly, with reverse specified. The children are removed once they have animated out.

    The debug overlay shown in the video is not in the gist, as its not useful for production.
    But I will include it here, in case somebody wants to test around with it. This is simply a replacement for the build method of the AnimatedIndexedStack:

    Debug Code
    @override
      Widget build(BuildContext context) {
        return Stack(
          fit: StackFit.passthrough,
          children: [
            widget.layoutBuilder(
              _entries.map(_buildChild).toList(),
            ),
            Material(
              type: MaterialType.transparency,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('reverse: ${widget.reverse}'),
                  ..._entries.map((e) {
                    return AnimatedBuilder(
                      animation: Listenable.merge([
                        e.primaryController,
                        e.secondaryController,
                      ]),
                      builder: (context, _) => Text(
                        '${shortHash(e.child.key)}; '
                        'visible: ${e.primaryController.isAnimating || e.secondaryController.isAnimating || e == _currentEntry}; '
                        'primary: ${e.primaryController.isAnimating}; '
                        'secondary: ${e.secondaryController.isAnimating}; '
                        'current: ${e == _currentEntry}',
                      ),
                    );
                  }),
                ],
              ),
            ),
          ],
        );
      }

    Additionally, I have experimented with getting the same transitions as a MaterialPageRoute.

    I thought I might be able to simply access Theme.of(context).pageTransitionsTheme and use it, however, it demands a Route to build the transitions. It seems as of right now, there is no way of retrieving these animations without passing a Route. This is unfortunate, but we can hack around this requirement by simply passing the surrounding ModalRoute.

    However, This leads to weird artefacting from ZoomPageTransition because it uses so called snapshots. We can disable these with a workaround and eventually arrive at a hacky but working animated indexed stack that functions similar to a navigator without being one.

    To disable the snapshots, we have to override the page transition theme. We can do that locally just for our AnimatedIndexedStack usage, like so:

    Transition Code
    PageTransitionsTheme _buildPageTransitionsTheme() {
      Map<TargetPlatform, PageTransitionsBuilder> builders = Map.fromEntries(
        Theme.of(context).pageTransitionsTheme.builders.entries.map(
          (e) {
            if (e.value is ZoomPageTransitionsBuilder) {
              return MapEntry(
                e.key,
                const ZoomPageTransitionsBuilder(
                  allowSnapshotting: false,
                ),
              );
            } else {
              return e;
            }
          },
        ),
      );
      if (builders[TargetPlatform.windows] == null) {
        builders[TargetPlatform.windows] = const ZoomPageTransitionsBuilder(
          allowSnapshotting: false,
        );
      }
      return PageTransitionsTheme(
        builders: builders,
      );
    }

    which we can then use like this:

    AnimatedIndexedStack(
      transitionBuilder: (child, animation, secondaryAnimation) =>
        _buildPageTransitionsTheme().buildTransitions(
          ModalRoute.of(context)! as PageRoute<dynamic>,
          context,
          animation,
          secondaryAnimation,
          child,
        ),
      // ...
    ),

    I hope this is useful to someone out there, for the time until we have a proper inbuilt solution.

  19. maheshj01 commented on Dec 8, 2023

    @maheshj01
    Member

    Here's a custom implementation of FadeTransition with IndexedStack built on top of the official sample
    Unfortunately with Indexed Stack it doesn't seem to fade well.

    Indexed stack

    Screen.Recording.2023-12-08.at.16.32.15.mov

    Stack

    Screen.Recording.2023-12-08.at.16.28.42.mov
    complete code sample
    import 'dart:math';
    
    import 'package:flutter/material.dart';
    
    /// Flutter code sample for [NavigationBar].
    
    void main() => runApp(const NavigationBarApp());
    
    class NavigationBarApp extends StatelessWidget {
      const NavigationBarApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          theme: ThemeData(useMaterial3: true),
          home: const NavigationExample(),
        );
      }
    }
    
    class Conversation {
      String text;
      bool isMe;
    
      Conversation(this.text, this.isMe);
    }
    
    class NavigationExample extends StatefulWidget {
      const NavigationExample({super.key});
    
      @override
      State<NavigationExample> createState() => _NavigationExampleState();
    }
    
    class _NavigationExampleState extends State<NavigationExample>
        with TickerProviderStateMixin {
      int currentPageIndex = 0;
    
      List<Widget> body = <Widget>[
        const Home(),
        const Notifications(),
        Messages(),
      ];
    
      List<NavigationDestination> navDestinations = const [
        NavigationDestination(
          selectedIcon: Icon(Icons.home),
          icon: Icon(Icons.home_outlined),
          label: 'Home',
        ),
        NavigationDestination(
          icon: Badge(child: Icon(Icons.notifications_sharp)),
          label: 'Notifications',
        ),
        NavigationDestination(
          icon: Badge(
            label: Text('2'),
            child: Icon(Icons.messenger_sharp),
          ),
          label: 'Messages',
        ),
      ];
    
      late List<AnimationController> fadeAnimation;
    
      @override
      void initState() {
        super.initState();
        initAnimation();
      }
    
      @override
      void dispose() {
        fadeAnimation.forEach((AnimationController controller) {
          controller.dispose();
        });
        super.dispose();
      }
    
      void initAnimation() {
        fadeAnimation = navDestinations.map<AnimationController>((destination) {
          return AnimationController(
            value: destination == navDestinations[currentPageIndex] ? 1.0 : 0.0,
            vsync: this,
            duration: const Duration(milliseconds: 200),
          );
        }).toList();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          bottomNavigationBar: NavigationBar(
            onDestinationSelected: (int index) {
              setState(() {
                currentPageIndex = index;
              });
              _handleAnimation();
            },
            indicatorColor: Colors.amber,
            selectedIndex: currentPageIndex,
            destinations: navDestinations,
          ),
          body: Stack(
            // index: currentPageIndex,
            children: [
              for (int i = 0; i < body.length; i++)
                _buildIndexedStackItem(i, context),
            ],
          ),
        );
      }
    
      void _handleAnimation() {
        for (int i = 0; i < fadeAnimation.length; i++) {
          if (i == currentPageIndex) {
            fadeAnimation[i].forward();
          } else {
            fadeAnimation[i].reverse();
          }
        }
      }
    
      Widget _buildIndexedStackItem(int index, BuildContext context) {
        return AnimatedBuilder(
          animation: fadeAnimation[index],
          builder: (context, child) {
            return IgnorePointer(
              ignoring: index != currentPageIndex,
              child: Opacity(
                opacity: fadeAnimation[index].value,
                child: child,
              ),
            );
          },
          child: body[index],
        );
      }
    }
    
    class Home extends StatelessWidget {
      const Home({super.key});
    
      @override
      Widget build(BuildContext context) {
        final theme = Theme.of(context);
        return Card(
          shadowColor: Colors.transparent,
          margin: const EdgeInsets.all(8.0),
          child: SizedBox.expand(
            child: Center(
              child: Text(
                'Home page',
                style: theme.textTheme.titleLarge,
              ),
            ),
          ),
        );
      }
    }
    
    class Notifications extends StatelessWidget {
      const Notifications({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const SafeArea(
          child: Padding(
            padding: EdgeInsets.all(8.0),
            child: Column(
              children: <Widget>[
                Card(
                  child: ListTile(
                    leading: Icon(Icons.notifications_sharp),
                    title: Text('Notification 1'),
                    subtitle: Text('This is a notification'),
                  ),
                ),
                Card(
                  child: ListTile(
                    leading: Icon(Icons.notifications_sharp),
                    title: Text('Notification 2'),
                    subtitle: Text('This is a notification'),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class Messages extends StatelessWidget {
      Messages({super.key});
      List<Conversation> conversations = [
        Conversation('Hi!', true),
        Conversation('Hello!', false),
        Conversation('How are you?', true),
        Conversation('I\'m fine, thanks!', false),
        Conversation('What are you doing?', true),
        Conversation('Nothing much', false),
        Conversation('Ok', true),
        Conversation('Bye!', false),
      ];
    
      TextEditingController textEditingController = TextEditingController();
      @override
      Widget build(BuildContext context) {
        Widget message(String text, bool isMe) => Align(
              alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
              child: Container(
                margin: const EdgeInsets.all(8.0),
                padding: const EdgeInsets.all(8.0),
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.primary,
                  borderRadius: BorderRadius.circular(8.0),
                ),
                child: Text(
                  text,
                  style: Theme.of(context).textTheme.bodyLarge!.copyWith(
                        color: Theme.of(context).colorScheme.onPrimary,
                      ),
                ),
              ),
            );
    
        return Column(
          children: [
            Expanded(
              child: ListView.builder(
                reverse: true,
                itemCount: conversations.length * 10,
                itemBuilder: (BuildContext context, int index) {
                  if (index == 0) {
                    return message(conversations[0].text, conversations[0].isMe);
                  }
                  return message(conversations[index % conversations.length].text,
                      conversations[index % conversations.length].isMe);
                },
              ),
            ),
            // message input
    
            Container(
              margin: const EdgeInsets.all(8.0),
              padding: const EdgeInsets.all(8.0),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(8.0),
              ),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: textEditingController,
                      decoration: const InputDecoration(
                        hintText: 'Type a message',
                        border: InputBorder.none,
                      ),
                    ),
                  ),
                  IconButton(
                    onPressed: () {
                      conversations.insert(
                        0,
                        Conversation(
                            textEditingController.text.trim(), Random().nextBool()),
                      );
                      textEditingController.clear();
                      FocusScope.of(context).unfocus();
                    },
                    icon: const Icon(Icons.send),
                  ),
                ],
              ),
            ),
          ],
        );
      }
    }
    
  20. shubhamsinghshubham777 commented on Sep 18, 2024

    @shubhamsinghshubham777

    This simple code has worked for my simple requirements so far. Hope it helps somebody until the Flutter team comes up with an actual solution/implementation.

    Btw I've created an easy to use library (a legit drop-in replacement 😂) using this idea.

    Demo

    Screen.Recording.2024-09-19.at.2.28.34.AM.mp4

    Simple Usage

      @override
      Widget build(BuildContext context) {
        return AnimatedIndexedStack(
          index: selectedIndex,
          children: [
            ScreenA(),
            ScreenB(),
            ScreenC(),
          ],
        );
      }

    Code

    import 'package:flutter/material.dart';
    
    class AnimatedIndexedStack extends StatefulWidget {
      const AnimatedIndexedStack({
        required this.index,
        required this.children,
        this.duration = const Duration(milliseconds: 300),
        this.curve = Curves.easeInOut,
        super.key,
      });
    
      final int index;
      final List<Widget> children;
      final Duration duration;
      final Curve curve;
    
      @override
      State<AnimatedIndexedStack> createState() => _AnimatedIndexedStackState();
    }
    
    class _AnimatedIndexedStackState extends State<AnimatedIndexedStack>
        with SingleTickerProviderStateMixin {
      late int _currentIndex = widget.index;
      late final AnimationController _animationController;
      late final Animation<double> _animation;
      late final Animation<double> _secondaryAnimation;
    
      @override
      void initState() {
        _animationController = AnimationController(
          vsync: this,
          duration: widget.duration,
        );
        _animation = Tween<double>(begin: 1, end: 0).animate(
          CurvedAnimation(
            parent: _animationController,
            curve: widget.curve,
          ),
        );
        _secondaryAnimation = Tween<double>(begin: 0, end: 10).animate(
          CurvedAnimation(
            parent: _animationController,
            curve: widget.curve,
          ),
        );
        super.initState();
      }
    
      @override
      void didUpdateWidget(covariant AnimatedIndexedStack oldWidget) {
        if (oldWidget.index != widget.index) {
          // Here `0` means `begin` and `1` means `end` in the `animation` Tween
          // above. Therefore, `begin (1)` is visible and `end (0)` is invisible.
          // So the animation is basically: hide (w/ anim) -> switching index -> unhide (w/ anim)
          _animationController.animateTo(1).then((_) {
            setState(() => _currentIndex = widget.index);
            _animationController.animateTo(0);
          });
        }
        super.didUpdateWidget(oldWidget);
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
          animation: _animation,
          builder: (_, child) {
            return Opacity(opacity: _animation.value, child: child);
          },
          child: AnimatedBuilder(
            animation: _secondaryAnimation,
            builder: (_, child) {
              return Transform.translate(
                offset: Offset(0, _secondaryAnimation.value),
                child: child,
              );
            },
            child: IndexedStack(
              index: _currentIndex,
              children: widget.children,
            ),
          ),
        );
      }
    }
  21. vasilich6107 commented on Dec 10, 2024

    @vasilich6107
    Contributor

    For anyone who will find this issue - tried all examples mentioned above - unfortunately none of them was able to achieve the crossfade behavior

  22. krll-kov commented on Jan 24, 2025

    @krll-kov
    Contributor

    Working solution that maintains state, uses cross fade effect and lazy builds children

    /// A lazy-loading [_IndexedStack] that loads [children] accordingly.
    class LazyIndexedStack extends StatefulWidget {
      const LazyIndexedStack({
        super.key,
        this.alignment = AlignmentDirectional.topStart,
        this.textDirection,
        this.sizing = StackFit.loose,
        this.index = 0,
        this.children = const [],
      });
    
      final AlignmentGeometry alignment;
      final TextDirection? textDirection;
      final StackFit sizing;
      final int index;
      final List<Widget> children;
    
      @override
      LazyIndexedStackState createState() => LazyIndexedStackState();
    }
    
    class LazyIndexedStackState extends State<LazyIndexedStack> {
      late List<bool> _activated = _initializeActivatedList();
    
      List<bool> _initializeActivatedList() =>
          List<bool>.generate(widget.children.length, (i) => i == widget.index);
    
      @override
      void didUpdateWidget(covariant LazyIndexedStack oldWidget) {
        if (oldWidget.children.length != widget.children.length) {
          _activated = _initializeActivatedList();
        }
        super.didUpdateWidget(oldWidget);
      }
    
      @override
      Widget build(BuildContext context) {
        // Mark current index as active
        _activated[widget.index] = true;
        final children = List.generate(_activated.length, (i) {
          return _activated[i] ? widget.children[i] : const SizedBox.shrink();
        });
        return AnimatedIndexedStack(
          alignment: widget.alignment,
          sizing: widget.sizing,
          textDirection: widget.textDirection,
          index: widget.index,
          children: children,
          duration: Times.m200,
          firstCurve: Curves.easeIn,
          secondCurve: Curves.easeIn,
        );
      }
    }
    
    class AnimatedIndexedStack extends StatelessWidget {
      const AnimatedIndexedStack({
        super.key,
        this.alignment = AlignmentDirectional.topStart,
        this.textDirection,
        this.sizing = StackFit.loose,
        required this.index,
        required this.children,
        required this.duration,
        required this.firstCurve,
        required this.secondCurve,
      });
    
      final AlignmentGeometry alignment;
      final TextDirection? textDirection;
      final StackFit sizing;
      final int index;
      final List<Widget> children;
      final Duration duration;
    
      final Curve firstCurve;
      final Curve secondCurve;
    
      @override
      Widget build(BuildContext context) {
        final List<Widget> wrappedChildren = List<Widget>.generate(
          children.length,
          (int i) {
            return AnimatedCrossFade(
              duration: duration,
              firstChild: children[i],
              secondChild: const SizedBox(),
              firstCurve: firstCurve,
              secondCurve: secondCurve,
              crossFadeState: const <bool, CrossFadeState>{
                true: CrossFadeState.showFirst,
                false: CrossFadeState.showSecond,
              }[i == index]!,
              layoutBuilder: (Widget top, Key key1, Widget bottom, Key key2) {
                return Stack(
                  clipBehavior: Clip.none,
                  children: <Widget>[
                    Positioned.fill(key: key1, child: top),
                    Positioned.fill(key: key2, child: bottom),
                  ],
                );
              },
            );
          },
        );
    
        return Stack(
          alignment: alignment,
          textDirection: textDirection,
          fit: sizing,
          children: wrappedChildren,
        );
      }
    }
  23. intraector commented on Jul 20, 2025

    @intraector

    Previous solutions did not work well for me. For example, if there are GlobalKeys in the children (I use multiple Navigators) you going to have error "Multiple widgets used the same GlobalKey".

    So here is my solution, which takes a screenshots and fades it away while fading in the active child:

    import 'dart:ui' as ui show Image;
    
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    class FadeIndexedStack extends StatefulWidget {
      const FadeIndexedStack({
        required this.index,
        required this.children,
        this.duration = const Duration(milliseconds: 100),
        super.key,
      });
    
      final List<Widget> children;
      final Duration duration;
      final int index;
    
      @override
      State<FadeIndexedStack> createState() => _FadeIndexedStackState();
    }
    
    class _FadeIndexedStackState extends State<FadeIndexedStack> with SingleTickerProviderStateMixin {
      final repaintKey = GlobalKey<State<StatefulWidget>>();
    
      ui.Image? image;
    
      late Animation<double> _animation;
      late AnimationController _controller;
      int _currentIndex = -1;
    
      @override
      void didUpdateWidget(FadeIndexedStack oldWidget) {
        if (widget.index != oldWidget.index) {
          saveScreenshot().whenComplete(
            () {
              if (!mounted) return;
              setState(() {
                _currentIndex = widget.index;
                _controller.forward(from: 0);
              });
            },
          );
        }
        super.didUpdateWidget(oldWidget);
      }
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      void initState() {
        _currentIndex = widget.index;
        _controller = AnimationController(
          vsync: this,
          duration: widget.duration,
        );
        _animation = CurvedAnimation(
          parent: _controller,
          curve: Curves.easeOut,
        );
        _controller.forward();
        super.initState();
      }
    
      Future<void> saveScreenshot() async {
        if (!mounted) return;
        final boundary = repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
        image = await boundary!.toImage();
      }
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            if (image != null)
              FadeTransition(
                opacity: Tween<double>(begin: 1, end: 0).animate(_animation),
                child: RawImage(image: image),
              ),
            FadeTransition(
              opacity: Tween<double>(begin: 0, end: 1).animate(_animation),
              child: RepaintBoundary(
                key: repaintKey,
                child: IndexedStack(
                  index: _currentIndex,
                  children: widget.children,
                ),
              ),
            ),
          ],
        );
      }
    }
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projecta: animationAnimation APIsc: new featureNothing broken; request for a new capabilityframeworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions