Flutter - Collapsing ExpansionTile after choosing an item

Here is a solution. We just add a expand, collapse and toggle functionality to ExpansionTile.

import 'package:flutter/material.dart';
import 'package:meta/meta.dart';


void main() {
    runApp(new ExpansionTileSample());
}

class ExpansionTileSample extends StatefulWidget {
    @override
    ExpansionTileSampleState createState() => new ExpansionTileSampleState();
}

class ExpansionTileSampleState extends State<ExpansionTileSample> {

    final GlobalKey<AppExpansionTileState> expansionTile = new GlobalKey();
    String foos = 'One';

    @override
    Widget build(BuildContext context) {
        return new MaterialApp(
            home: new Scaffold(
                appBar: new AppBar(
                    title: const Text('ExpansionTile'),
                ),
                body: new AppExpansionTile(
                    key: expansionTile,
                    title: new Text(this.foos),
                    backgroundColor: Theme
                        .of(context)
                        .accentColor
                        .withOpacity(0.025),
                    children: <Widget>[
                        new ListTile(
                            title: const Text('One'),
                            onTap: () {
                                setState(() {
                                    this.foos = 'One';
                                    expansionTile.currentState.collapse();
                                });
                            },
                        ),
                        new ListTile(
                            title: const Text('Two'),
                            onTap: () {
                                setState(() {
                                    this.foos = 'Two';
                                    expansionTile.currentState.collapse();
                                });
                            },
                        ),
                        new ListTile(
                            title: const Text('Three'),
                            onTap: () {
                                setState(() {
                                    this.foos = 'Three';
                                    expansionTile.currentState.collapse();
                                });
                            },
                        ),
                    ]
                ),
            ),
        );
    }
}

// --- Copied and slightly modified version of the ExpansionTile.

const Duration _kExpand = const Duration(milliseconds: 200);

class AppExpansionTile extends StatefulWidget {
    const AppExpansionTile({
        Key key,
        this.leading,
        @required this.title,
        this.backgroundColor,
        this.onExpansionChanged,
        this.children: const <Widget>[],
        this.trailing,
        this.initiallyExpanded: false,
    })
        : assert(initiallyExpanded != null),
            super(key: key);

    final Widget leading;
    final Widget title;
    final ValueChanged<bool> onExpansionChanged;
    final List<Widget> children;
    final Color backgroundColor;
    final Widget trailing;
    final bool initiallyExpanded;

    @override
    AppExpansionTileState createState() => new AppExpansionTileState();
}

class AppExpansionTileState extends State<AppExpansionTile> with SingleTickerProviderStateMixin {
    AnimationController _controller;
    CurvedAnimation _easeOutAnimation;
    CurvedAnimation _easeInAnimation;
    ColorTween _borderColor;
    ColorTween _headerColor;
    ColorTween _iconColor;
    ColorTween _backgroundColor;
    Animation<double> _iconTurns;

    bool _isExpanded = false;

    @override
    void initState() {
        super.initState();
        _controller = new AnimationController(duration: _kExpand, vsync: this);
        _easeOutAnimation = new CurvedAnimation(parent: _controller, curve: Curves.easeOut);
        _easeInAnimation = new CurvedAnimation(parent: _controller, curve: Curves.easeIn);
        _borderColor = new ColorTween();
        _headerColor = new ColorTween();
        _iconColor = new ColorTween();
        _iconTurns = new Tween<double>(begin: 0.0, end: 0.5).animate(_easeInAnimation);
        _backgroundColor = new ColorTween();

        _isExpanded = PageStorage.of(context)?.readState(context) ?? widget.initiallyExpanded;
        if (_isExpanded)
            _controller.value = 1.0;
    }

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

    void expand() {
        _setExpanded(true);
    }

    void collapse() {
        _setExpanded(false);
    }

    void toggle() {
        _setExpanded(!_isExpanded);
    }

    void _setExpanded(bool isExpanded) {
        if (_isExpanded != isExpanded) {
            setState(() {
                _isExpanded = isExpanded;
                if (_isExpanded)
                    _controller.forward();
                else
                    _controller.reverse().then<void>((Null value) {
                        setState(() {
                            // Rebuild without widget.children.
                        });
                    });
                PageStorage.of(context)?.writeState(context, _isExpanded);
            });
            if (widget.onExpansionChanged != null) {
                widget.onExpansionChanged(_isExpanded);
            }
        }
    }

    Widget _buildChildren(BuildContext context, Widget child) {
        final Color borderSideColor = _borderColor.evaluate(_easeOutAnimation) ?? Colors.transparent;
        final Color titleColor = _headerColor.evaluate(_easeInAnimation);

        return new Container(
            decoration: new BoxDecoration(
                color: _backgroundColor.evaluate(_easeOutAnimation) ?? Colors.transparent,
                border: new Border(
                    top: new BorderSide(color: borderSideColor),
                    bottom: new BorderSide(color: borderSideColor),
                )
            ),
            child: new Column(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                    IconTheme.merge(
                        data: new IconThemeData(color: _iconColor.evaluate(_easeInAnimation)),
                        child: new ListTile(
                            onTap: toggle,
                            leading: widget.leading,
                            title: new DefaultTextStyle(
                                style: Theme
                                    .of(context)
                                    .textTheme
                                    .subhead
                                    .copyWith(color: titleColor),
                                child: widget.title,
                            ),
                            trailing: widget.trailing ?? new RotationTransition(
                                turns: _iconTurns,
                                child: const Icon(Icons.expand_more),
                            ),
                        ),
                    ),
                    new ClipRect(
                        child: new Align(
                            heightFactor: _easeInAnimation.value,
                            child: child,
                        ),
                    ),
                ],
            ),
        );
    }

    @override
    Widget build(BuildContext context) {
        final ThemeData theme = Theme.of(context);
        _borderColor.end = theme.dividerColor;
        _headerColor
            ..begin = theme.textTheme.subhead.color
            ..end = theme.accentColor;
        _iconColor
            ..begin = theme.unselectedWidgetColor
            ..end = theme.accentColor;
        _backgroundColor.end = widget.backgroundColor;

        final bool closed = !_isExpanded && _controller.isDismissed;
        return new AnimatedBuilder(
            animation: _controller.view,
            builder: _buildChildren,
            child: closed ? null : new Column(children: widget.children),
        );
    }
}

Here is a workaround. Just add a global key (or a value key that changes after selecting an item) and it will force ExpansionTile to rebuild. The downside is losing animation for collapsing.

ExpansionTile(
  key: GlobalKey(),
  title: Text(title),
  children: listTiles,
  ...
)

solution below would work, but it is quite hacky and might not be the best one:



    import 'package:flutter/material.dart';
    import 'dart:math';

    void main() {
      runApp(new ExpansionTileSample());
    }

    class ExpansionTileSample extends StatefulWidget {
      @override
      ExpansionTileSampleState createState() => new ExpansionTileSampleState();
    }

    class ExpansionTileSampleState extends State {
      String foos = 'One';
      int _key;

      _collapse() {
        int newKey;
        do {
          _key = new Random().nextInt(10000);
        } while(newKey == _key);
      }

      @override
      void initState() {
        super.initState();
        _collapse();
      }

      @override
      Widget build(BuildContext context) {
        return new MaterialApp(
          home: new Scaffold(
            appBar: new AppBar(
              title: const Text('ExpansionTile'),
            ),
            body: new ExpansionTile(
                key: new Key(_key.toString()),
                initiallyExpanded: false,
                title: new Text(this.foos),
                backgroundColor: Theme
                    .of(context)
                    .accentColor
                    .withOpacity(0.025),
                children: [
                  new ListTile(
                    title: const Text('One'),
                    onTap: () {
                      setState(() {
                        this.foos = 'One';
                        _collapse();
                      });
                    },
                  ),
                  new ListTile(
                    title: const Text('Two'),
                    onTap: () {
                      setState(() {
                        this.foos = 'Two';
                        _collapse();
                      });
                    },
                  ),
                  new ListTile(
                    title: const Text('Three'),
                    onTap: () {
                      setState(() {
                        this.foos = 'Three';
                        _collapse();
                      });
                    },
                  ),
                ]
            ),
          ),
        );
      }
    }

I found that ExpansionTile has initiallyExpanded property, which is the only way to make it collapsed. As property works only initially you want to make ExpansionTile to be recreated everytime build is called. To force it you just assign different key everytime you build it. This might not be best solution performance wise, but ExpansionTile is quite simple, so this should not be a problem.


None of the provided solutions pleased me.

I ended up creating a custom ExpandableListTile. As you can see below, its code is very brief and easy to customize.

I also had to create two supporting classes (that only handle the required animations) to build my widget:

  • ExpandableSection: a widget that can be easily controlled by one parameter "expanded".
  • RotatableSection: a widget to rotate the "Expand More" icon based on one parameter.

The main class:

class ExpandableListTile extends StatelessWidget {
  const ExpandableListTile({Key key, this.title, this.expanded, this.onExpandPressed, this.child}) : super(key: key);

  final Widget title;
  final bool expanded;
  final Widget child;
  final Function onExpandPressed;

  @override
  Widget build(BuildContext context) {
    return Column(children: <Widget>[
      ListTile(
        title: title,
        onTap: onExpandPressed,
        trailing: IconButton(
          onPressed: onExpandPressed,
          // icon: Icon(Icons.expand_more),
          icon: RotatableSection(
             rotated: expanded,
             child: SizedBox(height: 30, width: 30, child: Icon(Icons.expand_more),) 
          ),
        ),
      ),
      ExpandableSection(child: child, expand: expanded,)
    ]);
  }
}

Usage (simplified):

//...
return ExpandableListTile(
  onExpandPressed: (){ setState((){ _expandedItem = 0;}) },
  title: Text('Item'),
  expanded: _expandedItem==0,
  child: Padding(
    padding: const EdgeInsets.fromLTRB(8,0,0,0),
    child: Container(
      color: Color.fromRGBO(0, 0, 0, .2),
      child: Column(children: <Widget>[
        ListTile(title: Text('Item 1')),
        ListTile(title: Text('Item 2')),
        ListTile(title: Text('Item 3')),
        ListTile(title: Text('Item 4'))
      ],),
    ),
  ),
),
//...

The ExpandableSection class:

class ExpandableSection extends StatefulWidget {

  final Widget child;
  final bool expand;
  ExpandableSection({this.expand = false, this.child});

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

class _ExpandableSectionState extends State<ExpandableSection> with SingleTickerProviderStateMixin {
  AnimationController animationController;
  Animation<double> sizeAnimation; 
  Animation<double> opacityAnimation; 

  @override
  void initState() {
    super.initState();
    prepareAnimations();
    _runExpandCheck();
  }

  ///Setting up the animation
  void prepareAnimations() {
    animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300),);
    sizeAnimation = CurvedAnimation(parent: animationController, curve: Curves.fastOutSlowIn,);
    opacityAnimation = CurvedAnimation(parent: animationController, curve: Curves.slowMiddle,);
  }

  void _runExpandCheck() {
    if(widget.expand) { animationController.forward(); }
    else { animationController.reverse(); }
  }

  @override
  void didUpdateWidget(ExpandableSection oldWidget) {
    super.didUpdateWidget(oldWidget);
    _runExpandCheck();
  }

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

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: opacityAnimation,
      child: SizeTransition(
        axisAlignment: 1.0,
        sizeFactor: sizeAnimation,
        child: widget.child
      )
    );
  }
}

The RotatableSection class:

class RotatableSection extends StatefulWidget {

  final Widget child;
  final bool rotated;
  final double initialSpin;
  final double endingSpin;
  RotatableSection({this.rotated = false, this.child, this.initialSpin=0, this.endingSpin=0.5});

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

class _RotatableSectionState extends State<RotatableSection> with SingleTickerProviderStateMixin {
  AnimationController animationController;
  Animation<double> animation; 

  @override
  void initState() {
    super.initState();
    prepareAnimations();
    _runCheck();
  }

  final double _oneSpin = 6.283184;

  ///Setting up the animation
  void prepareAnimations() {
    animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300), 
      lowerBound: _oneSpin * widget.initialSpin, upperBound: _oneSpin * widget.endingSpin, );
    animation = CurvedAnimation( parent: animationController, curve: Curves.linear, );
  }

  void _runCheck() {
    if(widget.rotated) { animationController.forward(); }
    else { animationController.reverse(); }
  }

  @override
  void didUpdateWidget(RotatableSection oldWidget) {
    super.didUpdateWidget(oldWidget);
    _runCheck();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: animationController,
        child: widget.child,
        builder: (BuildContext context, Widget _widget) {
          return new Transform.rotate(
            angle: animationController.value,
            child: _widget,
          );
      },
    );
  }
}