rafaelcb21
rafaelcb21

Reputation: 13304

Flutter - Collapsing ExpansionTile after choosing an item

I'm trying to get ExpansionTile to collapse after I choose an item, but it does not close the list that was opened.

I tried to use the onExpansionChanged property but I did not succeed

How could you solve this problem?

Insert a gif demonstrating that ExpansionTile does not collapse after choosing an item, and below is also the code used.

enter image description here

import 'package:flutter/material.dart';

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

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

class ExpansionTileSampleState extends State<ExpansionTileSample> {
  String foos = 'One';

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new Scaffold(
        appBar: new AppBar(
          title: const Text('ExpansionTile'),
        ),
        body: new 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';
                });
              },              
            ),
            new ListTile(
              title: const Text('Two'),
              onTap: () {
                setState(() {
                  this.foos = 'Two';
                });
              },              
            ),
            new ListTile(
              title: const Text('Three'),
              onTap: () {
                setState(() {
                  this.foos = 'Three';
                });
              },              
            ),
          ]
        ),
      ),
    );
  }
}

Upvotes: 37

Views: 85971

Answers (14)

Osama Remlawi
Osama Remlawi

Reputation: 2990

The only solution worked here is defining 3 lists (consider that I have 3 Tiles:

list #1:

final List<String> _tilesList = ['Tile1','Title2', 'Tile3'];

list #2

final List<bool> _isExpanded = [false, false, false];

list #3

List<GlobalKey> _tilesKeysList = [GlobalKey(), GlobalKey(), GlobalKey()]

Then this is the code:

Widget _renderTiles() {
  return ListView.builder(
    padding: EdgeInsets.zero,
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    itemCount: _tilesList.length,
    itemBuilder: (
      BuildContext context,
      int i,
    ) {
      return ExpansionTile(
        key: _tilesKeysList[i],
        backgroundColor: AveyColors.white,
        initiallyExpanded: _isExpanded[2],
        onExpansionChanged: (
          bool isExpanded,
        ) {
          setState(
            () => _isExpanded[i] = isExpanded,
          );
        },
        title: Text(
          _tilesList[i],
        ),
        children: [
          // ... Tile content
        ],
      );
    },
  );
}

Finally, Place this code wherever you want to perform the collapse/expand behavior:

setState(() {
  _isExpanded[2] = true; // Expand only Tile #3 or 'false' to collapse
  _tilesKeysList[2] = GlobalKey(); // Force ExpansionTile to rebuild
});

Upvotes: 0

Adalberto Pompolo
Adalberto Pompolo

Reputation: 111

Just for future reference, maybe you don't need to use keys nor creating a new ExpandedTile! All you need to do is to use ExpansionTileController. Here's an example:

import 'package:flutter/material.dart';

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

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

class ExpansionTileSampleState extends State<ExpansionTileSample> {
  String foos = 'One';
  final _expansionTileController = ExpansionTileController();

  @override
  void dispose() {
    _expansionTileController.dispose(); // Remember to dispose your controllers!
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('ExpansionTile'),
        ),
        body: ExpansionTile(
          title: Text(this.foos),
          backgroundColor: Theme.of(context).accentColor.withOpacity(0.025),
          children: <Widget>[
            ListTile(
              title: const Text('One'),
              onTap: () {
                setState(() {
                  this.foos = 'One';
                });
                _expansionTileController.collapse();
              },              
            ),
            // TODO: Other ListTiles...
          ]
        ),
      ),
    );
  }
}

Upvotes: 0

Talis
Talis

Reputation: 11

After spending more time than i wish on this, i finally found a native solution and since it has been 4 years i'll show my own example.

class ExpansionTileEvent extends StatefulWidget {
  const ExpansionTileEvent({super.key});

  @override
  State<ExpansionTileEvent> createState() => _ExpansionTileEventState();
}

class _ExpansionTileEventState extends State<ExpansionTileEvent> {
  List<String> list = <String>[
    "Item 1",
    "Item 2.",
    "Item 3",
  ];

  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    return ExpansionTile(
      title: const Text("Select an item"),
      subtitle: Text(appState.selectedEvent),
      children: list.map((String value) {
        return Builder(builder: (context) {
          return ListTile(
            title: Text(value),
            onTap: () {
              // This is called when the user selects an item.
              setState(() {
                appState.selectedEvent = value;
                ExpansionTileController.of(context).collapse();
              });
            },
          );
        });
      }).toList(),
    );
  }
}`

Wrap the ListTiles with a builder and the ExpansionTileController sets the .collapse from context.

Upvotes: 1

Wodota ML
Wodota ML

Reputation: 175

I think it is impossible with expansion tile but, there's a package named accordion and has much more comfortabilities. Link:https://pub.dev/packages/accordion

Upvotes: 1

satish
satish

Reputation: 1374

I have modified the custom code, And its works fine for me.

Here is the solution.

 // Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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

const Duration _kExpand = Duration(milliseconds: 200);

/// A single-line [ListTile] with a trailing button that expands or collapses
/// the tile to reveal or hide the [children].
///
/// This widget is typically used with [ListView] to create an
/// "expand / collapse" list entry. When used with scrolling widgets like
/// [ListView], a unique [PageStorageKey] must be specified to enable the
/// [AppExpansionTile] to save and restore its expanded state when it is scrolled
/// in and out of view.
///
/// This class overrides the [ListTileTheme.iconColor] and [ListTileTheme.textColor]
/// theme properties for its [ListTile]. These colors animate between values when
/// the tile is expanded and collapsed: between [iconColor], [collapsedIconColor] and
/// between [textColor] and [collapsedTextColor].
///
/// See also:
///
///  * [ListTile], useful for creating expansion tile [children] when the
///    expansion tile represents a sublist.
///  * The "Expand and collapse" section of
///    <https://material.io/components/lists#types>
class AppExpansionTile extends StatefulWidget {
  /// Creates a single-line [ListTile] with a trailing button that expands or collapses
  /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
  /// be non-null.
  const AppExpansionTile({
    GlobalKey<AppExpansionTileState>? key,
    this.leading,
    required this.title,
    this.subtitle,
    this.onExpansionChanged,
    this.children = const <Widget>[],
    this.trailing,
    this.initiallyExpanded = false,
    this.maintainState = false,
    this.tilePadding,
    this.expandedCrossAxisAlignment,
    this.expandedAlignment,
    this.childrenPadding,
    this.backgroundColor,
    this.collapsedBackgroundColor,
    this.textColor,
    this.collapsedTextColor,
    this.iconColor,
    this.collapsedIconColor,
  })  : assert(initiallyExpanded != null),
        assert(maintainState != null),
        assert(
          expandedCrossAxisAlignment != CrossAxisAlignment.baseline,
          'CrossAxisAlignment.baseline is not supported since the expanded children '
          'are aligned in a column, not a row. Try to use another constant.',
        ),
        super(key: key);

  /// A widget to display before the title.
  ///
  /// Typically a [CircleAvatar] widget.
  final Widget? leading;

  /// The primary content of the list item.
  ///
  /// Typically a [Text] widget.
  final Widget title;

  /// Additional content displayed below the title.
  ///
  /// Typically a [Text] widget.
  final Widget? subtitle;

  /// Called when the tile expands or collapses.
  ///
  /// When the tile starts expanding, this function is called with the value
  /// true. When the tile starts collapsing, this function is called with
  /// the value false.
  final ValueChanged<bool>? onExpansionChanged;

  /// The widgets that are displayed when the tile expands.
  ///
  /// Typically [ListTile] widgets.
  final List<Widget> children;

  /// The color to display behind the sublist when expanded.
  final Color? backgroundColor;

  /// When not null, defines the background color of tile when the sublist is collapsed.
  final Color? collapsedBackgroundColor;

  /// A widget to display instead of a rotating arrow icon.
  final Widget? trailing;

  /// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
  final bool initiallyExpanded;

  /// Specifies whether the state of the children is maintained when the tile expands and collapses.
  ///
  /// When true, the children are kept in the tree while the tile is collapsed.
  /// When false (default), the children are removed from the tree when the tile is
  /// collapsed and recreated upon expansion.
  final bool maintainState;

  /// Specifies padding for the [ListTile].
  ///
  /// Analogous to [ListTile.contentPadding], this property defines the insets for
  /// the [leading], [title], [subtitle] and [trailing] widgets. It does not inset
  /// the expanded [children] widgets.
  ///
  /// When the value is null, the tile's padding is `EdgeInsets.symmetric(horizontal: 16.0)`.
  final EdgeInsetsGeometry? tilePadding;

  /// Specifies the alignment of [children], which are arranged in a column when
  /// the tile is expanded.
  ///
  /// The internals of the expanded tile make use of a [Column] widget for
  /// [children], and [Align] widget to align the column. The `expandedAlignment`
  /// parameter is passed directly into the [Align].
  ///
  /// Modifying this property controls the alignment of the column within the
  /// expanded tile, not the alignment of [children] widgets within the column.
  /// To align each child within [children], see [expandedCrossAxisAlignment].
  ///
  /// The width of the column is the width of the widest child widget in [children].
  ///
  /// When the value is null, the value of `expandedAlignment` is [Alignment.center].
  final Alignment? expandedAlignment;

  /// Specifies the alignment of each child within [children] when the tile is expanded.
  ///
  /// The internals of the expanded tile make use of a [Column] widget for
  /// [children], and the `crossAxisAlignment` parameter is passed directly into the [Column].
  ///
  /// Modifying this property controls the cross axis alignment of each child
  /// within its [Column]. Note that the width of the [Column] that houses
  /// [children] will be the same as the widest child widget in [children]. It is
  /// not necessarily the width of [Column] is equal to the width of expanded tile.
  ///
  /// To align the [Column] along the expanded tile, use the [expandedAlignment] property
  /// instead.
  ///
  /// When the value is null, the value of `expandedCrossAxisAlignment` is [CrossAxisAlignment.center].
  final CrossAxisAlignment? expandedCrossAxisAlignment;

  /// Specifies padding for [children].
  ///
  /// When the value is null, the value of `childrenPadding` is [EdgeInsets.zero].
  final EdgeInsetsGeometry? childrenPadding;

  /// The icon color of tile's [trailing] expansion icon when the
  /// sublist is expanded.
  ///
  /// Used to override to the [ListTileTheme.iconColor].
  final Color? iconColor;

  /// The icon color of tile's [trailing] expansion icon when the
  /// sublist is collapsed.
  ///
  /// Used to override to the [ListTileTheme.iconColor].
  final Color? collapsedIconColor;

  /// The color of the tile's titles when the sublist is expanded.
  ///
  /// Used to override to the [ListTileTheme.textColor].
  final Color? textColor;

  /// The color of the tile's titles when the sublist is collapsed.
  ///
  /// Used to override to the [ListTileTheme.textColor].
  final Color? collapsedTextColor;

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

class AppExpansionTileState extends State<AppExpansionTile>
    with SingleTickerProviderStateMixin {
  static final Animatable<double> _easeOutTween =
      CurveTween(curve: Curves.easeOut);
  static final Animatable<double> _easeInTween =
      CurveTween(curve: Curves.easeIn);
  static final Animatable<double> _halfTween =
      Tween<double>(begin: 0.0, end: 0.5);

  final ColorTween _borderColorTween = ColorTween();
  final ColorTween _headerColorTween = ColorTween();
  final ColorTween _iconColorTween = ColorTween();
  final ColorTween _backgroundColorTween = ColorTween();

  late AnimationController _controller;
  late Animation<double> _iconTurns;
  late Animation<double> _heightFactor;
  late Animation<Color?> _borderColor;
  late Animation<Color?> _headerColor;
  late Animation<Color?> _iconColor;
  late Animation<Color?> _backgroundColor;

  bool _isExpanded = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: _kExpand, vsync: this);
    _heightFactor = _controller.drive(_easeInTween);
    _iconTurns = _controller.drive(_halfTween.chain(_easeInTween));
    _borderColor = _controller.drive(_borderColorTween.chain(_easeOutTween));
    _headerColor = _controller.drive(_headerColorTween.chain(_easeInTween));
    _iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
    _backgroundColor =
        _controller.drive(_backgroundColorTween.chain(_easeOutTween));

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

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

  void expand() {
    _isExpanded = true;
    handleTap();
  }

  void collapse() {
    _isExpanded = false;
    handleTap();
  }

  @override
  void didUpdateWidget(covariant AppExpansionTile oldWidget) {
    if (widget.initiallyExpanded) {
      expand();
    } else {
      collapse();
    }
    super.didUpdateWidget(oldWidget);
  }

  void handleTap() {
    setState(() {
      if (_isExpanded) {
        _controller.forward();
      } else {
        _controller.reverse().then<void>((void value) {
          if (!mounted) return;
          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.value ?? Colors.transparent;

    return Container(
      decoration: BoxDecoration(
        color: _backgroundColor.value ?? Colors.transparent,
        border: Border(
          top: BorderSide(color: borderSideColor),
          bottom: BorderSide(color: borderSideColor),
        ),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          ListTileTheme.merge(
            iconColor: _iconColor.value,
            textColor: _headerColor.value,
            child: ListTile(
              onTap: () {
                if (widget.onExpansionChanged != null) {
                  widget.onExpansionChanged!(_isExpanded);
                }
              },
              contentPadding: widget.tilePadding,
              leading: widget.leading,
              title: widget.title,
              subtitle: widget.subtitle,
              trailing: widget.trailing ??
                  RotationTransition(
                    turns: _iconTurns,
                    child: const Icon(Icons.expand_more),
                  ),
            ),
          ),
          ClipRect(
            child: Align(
              alignment: widget.expandedAlignment ?? Alignment.center,
              heightFactor: _heightFactor.value,
              child: child,
            ),
          ),
        ],
      ),
    );
  }

  @override
  void didChangeDependencies() {
    final ThemeData theme = Theme.of(context);
    final ColorScheme colorScheme = theme.colorScheme;
    _borderColorTween.end = theme.dividerColor;
    _headerColorTween
      ..begin = widget.collapsedTextColor ?? theme.textTheme.subtitle1!.color
      ..end = widget.textColor ?? colorScheme.secondary;
    _iconColorTween
      ..begin = widget.collapsedIconColor ?? theme.unselectedWidgetColor
      ..end = widget.iconColor ?? colorScheme.secondary;
    _backgroundColorTween
      ..begin = widget.collapsedBackgroundColor
      ..end = widget.backgroundColor;
    super.didChangeDependencies();
  }

  @override
  Widget build(BuildContext context) {
    final bool closed = !_isExpanded && _controller.isDismissed;
    final bool shouldRemoveChildren = closed && !widget.maintainState;

    final Widget result = Offstage(
      child: TickerMode(
        child: Padding(
          padding: widget.childrenPadding ?? EdgeInsets.zero,
          child: Column(
            crossAxisAlignment:
                widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
            children: widget.children,
          ),
        ),
        enabled: !closed,
      ),
      offstage: closed,
    );

    return AnimatedBuilder(
      animation: _controller.view,
      builder: _buildChildren,
      child: shouldRemoveChildren ? null : result,
    );
  }
}

Usage

late int _tileIndex=-1;
    return AppExpansionTile(
          title: Text(
           'Tile $index',
          tilePadding: const EdgeInsets.symmetric(horizontal: 24),
          initiallyExpanded: _tileIndex == index,
          onExpansionChanged: (s) {
            if (_tileIndex == index) {
              _tileIndex = -1;
              setState(() {});
            } else {
              setState(() {
                _tileIndex = index!;
              });
            }
          },
    );

Upvotes: 1

Gstuntz
Gstuntz

Reputation: 453

For List of items using @simon solution

List<GlobalKey<AppExpansionTileState> > expansionTile;

instantiate your expansionTile

expansionTile=List<GlobalKey<AppExpansionTileState>>.generate(listItems.length, (index) => GlobalKey());

and use like so inside a ListView.builder()

                               key: expansionTile[index],
                                onExpansionChanged: (value) {
                                  if (value) {
                                    for (var tileKey in expansionTile) {
                                      if (tileKey.currentState !=
                                          expansionTile[index]
                                              .currentState) {
                                        tileKey.currentState.collapse();
                                      } else {
                                        tileKey.currentState.expand();
                                      }
                                    }
                                  }
                                },

Upvotes: 0

Edwin Liu
Edwin Liu

Reputation: 8015

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,
  ...
)

Upvotes: 48

iDecode
iDecode

Reputation: 28866

Use UniqueKey:

ExpansionTile(
  key: UniqueKey(),
  // Other properties
)

Upvotes: 3

Rafsan Uddin Beg Rizan
Rafsan Uddin Beg Rizan

Reputation: 2297

Use this package and follow my code. Hope this will help you :). Easy to use. https://pub.dev/packages/expansion_tile_card/example

final List<GlobalKey<ExpansionTileCardState>> cardKeyList = [];

     ...    ListView.builder(
              itemCount: 10,
              itemBuilder: (BuildContext context, int index) {
                cardKeyList.add(GlobalKey(debugLabel: "index :$index"));
                return ExpansionTileCard(
                  title: Text('title'),
                  key: cardKeyList[index],
                  onExpansionChanged: (value) {
                    if (value) {
                      Future.delayed(const Duration(milliseconds: 500), () {
                        for (var i = 0; i < cardKeyList.length; i++) {
                          if (index != i) {
                            cardKeyList[i].currentState?.collapse();
                          }
                        }
                      });
                    }
                  },
                );
              }),

Upvotes: 3

Juliano
Juliano

Reputation: 2552

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,
          );
      },
    );
  }
}

Upvotes: 8

Hossein
Hossein

Reputation: 847

Create a clone from ExpansionTile class and replace build method code by the following:

@override
Widget build(BuildContext context) {
  final bool closed = !_isExpanded && _controller.isDismissed;
  return AnimatedBuilder(
    animation: _controller.view,
    builder: _buildChildren,
    child: closed ? null : GestureDetector(
      child: Column(children: widget.children),
      onTap: _handleTap,
    ),
  );
}

and then ExpansionTile will collapse after click on each item.

Note: if one of children has onTap call back, this solution doesn't work. in this case you must provide onChildTap handler to pass index of tapped child in use case.(contact me for complete code)

Upvotes: 1

Nk54
Nk54

Reputation: 771

I've made a TreeView widget. It uses ExpansionTile to simulate the hierarchy. Each ExpansionTile could host a collection of ExpansionTile which can host ...etc.

Everything worked fine until I wanted to add 2 features : expand all / collapse all. What helped me to overcame this problem is the GlobalKey.

My TreeView widget, is hosted in a page and is used with a global key. I expose a VoidCallback. The implementation sets a new key in the setState method.

// TreeView host page
GlobalKey<TreeViewState> _key = GlobalKey();
void redrawWidgetCallback() {
    setState(() {
      // Triggers a rebuild of the whole TreeView.
      _key = GlobalKey();
    });
}
[...]
// In the Scaffold body :
TreeView(
    key: _key,
    treeViewItems: widget.treeViewItems,
    redrawWidgetCallback: redrawWidgetCallback,
  )

Then in my collapse/expand method in the widget, at the end, I call widget.redrawWidgetCallback. No need to deal with a key for each level of the treeView : the root element widget is enough.

It may have perf issues / not the right way to go. But since my TreeView won't be used with more than 50 nodes, it's ok for me until I found a better solution which doesn't involve to create an ExpandableTile because I believe this behavior will be available oneday on the ExpansionTile itself.

PS : notice that this workaround doesn't run the expand animation.

Upvotes: 1

Simon
Simon

Reputation: 11190

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),
        );
    }
}

Upvotes: 44

Alex Radzishevsky
Alex Radzishevsky

Reputation: 3768

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.

Upvotes: 10

Related Questions