mod: expansionPanelList 扩展
This commit is contained in:
351
lib/common/widgets/app_expansion_panel_list.dart
Normal file
351
lib/common/widgets/app_expansion_panel_list.dart
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const double _kPanelHeaderCollapsedHeight = kMinInteractiveDimension;
|
||||||
|
|
||||||
|
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 => 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<AppExpansionPanel> 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<AppExpansionPanelList> createState() => _AppExpansionPanelListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppExpansionPanelListState extends State<AppExpansionPanelList> {
|
||||||
|
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(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<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) {
|
||||||
|
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<ExpansionPanelRadio> 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<MergeableMaterialItem> items = <MergeableMaterialItem>[];
|
||||||
|
|
||||||
|
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<BuildContext, int>(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: <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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (expandIconContainer != null) 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),
|
||||||
|
color: child.backgroundColor,
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user