// 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() => _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(MaterialGap(key: _SaltedKey<BuildContext, int>(context, index * 2 - 1))); final ExpansionPanel child = widget.children[index]; final Row header = Row( children: <Widget>[ Expanded( child: AnimatedContainer( duration: widget.animationDuration, curve: Curves.fastOutSlowIn, margin: _isChildExpanded(index) ? kExpandedEdgeInsets : EdgeInsets.zero, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight), child: child.headerBuilder( context, _isChildExpanded(index), ), ), ), ), Container( margin: const EdgeInsetsDirectional.only(end: 8.0), child: ExpandIcon( isExpanded: _isChildExpanded(index), padding: const EdgeInsets.all(16.0), onPressed: (bool isExpanded) => _handlePressed(isExpanded, index), ), ), ], ); items.add( MaterialSlice( key: _SaltedKey<BuildContext, int>(context, index * 2), child: Column( children: <Widget>[ MergeSemantics(child: 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, ); } }