Search code examples
flutterflutter-layout

How can I intrinsically size and show a widget with an animation from a list of widgets?


I would like to have a widget that takes any number of children widgets and displays whatever widget is selected. A widget is selected by index of the widget in the children list.

In order to select a widget, I create a stateful widget whose builder method returns the selected child. The ChildByIndexState allows some other widget to access and update the selected child index.

class ChildByIndex extends StatefulWidget {
  const ChildByIndex({
    Key? key,
    required this.index,
    required this.children,
  }) : super(key: key);

  final int index;
  final List<Widget> children;

  @override
  State<ChildByIndex> createState() => ChildByIndexState();
}

class ChildByIndexState extends State<ChildByIndex> {
  late int _index = widget.index;

  int get index => _index;
  
  /// Update the state if the index is different from the current index.
  set index(int value) {
    if (value != _index) setState(() => _index = value);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 300),
      child: SizedBox(key: ValueKey(_index), child: widget.children[_index]),
    );
  }
}

So that takes care of switching widgets with a nice animation, next I need to tackle the dynamic resizing based on the selected widget's intrinsic size. The demonstration widget uses a AnimatedSize widget to animate size transitions and a GlobalKey<ChildByIndexState> to set the index.

class Resizer extends StatelessWidget {
  const Resizer({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[
      Container(color: Colors.red, width: 100, height: 100),
      Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: const [
          SizedBox(height: 100, child: Card(color: Colors.purple)),
          SizedBox(height: 100, child: Card(color: Colors.pink)),
        ],
      ),
      Container(color: Colors.green, width: 250, height: 150),
    ];

    final childByIndexKey = GlobalKey<ChildByIndexState>();

    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            Positioned.fill(
              child: Center(
                child: AnimatedSize(
                  duration: const Duration(milliseconds: 300),
                  reverseDuration: const Duration(milliseconds: 300),
                  curve: Curves.easeOutCubic,
                  child: ChildByIndex(
                    key: childByIndexKey,
                    index: 0,
                    children: children,
                  ),
                ),
              ),
            ),

            /// Sets the index of [ChildByIndex] and wraps around to the first
            /// child index when the index is past at the final child.
            Positioned(
              width: 64.0,
              height: 64.0,
              top: 8.0,
              left: 8.0,
              child: FloatingActionButton(
                onPressed: () {
                  final childByIndexState = childByIndexKey.currentState;

                  if (childByIndexState == null) return;

                  childByIndexState.index =
                      (childByIndexState.index + 1) % children.length;
                },
                child: const Icon(Icons.ac_unit),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Issue

The AnimatedSize widget only works when going from a smaller size to a larger size. It skips (no animation) when going from a larger size to a smaller size. How can I fix this?

AnimatedContainer needs a width and height to animate towards, but I don't want to specify anything (the goal is intrinsic size) and the sizes are not available until the build method completes.


Solution

  • Solution:

    A custom MultiChildRenderObjectWidget that uses a selectedIndex parameter to find a matching child from its children and then sets its size based on a SizeTween animation that begins from the size of the previously selected child (if any) and ends at the size of the newly selected child.

    Output:

    output

    Considerations:

    The following is an incomplete list of features that could be implemented. I leave them as an exercise to anyone who wishes to use the code.

    • This could be optimized by using a List<Widget> instead of the linked-list like child access provided by the ContainerRenderObjectMixin.
    • The child switching process could be done with a builder method, or a mix of previous constructed methods and builders for certain children.
    • Custom transitions can be parameterized through paint callbacks or objects that handle the painting of the widget.

    Source: The source code is fairly well documented and can be read without further explanation.

    The AnimatedSizeSwitcherParentData is not necessary for this current implementation. It will be useful for adding additional behavior, such as animating the position/offset of the currently selected child.

    /// Every child of a [MultiChildRenderObject] has [ParentData].
    /// Since this is a container-type of widget, we can used the predefined
    /// [ContainerBoxParentData] which allows children to have access to their
    /// previous and next sibling.
    ///
    /// * See [ContainerBoxParentData].
    /// * See [ContainerParentDataMixin].
    class AnimatedSizeSwitcherParentData extends ContainerBoxParentData<RenderBox> {}
    

    The IntrinsicSizeSwitcher and its related RenderIntrinsicSizeSwitcher provide the functionality.

    /// A widget sizes itself according to the size of its selected child.
    class IntrinsicSizeSwitcher extends MultiChildRenderObjectWidget {
      IntrinsicSizeSwitcher({
        Key? key,
        // Since this is a [MultiChildRenderObjectWidget], we pass the children
        // to the super constructor.
        required AnimationController animationController,
        required Curve curve,
        required int selectedIndex,
        required List<Widget> children,
      })  : assert(selectedIndex >= 0),
            _animationController = animationController,
            _animation = CurvedAnimation(parent: animationController, curve: curve),
            _selectedIndex = selectedIndex,
            super(key: key, children: children);
    
      final AnimationController _animationController;
      final CurvedAnimation _animation;
      final int _selectedIndex;
    
      @override
      RenderObject createRenderObject(BuildContext context) {
        // The custom [RenderObject] that we define to create our custom widget.
        return RenderIntrinsicSizeSwitcher(
          animationController: _animationController,
          animation: _animation,
          selectedIndex: _selectedIndex,
        );
      }
    
      @override
      void updateRenderObject(
        BuildContext context,
        RenderIntrinsicSizeSwitcher renderObject,
      ) {
        /// The [RenderObject] is updated when [selectedIndex] changes, so that
        /// the old child can be replaced by the new child.
        renderObject.selectedIndex = _selectedIndex;
      }
    }
    
    class RenderIntrinsicSizeSwitcher extends RenderBox
        with ContainerRenderObjectMixin<RenderBox, AnimatedSizeSwitcherParentData> {
      RenderIntrinsicSizeSwitcher({
        required AnimationController animationController,
        required CurvedAnimation animation,
        required int selectedIndex,
      })  : _animationController = animationController,
            _animation = animation,
            _selectedIndex = selectedIndex {
        _onLayout = _onFirstLayout;
    
        /// Listen to animation changes so that the layout of this [RenderBox] can
        /// be adjusted according to [animationController.value].
        animationController.addListener(
          () {
            if (_lastValue != animationController.value) markNeedsLayout();
          },
        );
      }
    
      final AnimationController _animationController;
      final CurvedAnimation _animation;
    
      /// A [SizeTween] whose [begin] is the size of the previous child, and
      /// whose [end] is the size of the next child.
      final SizeTween _sizeTween = SizeTween();
    
      /// Called by [performLayout]. This method is initialized to [_onFirstLayout]
      /// so that the first child can be found by index and laid out.
      ///
      /// After [_onFirstLayout] completes, additional layout passes have two
      /// possibilities: the selected child changed or the current child is being
      /// used to update the size of this [RenderBox].
      late void Function() _onLayout;
    
      int _selectedIndex;
      int get selectedIndex => _selectedIndex;
      set selectedIndex(int value) {
        assert(selectedIndex >= 0);
    
        /// Update [_selectedIndex] if it is different from [value], because this
        /// method restarts [_animationController], which calls [markNeedsLayout].
        if (_selectedIndex == value) return;
    
        _selectedIndex = value;
    
        /// No need to call [markNeedsLayout] because this [RenderBox] is a
        /// listener of [_animationController].
        ///
        /// The listener callback calls [markNeedsLayout] whenever [_lastValue]
        /// differs from [_animationController.value] in order to use the new
        /// animation value to update the layout.
        _animationController.forward(from: .0);
      }
    
      @override
      bool get sizedByParent => false;
    
      Pair<RenderBox?, Size>? _oldSelection;
      Pair<RenderBox?, Size>? _selection;
      double? _lastValue;
    
      @override
      void setupParentData(covariant RenderObject child) {
        if (child.parentData is AnimatedSizeSwitcherParentData) return;
        child.parentData = AnimatedSizeSwitcherParentData();
      }
    
      Pair<RenderBox?, Size> _findSelectedChildAndDryLayout() {
        var child = firstChild;
        var index = 0;
    
        /// Find the child matching [selectedIndex].
        while (child != null) {
          if (index == selectedIndex) {
            return Pair(first: child, second: child.computeDryLayout(constraints));
          }
    
          var childParentData = child.parentData as AnimatedSizeSwitcherParentData;
          child = childParentData.nextSibling;
          ++index;
        }
    
        /// No matching child was found.
        return const Pair(first: null, second: Size.zero);
      }
    
      /// Find the child corresponding to [selectedIndex] and perform a wet layout.
      Pair<RenderBox?, Size> _findSelectedChildAndWetLayout() {
        var child = firstChild;
        var index = 0;
    
        while (child != null) {
          if (index == selectedIndex) {
            return Pair(
              first: child,
              second: wetLayoutSizeComputer(child, constraints),
            );
          }
    
          var childParentData = child.parentData as AnimatedSizeSwitcherParentData;
          child = childParentData.nextSibling;
          ++index;
        }
    
        return const Pair(first: null, second: Size.zero);
      }
    
      @override
      void performLayout() {
        _lastValue = _animationController.value;
        _onLayout();
      }
    
      @override
      Size computeDryLayout(BoxConstraints constraints) {
        final child = _selection?.first;
        if (child == null) return Size.zero;
        return child.getDryLayout(constraints);
      }
    
      @override
      double computeMaxIntrinsicWidth(double height) {
        final child = _selection?.first;
        if (child == null) return .0;
        return child.getMaxIntrinsicWidth(height);
      }
    
      @override
      double computeMaxIntrinsicHeight(double width) {
        final child = _selection?.first;
        if (child == null) return .0;
        return child.getMaxIntrinsicHeight(width);
      }
    
      @override
      bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
        final child = _selection?.first;
    
        /// If there is no current child, then there is nothing to hit test.
        if (child == null) return false;
    
        var childParentData = child.parentData as BoxParentData;
    
        /// This [RenderBox] only displays one child at a time, so it only needs to
        /// hit test the child being displayed.
        final isHit = result.addWithPaintOffset(
          offset: childParentData.offset,
          position: position,
          hitTest: (BoxHitTestResult result, Offset transformed) {
            assert(transformed == position - childParentData.offset);
            return child.hitTest(result, position: transformed);
          },
        );
    
        return isHit;
      }
    
      @override
      void paint(PaintingContext context, Offset offset) {
        final child = _selection?.first;
    
        /// If there is no current child, then there is nothing to paint.
        if (child == null) return;
    
        /// Clip the painted area to [size], which is set to the value of
        /// [animatedSize] during layout; this prevents having to resizing the old
        /// child which can cause visual overflows.
        context.pushClipRect(
          true,
          offset,
          Offset.zero & size,
          (PaintingContext context, Offset offset) {
            /// The animation dependent alpha value is used to fade out the old
            /// child and fade in the current child.
            final alpha = (_animationController.value * 255).toInt();
    
            final oldChild = _oldSelection?.first;
    
            /// If there is an old child, paint it first, so that it is painted
            /// below the current child.
            ///
            /// We only want to paint the old child if the animation is running.
            /// Once the animation is completed, the old child is fully transparent.
            /// Subsequently, it is no longer necessary to paint it.
            if (oldChild != null && _animationController.isAnimating) {
              context.pushOpacity(
                offset,
                255 - alpha,
                (PaintingContext context, Offset offset) {
                  final childOffset = (oldChild.parentData as BoxParentData).offset;
                  context.paintChild(oldChild, childOffset + offset);
                },
              );
            }
    
            context.pushOpacity(
              offset,
              alpha,
              (PaintingContext context, Offset offset) {
                final childOffset = (child.parentData as BoxParentData).offset;
                context.paintChild(child, childOffset + offset);
              },
            );
          },
        );
      }
    
      @override
      double? computeDistanceToActualBaseline(TextBaseline baseline) {
        assert(!debugNeedsLayout);
    
        final child = _selection?.first;
    
        if (child == null) return null;
    
        final result = child.getDistanceToActualBaseline(baseline);
    
        if (result == null) return null;
    
        return result + (child.parentData as BoxParentData).offset.dy;
      }
    
      /// Calculates the animated size of this [RenderBox].
      void _performLayout(RenderBox child) {
        final childParentData = child.parentData as AnimatedSizeSwitcherParentData;
    
        final animatedSize = _sizeTween.evaluate(_animation)!;
        final childSize = wetLayoutSizeComputer(child, constraints);
    
        final oldChild = _oldSelection?.first;
    
        if (oldChild != null) {
          final oldChildSize = wetLayoutSizeComputer(oldChild, constraints);
          final oldChildParentData = oldChild.parentData as BoxParentData;
    
          /// Center the old child.
          oldChildParentData.offset = Offset(
            (animatedSize.width - oldChildSize.width) / 2.0,
            (animatedSize.height - oldChildSize.height) / 2.0,
          );
        }
    
        /// Center the new child.
        childParentData.offset = Offset(
          (animatedSize.width - childSize.width) / 2.0,
          (animatedSize.height - childSize.height) / 2.0,
        );
    
        size = animatedSize;
      }
    
      void _onFirstLayout() {
        /// The first layout pass must find the selected child by index and perform
        /// a wet layout.
        final selectedChild = _findSelectedChildAndWetLayout();
    
        /// If [selection.first] is null, then the child list is empty, so there's
        /// nothing to lay out, and [Size.zero] is returned.
        if (selectedChild.first == null) {
          size = Size.zero;
          return;
        }
    
        /// Since this is the first pass, [_sizeTween.begin] and [_sizeTween.end]
        /// will just be set to the size of the currently selected child.
        _sizeTween.begin = _sizeTween.end = selectedChild.second;
    
        /// The selection is updated to the child matching [selectedChildIndex].
        _selection = selectedChild;
    
        /// Subsequent layout passes are ensured to have a selected child.
        _onLayout = _onUpdateLayout;
    
        /// The size is set to the size of the selected child.
        size = selectedChild.second;
      }
    
      void _onUpdateLayout() {
        /// A dry layout is needed just to get the size of the selected child.
        final newSelection = _findSelectedChildAndDryLayout();
    
        /// After [_onFirstLayout], it is safe to assume that [_selection] is not
        /// null and that the child that has gone through the wet layout process.
        final child = _selection!.first!;
    
        /// If the selection is the same, perform a layout pass.
        if (child == newSelection.first) {
          _performLayout(child);
        } else {
          /// The selected child index has changed.
    
          /// The size animation will begin from the current child's size.
          _sizeTween.begin = child.size;
    
          /// The size animation will end at the new child's size.
          _sizeTween.end = newSelection.second;
    
          assert(newSelection.first != null);
    
          _performLayout(newSelection.first!);
    
          /// Update the old and new children selection state.
          _oldSelection = _selection;
          _selection = newSelection;
        }
      }
    }
    

    A convenience wrapper that makes using the IntrinsicSizeSwitcher widget simple:

    /// Convenience wrapper around an [IntrinsicSizeSwitcher].
    class AnimatedSizeSwitcher extends StatefulWidget {
      const AnimatedSizeSwitcher({
        Key? key,
        this.duration = const Duration(milliseconds: 300),
        this.curve = Curves.linear,
        this.initialChildIndex = 0,
        required this.children,
      }) : super(key: key);
    
      final Duration duration;
      final Curve curve;
      final int initialChildIndex;
      final List<Widget> children;
    
      @override
      State<AnimatedSizeSwitcher> createState() => AnimatedSizeSwitcherState();
    }
    
    class AnimatedSizeSwitcherState extends State<AnimatedSizeSwitcher>
        with SingleTickerProviderStateMixin {
      late final AnimationController _animationController;
      late int _index;
    
      @override
      void initState() {
        super.initState();
    
        _animationController =
            AnimationController(vsync: this, duration: widget.duration);
    
        _index = widget.initialChildIndex;
    
        /// The [_animationController] is started at 1.0 so that the first child is
        /// visible, because [RenderIntrinsicSizeSwitcher.paint] uses
        /// [_animationController.value] as an opacity factor.
        SchedulerBinding.instance.addPostFrameCallback(
          (timeStamp) => _animationController.forward(from: 1.0),
        );
      }
    
      @override
      void dispose() {
        _animationController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return IntrinsicSizeSwitcher(
          animationController: _animationController,
          curve: widget.curve,
          selectedIndex: _index,
          children: widget.children,
        );
      }
    
      /// Rebuilds the widget by setting the selected index to whatever is next,
      /// wrapping back to 0 when the end is reached.
      void nextChild() {
        setState(() => _index = (_index + 1) % widget.children.length);
      }
    }
    

    Utility classes:

    The Pair<T, E> holds two values.

    class Pair<T, E> {
      final T first;
      final E second;
    
      const Pair({required this.first, required this.second});
    
      @override
      String toString() => "first = $first, second = $second";
    }
    

    The SizeComputer<T> class is used to abstract a call to RenderBox.getDryLayout or RenderBox.layout.

    abstract class SizeComputer<T> {
      const SizeComputer();
    
      Size call(T item, BoxConstraints constraints);
    }
    
    class DryLayoutSizeComputer extends SizeComputer<RenderBox> {
      const DryLayoutSizeComputer();
    
      @override
      Size call(RenderBox item, BoxConstraints constraints) {
        final size = item.getDryLayout(constraints);
        assert(size.isFinite);
        return size;
      }
    }
    
    class WetLayoutSizeComputer extends SizeComputer<RenderBox> {
      const WetLayoutSizeComputer();
    
      @override
      Size call(RenderBox item, BoxConstraints constraints) {
        item.layout(constraints, parentUsesSize: true);
        assert(item.hasSize);
        return item.size;
      }
    }
    
    const dryLayoutSizeComputer = DryLayoutSizeComputer();
    const wetLayoutSizeComputer = WetLayoutSizeComputer();
    

    The test application:

    void main() => runApp(const MaterialApp(home: AnimatedSizeSwitcherApp()));
    
    class AnimatedSizeSwitcherApp extends StatelessWidget {
      const AnimatedSizeSwitcherApp({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final key = GlobalKey<_AnimatedSizeTestState>();
    
        return Scaffold(
          body: GestureDetector(
            onTap: () => key.currentState?.displayNextChild(),
            child: Center(child: AnimatedSizeTest(key: key)),
          ),
        );
      }
    }
    
    class AnimatedSizeTest extends StatefulWidget {
      const AnimatedSizeTest({Key? key}) : super(key: key);
    
      @override
      State<AnimatedSizeTest> createState() => _AnimatedSizeTestState();
    }
    
    class _AnimatedSizeTestState extends State<AnimatedSizeTest> {
      static const _decoration = BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(Radius.circular(6.0)),
      );
    
      late final children = <Widget>[
        Container(
          decoration: _decoration.copyWith(color: Colors.red),
          width: 110,
          height: 100,
        ),
        Container(
          decoration: _decoration.copyWith(color: Colors.green),
          width: 350,
          height: 200,
        ),
        Container(
          decoration: _decoration.copyWith(color: Colors.blue),
          width: 200,
          height: 250,
        ),
        Container(
          decoration: _decoration.copyWith(color: Colors.amber),
          width: 300,
          height: 350,
        ),
      ];
    
      final _switcherKey = GlobalKey<AnimatedSizeSwitcherState>();
    
      @override
      Widget build(BuildContext context) {
        return Container(
          padding: const EdgeInsets.all(4.0),
          decoration: _decoration.copyWith(
            boxShadow: [
              const BoxShadow(
                color: Colors.black38,
                spreadRadius: 1.0,
                blurRadius: 2.0,
              ),
            ],
          ),
          child: AnimatedSizeSwitcher(
            key: _switcherKey,
            curve: Curves.easeOutQuad,
            initialChildIndex: 0,
            children: children,
          ),
        );
      }
    
      void displayNextChild() {
        final switcherState = _switcherKey.currentState;
        if (switcherState == null) return;
        switcherState.nextChild();
      }
    }