From 0fe6d6c8e28a5f9289ae54acfffab9aad49e2722 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Tue, 22 Aug 2023 16:51:43 +0800 Subject: [PATCH] =?UTF-8?q?mod:=20expansionPanelList=20=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/app_expansion_panel_list.dart | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 lib/common/widgets/app_expansion_panel_list.dart diff --git a/lib/common/widgets/app_expansion_panel_list.dart b/lib/common/widgets/app_expansion_panel_list.dart new file mode 100644 index 00000000..4698310a --- /dev/null +++ b/lib/common/widgets/app_expansion_panel_list.dart @@ -0,0 +1,351 @@ +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; +}