Skip to content

Instantly share code, notes, and snippets.

@iska9der
Forked from funyin/app_expansion_panel.dart
Last active June 18, 2024 08:45
Show Gist options
  • Save iska9der/9a78b7bb7bc0d063d5f3f56e843057ae to your computer and use it in GitHub Desktop.
Save iska9der/9a78b7bb7bc0d063d5f3f56e843057ae to your computer and use it in GitHub Desktop.
Flutter ExpansionPanelList and ExpansionPanel modification: added iconBuilder to ExpansionPanel to remove or customize the icon
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;
}
@iska9der
Copy link
Author

Default

For default behaviour, dont provide iconBuilder

Icon customization

Provide iconBuilder to ExpansionPanel:

              iconBuilder: (child, isExpanded) {
                return ClipRRect(
                  clipBehavior: Clip.hardEdge,
                  borderRadius: const BorderRadius.circular(12),
                  child: DecoratedBox(
                    decoration: BoxDecoration(
                      color: isExpanded
                          ? Colors.yellowAccent.withOpacity(.12)
                          : Theme.of(context).colorScheme.surface,
                    ),
                    child: child,
                  ),
                );
              }

Remove icon

Just return null from iconBuilder:

              iconBuilder: (child, isExpanded) {
                return null;
              }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment