10

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.

mwarrior
  • 499
  • 5
  • 17

3 Answers3

25

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"),
            ],
          ),
        ),
      ),
    );
  }
}
kohjakob
  • 668
  • 6
  • 10
  • 1
    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 params – mwarrior Mar 18 '21 at 01:34
  • 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. – enchance Nov 12 '22 at 03:11
  • @enchance 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). – FoxDonut Jan 14 '23 at 04:22
  • 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. – FoxDonut Jan 14 '23 at 04:31
2

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.

mwarrior
  • 499
  • 5
  • 17
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,
    );
  }
}
IcyIcicle
  • 564
  • 3
  • 15
  • If you would like the animation not to play when the widget loads for the first time, add `initialValue: 1` to the AnimationController – IcyIcicle Aug 15 '23 at 21:05