How do you animate to expand a container from 0 height to the height of its contents in Flutter?
Asked Answered
H

3

11

I have a container that starts at zero height and needs to be expanded after a user interaction.

  • I tried using AnimatedContainer / AnimatedSize and changing the child widget's height from 0 to null, but in both cases, Flutter complains that it cant' interpolate from 0 to null.
  • I've also tried using BoxConstraints (with expanded using maxHeight = double.infinity) instead of explicit heights, in which case Flutter complains it can't interpolate from a finite value to an indefinite one.
  • I've also tried setting mainAxisSize to min/max, in which case Flutter complains that vsync is null.

How do I animate expanding a widget such that it dynamically grows big enough to wrap its contents? And if this can't be done dynamically, what's a safe way to size contents such that they make sense across screen sizes? In web dev, I know things like em are sort of relative sizing, but in the context of Flutter, I don't see how to control the size of things reliably.


Update: As suggested by @pskink, wrapping the child in an Align widget and animating Align's heightFactor param accomplishes collapsing. However, I'm still having trouble getting collapse to work when the collapsing child itself has children. For example, Column widgets don't clip at all with ClipRect (see https://github.com/flutter/flutter/issues/29357), and even if I use Wrap instead of Column, that doesn't work if the Wrap's children are Rows. Not sure how to get clipping to work consistently.

Halifax answered 15/3, 2021 at 15:28 Comment(8)
check expansion_tile.dart source file - it uses Align.heightFactor that makes a trickCymbiform
@Cymbiform it sort of works, but it's not clipping even with a ClipRect wrapping it. How do you make it clip?Halifax
@Cymbiform ClipRect doesn't work on a column/wrap/rowHalifax
i have no idea what you mean by thatCymbiform
github.com/flutter/flutter/issues/29357 I mean it doesn't clip columns/wraps/rowsHalifax
@Cymbiform ah it looks like it does work for Wraps. I guess I'll switch to a Wrap :( kind of frustrating that columns don't clip thoughHalifax
updated question to reflect new findings. Still can't get it to work consistently with clippingHalifax
@Cymbiform moved summary of this to an answer below. Thank youHalifax
K
33

Maybe you could also solve this with a SizeTransition?

enter image description here

class VariableSizeContainerExample extends StatefulWidget {
  VariableSizeContainerExample();

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

class _VariableSizeContainerExampleState extends State<VariableSizeContainerExample> with TickerProviderStateMixin {
  AnimationController _controller;
  Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.fastLinearToSlowEaseIn,
    );
  }

  _toggleContainer() {
    print(_animation.status);
    if (_animation.status != AnimationStatus.completed) {
      _controller.forward();
    } else {
      _controller.animateBack(0, duration: Duration(seconds: 1));
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: SafeArea(
          child: Column(
            children: [
              TextButton(
                onPressed: () => _toggleContainer(),
                child: Text("Toggle container visibility"),
              ),
              SizeTransition(
                sizeFactor: _animation,
                axis: Axis.vertical,
                child: Container(
                  child: Text(
                    "This can have variable size",
                    style: TextStyle(fontSize: 40),
                  ),
                ),
              ),
              Text("This is below the above container"),
            ],
          ),
        ),
      ),
    );
  }
}
Kinney answered 17/3, 2021 at 2:22 Comment(4)
This worked great! @pskink's answer works too, but required a bit more wiring. This one does exactly what I want in a straightforward way. I want to also mention that if anyone wants it to collapse from the bottom (as opposed to toward the middle), you can add axisAlignment: -1 to the SizeTransition paramsHalifax
Is there a way so it doesn't go all the way to 0 when it gets smaller? In my case the trigger is the content itself and if they click on it it either grows in size to show the full content or folds itself until it reaches a height of 50. This way the user has space to click on it again to make it expand.Sinistrad
@Sinistrad I just did some (minimal) testing on your behalf. You can't use _controller.animateBack(0.5, duration: Duration(seconds: 1), to accomplish this. You have to use "animateTo" to get to a certain position. Perhaps you could add an extra variable to control having one click make the Widget 50 percent size, and a second click will fully close, where the last click will use "animateBack()". You can also set the size scale manually by setting _controller.value = 0.5, but that would rebuild the widget if you did it in setState, making it "snap" to 50 percent size (looks bad).Marasco
I did end up testing this, and made it work with a simple int counter var. In the first setState -> animateTo(1), second call to setState() -> animateTo(0.5), and last call to setState() -> animateBack(0). I didn't add the counter incrementing in this comment, but I believe in you! ~Very Best.Marasco
H
3

Moving @pskink's comments to an answer for posterity:

The main concept is that the Align widget has a property called heightFactor, which takes a double between 0 and 1 to scale its child's height (there's also a similar widthFactor property for width). By animating this property, we can collapse/expand the child. For example:

ClipRect(
      child: Align(
        alignment: alignment,
        child: Align(
          alignment: innerAlignment,
          widthFactor: constantValue,
          heightFactor: animatedValue.value,
          child: builder(context, animation),
        ),
      )
)

where animatedValue is of type Animation<double>, and ClipReact is used to clip/truncate the child widget. Note that ClipReact needs to be wrapped outside the Align widget; it doesn't work consistently when wrapping Align's child widget.

Edit: it's also necessary for the recipient of the animation to be an AnimatedWidget for things to go smoothly. See selected answer for an approach that handles this for you.

Halifax answered 17/3, 2021 at 1:8 Comment(1)
Using this with TweenAnimationBuilder was exactly what I needed. Thanks!Hildehildebrand
S
0

@kohjakob's answer worked, but it isn't a reusable widget.

So I made AnimatedCollapse. It is based on the style of the other Animated[...] widgets, and it uses the SizeTransition and listens for any changes in the collapsed property to reverse or forward the animation.

class AnimatedCollapse extends StatefulWidget {
  const AnimatedCollapse({
    Key? key,
    this.child,
    required this.collapsed,
    this.axis = Axis.vertical,
    this.axisAlignment = 0.0,
    this.curve = Curves.linear,
    required this.duration,
    this.reverseDuration,
  }) : super(key: key);


  final Widget? child;

  /// Show or hide the child
  final bool collapsed;

  /// See [SizeTransition]
  final Axis axis;

  /// See [SizeTransition]
  final double axisAlignment;
  final Curve curve;
  final Duration duration;
  final Duration? reverseDuration;

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

class _AnimatedCollapseState extends State<AnimatedCollapse> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
      reverseDuration: widget.reverseDuration,
    );

    _animation = CurvedAnimation(
      parent: _controller,
      curve: widget.curve,
    );

    if (!widget.collapsed) {
      _controller.forward();
    }
  }

  @override
  void didUpdateWidget(covariant AnimatedCollapse oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.collapsed != oldWidget.collapsed) {
      if (widget.collapsed) {
        _controller.reverse();
      } else {
        _controller.forward();
      }
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SizeTransition(
      sizeFactor: _animation,
      axis: widget.axis,
      axisAlignment: widget.axisAlignment,
      child: widget.child,
    );
  }
}
Spinal answered 15/8, 2023 at 21:1 Comment(2)
If you would like the animation not to play when the widget loads for the first time, add initialValue: 1 to the AnimationControllerSpinal
In initState, add value: widget.collapsed ? 0.0 : 1.0, to the AnimationController, and remove the call to _controller.forward(). This will make it so the widget is either fully extended or fully collapsed on build. Otherwise a widget that isn't collapsed will do it's whole "reveal" animation on build, instead of only on state change.Gschu

© 2022 - 2024 — McMap. All rights reserved.