| // Copyright 2016 The Chromium 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 'expand_icon.dart'; |
| import 'mergeable_material.dart'; |
| import 'theme.dart'; |
| |
| const double _kPanelHeaderCollapsedHeight = 48.0; |
| const double _kPanelHeaderExpandedHeight = 64.0; |
| |
| class _SaltedKey<S, V> extends LocalKey { |
| const _SaltedKey(this.salt, this.value); |
| |
| final S salt; |
| final V value; |
| |
| @override |
| bool operator ==(dynamic other) { |
| if (other.runtimeType != runtimeType) |
| return false; |
| final _SaltedKey<S, V> typedOther = other; |
| return salt == typedOther.salt |
| && value == typedOther.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 void ExpansionPanelCallback(int panelIndex, bool isExpanded); |
| |
| /// Signature for the callback that's called when the header of the |
| /// [ExpansionPanel] needs to rebuild. |
| typedef Widget ExpansionPanelHeaderBuilder(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 also: |
| /// |
| /// * [ExpansionPanelList] |
| /// * <https://material.google.com/components/expansion-panels.html> |
| class ExpansionPanel { |
| /// Creates an expansion panel to be used as a child for [ExpansionPanelList]. |
| /// |
| /// The [headerBuilder], [body], and [isExpanded] arguments must not be null. |
| ExpansionPanel({ |
| @required this.headerBuilder, |
| @required this.body, |
| this.isExpanded = false |
| }) : assert(headerBuilder != null), |
| assert(body != null), |
| assert(isExpanded != 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; |
| |
| } |
| |
| /// An expansion panel that allows for radio-like functionality. |
| /// |
| /// A unique identifier [value] must be assigned to each panel. |
| 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, |
| }) : assert(value != null), |
| super(body: body, headerBuilder: headerBuilder); |
| |
| /// 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. |
| /// |
| /// See also: |
| /// |
| /// * [ExpansionPanel] |
| /// * <https://material.google.com/components/expansion-panels.html> |
| 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, |
| }) : assert(children != null), |
| assert(animationDuration != null), |
| _allowOnlyOnePanelOpen = false, |
| this.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]. |
| const ExpansionPanelList.radio({ |
| Key key, |
| List<ExpansionPanelRadio> children = const <ExpansionPanelRadio>[], |
| this.expansionCallback, |
| this.animationDuration = kThemeAnimationDuration, |
| this.initialOpenPanelValue, |
| }) : children = children, // ignore: prefer_initializing_formals |
| 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 |
| /// to-be-expanded panel in the list and whether the panel is currently |
| /// expanded or not. |
| /// |
| /// 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; |
| |
| @override |
| State<StatefulWidget> createState() => new _ExpansionPanelListState(); |
| } |
| |
| class _ExpansionPanelListState extends State<ExpansionPanelList> { |
| ExpansionPanelRadio _currentOpenPanel; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget._allowOnlyOnePanelOpen) { |
| assert(_allIdentifiersUnique(), 'All object identifiers are not unique!'); |
| for (ExpansionPanelRadio child in widget.children) { |
| if (widget.initialOpenPanelValue != null && |
| child.value == widget.initialOpenPanelValue) |
| _currentOpenPanel = child; |
| } |
| } |
| } |
| |
| @override |
| void didUpdateWidget(ExpansionPanelList oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget._allowOnlyOnePanelOpen) { |
| assert(_allIdentifiersUnique(), 'All object identifiers are not unique!'); |
| for (ExpansionPanelRadio newChild in widget.children) { |
| if (widget.initialOpenPanelValue != null && |
| newChild.value == widget.initialOpenPanelValue) |
| _currentOpenPanel = newChild; |
| } |
| } else if(oldWidget._allowOnlyOnePanelOpen) { |
| _currentOpenPanel = null; |
| } |
| } |
| |
| bool _allIdentifiersUnique() { |
| final Map<Object, bool> identifierMap = <Object, bool>{}; |
| for (ExpansionPanelRadio child in widget.children) { |
| identifierMap[child.value] = true; |
| } |
| return identifierMap.length == widget.children.length; |
| } |
| |
| bool _isChildExpanded(int index) { |
| if (widget._allowOnlyOnePanelOpen) { |
| final ExpansionPanelRadio radioWidget = widget.children[index]; |
| 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]; |
| |
| for (int childIndex = 0; childIndex < widget.children.length; childIndex += 1) { |
| final ExpansionPanelRadio child = widget.children[childIndex]; |
| if (widget.expansionCallback != null && |
| childIndex != index && |
| child.value == _currentOpenPanel?.value) |
| widget.expansionCallback(childIndex, false); |
| } |
| _currentOpenPanel = isExpanded ? null : pressedChild; |
| } |
| setState((){}); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final List<MergeableMaterialItem> items = <MergeableMaterialItem>[]; |
| const EdgeInsets kExpandedEdgeInsets = EdgeInsets.symmetric( |
| vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight |
| ); |
| |
| for (int index = 0; index < widget.children.length; index += 1) { |
| if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1)) |
| items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 - 1))); |
| |
| final ExpansionPanel child = widget.children[index]; |
| final Row header = new Row( |
| children: <Widget>[ |
| new Expanded( |
| child: new AnimatedContainer( |
| duration: widget.animationDuration, |
| curve: Curves.fastOutSlowIn, |
| margin: _isChildExpanded(index) ? kExpandedEdgeInsets : EdgeInsets.zero, |
| child: new ConstrainedBox( |
| constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight), |
| child: child.headerBuilder( |
| context, |
| _isChildExpanded(index), |
| ), |
| ), |
| ), |
| ), |
| new Container( |
| margin: const EdgeInsetsDirectional.only(end: 8.0), |
| child: new ExpandIcon( |
| isExpanded: _isChildExpanded(index), |
| padding: const EdgeInsets.all(16.0), |
| onPressed: (bool isExpanded) => _handlePressed(isExpanded, index), |
| ), |
| ), |
| ], |
| ); |
| |
| items.add( |
| new MaterialSlice( |
| key: new _SaltedKey<BuildContext, int>(context, index * 2), |
| child: new Column( |
| children: <Widget>[ |
| new MergeSemantics(child: header), |
| new AnimatedCrossFade( |
| firstChild: new 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(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 + 1))); |
| } |
| |
| return new MergeableMaterial( |
| hasDividers: true, |
| children: items, |
| ); |
| } |
| } |