import 'package:flutter/material.dart'; const double _kPanelHeaderCollapsedHeight = kMinInteractiveDimension; class _SaltedKey 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 && other.salt == salt && other.value == value; } @override int get hashCode => Object.hash(runtimeType, salt, value); @override String toString() { final String saltString = S == String ? "<'$salt'>" : '<$salt>'; final String valueString = V == String ? "<'$value'>" : '<$value>'; return '[$saltString $valueString]'; } } class AppExpansionPanelList 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 AppExpansionPanelList({ super.key, required this.children, this.expansionCallback, this.animationDuration = kThemeAnimationDuration, this.expandedHeaderPadding = EdgeInsets.zero, this.dividerColor, this.elevation = 2, }) : _allowOnlyOnePanelOpen = false, initialOpenPanelValue = null; /// The children of the expansion panel list. They are laid out in a similar /// fashion to [ListBody]. final List 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 AppExpansionPanelList.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 AppExpansionPanelList, the callback needs to setState when it's notified /// about the closing/opening panel. On the other hand, the callback for /// AppExpansionPanelList.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 [AppExpansionPanelList.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; /// Defines color for the divider when [AppExpansionPanel.isExpanded] is false. /// /// If `dividerColor` is null, then [DividerThemeData.color] is used. If that /// is null, then [ThemeData.dividerColor] is used. final Color? dividerColor; /// Defines elevation for the [AppExpansionPanel] while it's expanded. /// /// By default, the value of elevation is 2. final double elevation; @override State createState() => _AppExpansionPanelListState(); } class _AppExpansionPanelListState extends State { 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(), widget.initialOpenPanelValue); } } } @override void didUpdateWidget(AppExpansionPanelList oldWidget) { super.didUpdateWidget(oldWidget); if (widget._allowOnlyOnePanelOpen) { assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.'); // If the previous widget was non-radio AppExpansionPanelList, initialize the // open panel to widget.initialOpenPanelValue if (!oldWidget._allowOnlyOnePanelOpen) { _currentOpenPanel = searchPanelByValue( widget.children.cast(), widget.initialOpenPanelValue); } } else { _currentOpenPanel = null; } } bool _allIdentifiersUnique() { final Map identifierMap = {}; for (final ExpansionPanelRadio child in widget.children.cast()) { 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) { widget.expansionCallback?.call(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 panels, Object? value) { for (final ExpansionPanelRadio panel in panels) { if (panel.value == value) return panel; } return null; } @override Widget build(BuildContext context) { assert( kElevationToShadow.containsKey(widget.elevation), 'Invalid value for elevation. See the kElevationToShadow constant for' ' possible elevation values.', ); final List items = []; for (int index = 0; index < widget.children.length; index += 1) { //todo: Uncomment to add gap between selected panels /*if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1)) items.add(MaterialGap(key: _SaltedKey(context, index * 2 - 1)));*/ final AppExpansionPanel child = widget.children[index]; final Widget headerWidget = child.headerBuilder( context, _isChildExpanded(index), ); Widget? expandIconContainer = ExpandIcon( isExpanded: _isChildExpanded(index), 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, ); } final iconContainer = child.iconBuilder; if (iconContainer != null) { expandIconContainer = iconContainer( expandIconContainer, _isChildExpanded(index), ); } Widget header = Row( children: [ 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, ), ), ), if (expandIconContainer != null) expandIconContainer, ], ); if (child.canTapOnHeader) { header = MergeSemantics( child: InkWell( onTap: () => _handlePressed(_isChildExpanded(index), index), child: header, ), ); } items.add( MaterialSlice( key: _SaltedKey(context, index * 2), color: child.backgroundColor, child: Column( children: [ 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(context, index * 2 + 1))); } } return MergeableMaterial( hasDividers: true, dividerColor: widget.dividerColor, elevation: widget.elevation, children: items, ); } } typedef ExpansionPanelIconBuilder = Widget? Function( Widget child, bool isExpanded, ); class AppExpansionPanel { /// 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. AppExpansionPanel({ required this.headerBuilder, required this.body, this.iconBuilder, this.isExpanded = false, this.canTapOnHeader = false, this.backgroundColor, }); /// The widget builder that builds the expansion panels' header. final ExpansionPanelHeaderBuilder headerBuilder; /// The widget builder that builds the expansion panels' icon. /// /// If not pass any function, then default icon will be displayed. /// /// If builder function return null, then icon will not displayed. final ExpansionPanelIconBuilder? iconBuilder; /// 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; /// Defines the background color of the panel. /// /// Defaults to [ThemeData.cardColor]. final Color? backgroundColor; }