| // 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/rendering.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'constants.dart'; |
| import 'expand_icon.dart'; |
| import 'ink_well.dart'; |
| import 'material_localizations.dart'; |
| import 'mergeable_material.dart'; |
| import 'theme.dart'; |
| |
| const double _kPanelHeaderCollapsedHeight = kMinInteractiveDimension; |
| const EdgeInsets _kPanelHeaderExpandedDefaultPadding = EdgeInsets.symmetric( |
| vertical: 64.0 - _kPanelHeaderCollapsedHeight |
| ); |
| |
| class _SaltedKey<S, V> extends LocalKey { |
| const _SaltedKey(this.salt, this.value); |
| |
| final S salt; |
| final V value; |
| |
| @override |
| bool operator ==(Object other) { |
| if (other.runtimeType != runtimeType) |
| return false; |
| return other is _SaltedKey<S, V> |
| && other.salt == salt |
| && other.value == value; |
| } |
| |
| @override |
| int get hashCode => hashValues(runtimeType, salt, value); |
| |
| @override |
| String toString() { |
| final String saltString = S == String ? "<'$salt'>" : '<$salt>'; |
| final String valueString = V == String ? "<'$value'>" : '<$value>'; |
| return '[$saltString $valueString]'; |
| } |
| } |
| |
| /// Signature for the callback that's called when an [ExpansionPanel] is |
| /// expanded or collapsed. |
| /// |
| /// The position of the panel within an [ExpansionPanelList] is given by |
| /// [panelIndex]. |
| typedef ExpansionPanelCallback = void Function(int panelIndex, bool isExpanded); |
| |
| /// Signature for the callback that's called when the header of the |
| /// [ExpansionPanel] needs to rebuild. |
| typedef ExpansionPanelHeaderBuilder = Widget Function(BuildContext context, bool isExpanded); |
| |
| /// A material expansion panel. It has a header and a body and can be either |
| /// expanded or collapsed. The body of the panel is only visible when it is |
| /// expanded. |
| /// |
| /// Expansion panels are only intended to be used as children for |
| /// [ExpansionPanelList]. |
| /// |
| /// See [ExpansionPanelList] for a sample implementation. |
| /// |
| /// See also: |
| /// |
| /// * [ExpansionPanelList] |
| /// * <https://material.io/design/components/lists.html#types> |
| class ExpansionPanel { |
| /// Creates an expansion panel to be used as a child for [ExpansionPanelList]. |
| /// See [ExpansionPanelList] for an example on how to use this widget. |
| /// |
| /// The [headerBuilder], [body], and [isExpanded] arguments must not be null. |
| ExpansionPanel({ |
| @required this.headerBuilder, |
| @required this.body, |
| this.isExpanded = false, |
| this.canTapOnHeader = false, |
| }) : assert(headerBuilder != null), |
| assert(body != null), |
| assert(isExpanded != null), |
| assert(canTapOnHeader != null); |
| |
| /// The widget builder that builds the expansion panels' header. |
| final ExpansionPanelHeaderBuilder headerBuilder; |
| |
| /// The body of the expansion panel that's displayed below the header. |
| /// |
| /// This widget is visible only when the panel is expanded. |
| final Widget body; |
| |
| /// Whether the panel is expanded. |
| /// |
| /// Defaults to false. |
| final bool isExpanded; |
| |
| /// Whether tapping on the panel's header will expand/collapse it. |
| /// |
| /// Defaults to false. |
| final bool canTapOnHeader; |
| |
| } |
| |
| /// An expansion panel that allows for radio-like functionality. |
| /// This means that at any given time, at most, one [ExpansionPanelRadio] |
| /// can remain expanded. |
| /// |
| /// A unique identifier [value] must be assigned to each panel. |
| /// This identifier allows the [ExpansionPanelList] to determine |
| /// which [ExpansionPanelRadio] instance should be expanded. |
| /// |
| /// See [ExpansionPanelList.radio] for a sample implementation. |
| class ExpansionPanelRadio extends ExpansionPanel { |
| |
| /// An expansion panel that allows for radio functionality. |
| /// |
| /// A unique [value] must be passed into the constructor. The |
| /// [headerBuilder], [body], [value] must not be null. |
| ExpansionPanelRadio({ |
| @required this.value, |
| @required ExpansionPanelHeaderBuilder headerBuilder, |
| @required Widget body, |
| bool canTapOnHeader = false, |
| }) : assert(value != null), |
| super( |
| body: body, |
| headerBuilder: headerBuilder, |
| canTapOnHeader: canTapOnHeader, |
| ); |
| |
| /// The value that uniquely identifies a radio panel so that the currently |
| /// selected radio panel can be identified. |
| final Object value; |
| } |
| |
| /// A material expansion panel list that lays out its children and animates |
| /// expansions. |
| /// |
| /// Note that [expansionCallback] behaves differently for [ExpansionPanelList] |
| /// and [ExpansionPanelList.radio]. |
| /// |
| /// {@tool dartpad --template=stateful_widget_scaffold} |
| /// |
| /// Here is a simple example of how to implement ExpansionPanelList. |
| /// |
| /// ```dart preamble |
| /// // stores ExpansionPanel state information |
| /// class Item { |
| /// Item({ |
| /// this.expandedValue, |
| /// this.headerValue, |
| /// this.isExpanded = false, |
| /// }); |
| /// |
| /// String expandedValue; |
| /// String headerValue; |
| /// bool isExpanded; |
| /// } |
| /// |
| /// List<Item> generateItems(int numberOfItems) { |
| /// return List.generate(numberOfItems, (int index) { |
| /// return Item( |
| /// headerValue: 'Panel $index', |
| /// expandedValue: 'This is item number $index', |
| /// ); |
| /// }); |
| /// } |
| /// ``` |
| /// |
| /// ```dart |
| /// List<Item> _data = generateItems(8); |
| /// |
| /// @override |
| /// Widget build(BuildContext context) { |
| /// return SingleChildScrollView( |
| /// child: Container( |
| /// child: _buildPanel(), |
| /// ), |
| /// ); |
| /// } |
| /// |
| /// Widget _buildPanel() { |
| /// return ExpansionPanelList( |
| /// expansionCallback: (int index, bool isExpanded) { |
| /// setState(() { |
| /// _data[index].isExpanded = !isExpanded; |
| /// }); |
| /// }, |
| /// children: _data.map<ExpansionPanel>((Item item) { |
| /// return ExpansionPanel( |
| /// headerBuilder: (BuildContext context, bool isExpanded) { |
| /// return ListTile( |
| /// title: Text(item.headerValue), |
| /// ); |
| /// }, |
| /// body: ListTile( |
| /// title: Text(item.expandedValue), |
| /// subtitle: Text('To delete this panel, tap the trash can icon'), |
| /// trailing: Icon(Icons.delete), |
| /// onTap: () { |
| /// setState(() { |
| /// _data.removeWhere((currentItem) => item == currentItem); |
| /// }); |
| /// } |
| /// ), |
| /// isExpanded: item.isExpanded, |
| /// ); |
| /// }).toList(), |
| /// ); |
| /// } |
| /// ``` |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// * [ExpansionPanel] |
| /// * [ExpansionPanelList.radio] |
| /// * <https://material.io/design/components/lists.html#types> |
| class ExpansionPanelList extends StatefulWidget { |
| /// Creates an expansion panel list widget. The [expansionCallback] is |
| /// triggered when an expansion panel expand/collapse button is pushed. |
| /// |
| /// The [children] and [animationDuration] arguments must not be null. |
| const ExpansionPanelList({ |
| Key key, |
| this.children = const <ExpansionPanel>[], |
| this.expansionCallback, |
| this.animationDuration = kThemeAnimationDuration, |
| this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding, |
| }) : assert(children != null), |
| assert(animationDuration != null), |
| _allowOnlyOnePanelOpen = false, |
| initialOpenPanelValue = null, |
| super(key: key); |
| |
| /// Creates a radio expansion panel list widget. |
| /// |
| /// This widget allows for at most one panel in the list to be open. |
| /// The expansion panel callback is triggered when an expansion panel |
| /// expand/collapse button is pushed. The [children] and [animationDuration] |
| /// arguments must not be null. The [children] objects must be instances |
| /// of [ExpansionPanelRadio]. |
| /// |
| /// {@tool dartpad --template=stateful_widget_scaffold} |
| /// |
| /// Here is a simple example of how to implement ExpansionPanelList.radio. |
| /// |
| /// ```dart preamble |
| /// // stores ExpansionPanel state information |
| /// class Item { |
| /// Item({ |
| /// this.id, |
| /// this.expandedValue, |
| /// this.headerValue, |
| /// }); |
| /// |
| /// int id; |
| /// String expandedValue; |
| /// String headerValue; |
| /// } |
| /// |
| /// List<Item> generateItems(int numberOfItems) { |
| /// return List.generate(numberOfItems, (int index) { |
| /// return Item( |
| /// id: index, |
| /// headerValue: 'Panel $index', |
| /// expandedValue: 'This is item number $index', |
| /// ); |
| /// }); |
| /// } |
| /// ``` |
| /// |
| /// ```dart |
| /// List<Item> _data = generateItems(8); |
| /// |
| /// @override |
| /// Widget build(BuildContext context) { |
| /// return SingleChildScrollView( |
| /// child: Container( |
| /// child: _buildPanel(), |
| /// ), |
| /// ); |
| /// } |
| /// |
| /// Widget _buildPanel() { |
| /// return ExpansionPanelList.radio( |
| /// initialOpenPanelValue: 2, |
| /// children: _data.map<ExpansionPanelRadio>((Item item) { |
| /// return ExpansionPanelRadio( |
| /// value: item.id, |
| /// headerBuilder: (BuildContext context, bool isExpanded) { |
| /// return ListTile( |
| /// title: Text(item.headerValue), |
| /// ); |
| /// }, |
| /// body: ListTile( |
| /// title: Text(item.expandedValue), |
| /// subtitle: Text('To delete this panel, tap the trash can icon'), |
| /// trailing: Icon(Icons.delete), |
| /// onTap: () { |
| /// setState(() { |
| /// _data.removeWhere((currentItem) => item == currentItem); |
| /// }); |
| /// } |
| /// ) |
| /// ); |
| /// }).toList(), |
| /// ); |
| /// } |
| /// ``` |
| /// {@end-tool} |
| const ExpansionPanelList.radio({ |
| Key key, |
| this.children = const <ExpansionPanelRadio>[], |
| this.expansionCallback, |
| this.animationDuration = kThemeAnimationDuration, |
| this.initialOpenPanelValue, |
| this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding, |
| }) : assert(children != null), |
| assert(animationDuration != null), |
| _allowOnlyOnePanelOpen = true, |
| super(key: key); |
| |
| /// The children of the expansion panel list. They are laid out in a similar |
| /// fashion to [ListBody]. |
| final List<ExpansionPanel> children; |
| |
| /// The callback that gets called whenever one of the expand/collapse buttons |
| /// is pressed. The arguments passed to the callback are the index of the |
| /// pressed panel and whether the panel is currently expanded or not. |
| /// |
| /// If ExpansionPanelList.radio is used, the callback may be called a |
| /// second time if a different panel was previously open. The arguments |
| /// passed to the second callback are the index of the panel that will close |
| /// and false, marking that it will be closed. |
| /// |
| /// For ExpansionPanelList, the callback needs to setState when it's notified |
| /// about the closing/opening panel. On the other hand, the callback for |
| /// ExpansionPanelList.radio is simply meant to inform the parent widget of |
| /// changes, as the radio panels' open/close states are managed internally. |
| /// |
| /// This callback is useful in order to keep track of the expanded/collapsed |
| /// panels in a parent widget that may need to react to these changes. |
| final ExpansionPanelCallback expansionCallback; |
| |
| /// The duration of the expansion animation. |
| final Duration animationDuration; |
| |
| // Whether multiple panels can be open simultaneously |
| final bool _allowOnlyOnePanelOpen; |
| |
| /// The value of the panel that initially begins open. (This value is |
| /// only used when initializing with the [ExpansionPanelList.radio] |
| /// constructor.) |
| final Object initialOpenPanelValue; |
| |
| /// The padding that surrounds the panel header when expanded. |
| /// |
| /// By default, 16px of space is added to the header vertically (above and below) |
| /// during expansion. |
| final EdgeInsets expandedHeaderPadding; |
| |
| @override |
| State<StatefulWidget> createState() => _ExpansionPanelListState(); |
| } |
| |
| class _ExpansionPanelListState extends State<ExpansionPanelList> { |
| ExpansionPanelRadio _currentOpenPanel; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget._allowOnlyOnePanelOpen) { |
| assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.'); |
| if (widget.initialOpenPanelValue != null) { |
| _currentOpenPanel = |
| searchPanelByValue(widget.children.cast<ExpansionPanelRadio>(), widget.initialOpenPanelValue); |
| } |
| } |
| } |
| |
| @override |
| void didUpdateWidget(ExpansionPanelList oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| |
| if (widget._allowOnlyOnePanelOpen) { |
| assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.'); |
| // If the previous widget was non-radio ExpansionPanelList, initialize the |
| // open panel to widget.initialOpenPanelValue |
| if (!oldWidget._allowOnlyOnePanelOpen) { |
| _currentOpenPanel = |
| searchPanelByValue(widget.children.cast<ExpansionPanelRadio>(), widget.initialOpenPanelValue); |
| } |
| } else { |
| _currentOpenPanel = null; |
| } |
| } |
| |
| bool _allIdentifiersUnique() { |
| final Map<Object, bool> identifierMap = <Object, bool>{}; |
| for (final ExpansionPanelRadio child in widget.children.cast<ExpansionPanelRadio>()) { |
| identifierMap[child.value] = true; |
| } |
| return identifierMap.length == widget.children.length; |
| } |
| |
| bool _isChildExpanded(int index) { |
| if (widget._allowOnlyOnePanelOpen) { |
| final ExpansionPanelRadio radioWidget = widget.children[index] as ExpansionPanelRadio; |
| return _currentOpenPanel?.value == radioWidget.value; |
| } |
| return widget.children[index].isExpanded; |
| } |
| |
| void _handlePressed(bool isExpanded, int index) { |
| if (widget.expansionCallback != null) |
| widget.expansionCallback(index, isExpanded); |
| |
| if (widget._allowOnlyOnePanelOpen) { |
| final ExpansionPanelRadio pressedChild = widget.children[index] as ExpansionPanelRadio; |
| |
| // If another ExpansionPanelRadio was already open, apply its |
| // expansionCallback (if any) to false, because it's closing. |
| for (int childIndex = 0; childIndex < widget.children.length; childIndex += 1) { |
| final ExpansionPanelRadio child = widget.children[childIndex] as ExpansionPanelRadio; |
| if (widget.expansionCallback != null && |
| childIndex != index && |
| child.value == _currentOpenPanel?.value) |
| widget.expansionCallback(childIndex, false); |
| } |
| |
| setState(() { |
| _currentOpenPanel = isExpanded ? null : pressedChild; |
| }); |
| } |
| } |
| |
| ExpansionPanelRadio searchPanelByValue(List<ExpansionPanelRadio> panels, Object value) { |
| for (final ExpansionPanelRadio panel in panels) { |
| if (panel.value == value) |
| return panel; |
| } |
| return null; |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final List<MergeableMaterialItem> items = <MergeableMaterialItem>[]; |
| |
| for (int index = 0; index < widget.children.length; index += 1) { |
| if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1)) |
| items.add(MaterialGap(key: _SaltedKey<BuildContext, int>(context, index * 2 - 1))); |
| |
| final ExpansionPanel child = widget.children[index]; |
| final Widget headerWidget = child.headerBuilder( |
| context, |
| _isChildExpanded(index), |
| ); |
| |
| Widget expandIconContainer = Container( |
| margin: const EdgeInsetsDirectional.only(end: 8.0), |
| child: ExpandIcon( |
| isExpanded: _isChildExpanded(index), |
| padding: const EdgeInsets.all(16.0), |
| onPressed: !child.canTapOnHeader |
| ? (bool isExpanded) => _handlePressed(isExpanded, index) |
| : null, |
| ), |
| ); |
| if (!child.canTapOnHeader) { |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| expandIconContainer = Semantics( |
| label: _isChildExpanded(index)? localizations.expandedIconTapHint : localizations.collapsedIconTapHint, |
| container: true, |
| child: expandIconContainer, |
| ); |
| } |
| Widget header = Row( |
| children: <Widget>[ |
| Expanded( |
| child: AnimatedContainer( |
| duration: widget.animationDuration, |
| curve: Curves.fastOutSlowIn, |
| margin: _isChildExpanded(index) ? widget.expandedHeaderPadding : EdgeInsets.zero, |
| child: ConstrainedBox( |
| constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight), |
| child: headerWidget, |
| ), |
| ), |
| ), |
| expandIconContainer, |
| ], |
| ); |
| if (child.canTapOnHeader) { |
| header = MergeSemantics( |
| child: InkWell( |
| onTap: () => _handlePressed(_isChildExpanded(index), index), |
| child: header, |
| ), |
| ); |
| } |
| items.add( |
| MaterialSlice( |
| key: _SaltedKey<BuildContext, int>(context, index * 2), |
| child: Column( |
| children: <Widget>[ |
| header, |
| AnimatedCrossFade( |
| firstChild: Container(height: 0.0), |
| secondChild: child.body, |
| firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn), |
| secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn), |
| sizeCurve: Curves.fastOutSlowIn, |
| crossFadeState: _isChildExpanded(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst, |
| duration: widget.animationDuration, |
| ), |
| ], |
| ), |
| ), |
| ); |
| |
| if (_isChildExpanded(index) && index != widget.children.length - 1) |
| items.add(MaterialGap(key: _SaltedKey<BuildContext, int>(context, index * 2 + 1))); |
| } |
| |
| return MergeableMaterial( |
| hasDividers: true, |
| children: items, |
| ); |
| } |
| } |