Introduce Expansible, a base widget for ExpansionTile (#164049)

Take 2 

Design doc:
[flutter.dev/go/codeshare-expansion-tile](https://docs.google.com/document/d/1GTyEZjjTpx6fcrzOX-6kQ3phwu5UhUGLOWXJoy0yto0/edit?tab=t.0)

Fixes [Codeshare between ExpansionTile and its Cupertino
variant](https://github.com/flutter/flutter/issues/163552)

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Victor Sanni
2025-03-19 00:56:09 -07:00
committed by GitHub
parent 5323f13842
commit 010d31d109
5 changed files with 827 additions and 328 deletions

View File

@@ -32,162 +32,37 @@ const Duration _kExpand = Duration(milliseconds: 200);
/// tile based on a system event. To do so, create an [ExpansionTile]
/// with an [ExpansionTileController] that's owned by a stateful widget
/// or look up the tile's automatically created [ExpansionTileController]
/// with [ExpansionTileController.of]
/// with [ExpansibleController.of].
///
/// The controller's [expand] and [collapse] methods cause the
/// the [ExpansionTile] to rebuild, so they may not be called from
/// {@tool dartpad}
/// Typical usage of the [ExpansibleController.of] function is to call it from within the
/// `build` method of a descendant of an [ExpansionTile].
///
/// When the [ExpansionTile] is actually created in the same `build`
/// function as the callback that refers to the controller, then the
/// `context` argument to the `build` function can't be used to find
/// the [ExpansionTileController] (since it's "above" the widget
/// being returned in the widget tree). In cases like that you can
/// add a [Builder] widget, which provides a new scope with a
/// [BuildContext] that is "under" the [ExpansionTile]:
///
/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.1.dart **
/// {@end-tool}
///
/// A more efficient solution is to split your build function into
/// several widgets. This introduces a new context from which you
/// can obtain the [ExpansionTileController]. With this approach you
/// would have an outer widget that creates the [ExpansionTile]
/// populated by instances of your new inner widgets, and then in
/// these inner widgets you would use `ExpansionTileController.of`.
///
/// The [ExpansibleController.expand] and [ExpansibleController.collapse]
/// methods cause the [ExpansionTile] to rebuild, so they may not be called from
/// a build method.
class ExpansionTileController {
/// Create a controller to be used with [ExpansionTile.controller].
ExpansionTileController();
_ExpansionTileState? _state;
/// Whether the [ExpansionTile] built with this controller is in expanded state.
///
/// This property doesn't take the animation into account. It reports `true`
/// even if the expansion animation is not completed.
///
/// See also:
///
/// * [expand], which expands the [ExpansionTile].
/// * [collapse], which collapses the [ExpansionTile].
/// * [ExpansionTile.controller] to create an ExpansionTile with a controller.
bool get isExpanded {
assert(_state != null);
return _state!._isExpanded;
}
/// Expands the [ExpansionTile] that was built with this controller;
///
/// Normally the tile is expanded automatically when the user taps on the header.
/// It is sometimes useful to trigger the expansion programmatically due
/// to external changes.
///
/// If the tile is already in the expanded state (see [isExpanded]), calling
/// this method has no effect.
///
/// Calling this method may cause the [ExpansionTile] to rebuild, so it may
/// not be called from a build method.
///
/// Calling this method will trigger an [ExpansionTile.onExpansionChanged] callback.
///
/// See also:
///
/// * [collapse], which collapses the tile.
/// * [isExpanded] to check whether the tile is expanded.
/// * [ExpansionTile.controller] to create an ExpansionTile with a controller.
void expand() {
assert(_state != null);
if (!isExpanded) {
_state!._toggleExpansion();
}
}
/// Collapses the [ExpansionTile] that was built with this controller.
///
/// Normally the tile is collapsed automatically when the user taps on the header.
/// It can be useful sometimes to trigger the collapse programmatically due
/// to some external changes.
///
/// If the tile is already in the collapsed state (see [isExpanded]), calling
/// this method has no effect.
///
/// Calling this method may cause the [ExpansionTile] to rebuild, so it may
/// not be called from a build method.
///
/// Calling this method will trigger an [ExpansionTile.onExpansionChanged] callback.
///
/// See also:
///
/// * [expand], which expands the tile.
/// * [isExpanded] to check whether the tile is expanded.
/// * [ExpansionTile.controller] to create an ExpansionTile with a controller.
void collapse() {
assert(_state != null);
if (isExpanded) {
_state!._toggleExpansion();
}
}
/// Finds the [ExpansionTileController] for the closest [ExpansionTile] instance
/// that encloses the given context.
///
/// If no [ExpansionTile] encloses the given context, calling this
/// method will cause an assert in debug mode, and throw an
/// exception in release mode.
///
/// To return null if there is no [ExpansionTile] use [maybeOf] instead.
///
/// {@tool dartpad}
/// Typical usage of the [ExpansionTileController.of] function is to call it from within the
/// `build` method of a descendant of an [ExpansionTile].
///
/// When the [ExpansionTile] is actually created in the same `build`
/// function as the callback that refers to the controller, then the
/// `context` argument to the `build` function can't be used to find
/// the [ExpansionTileController] (since it's "above" the widget
/// being returned in the widget tree). In cases like that you can
/// add a [Builder] widget, which provides a new scope with a
/// [BuildContext] that is "under" the [ExpansionTile]:
///
/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.1.dart **
/// {@end-tool}
///
/// A more efficient solution is to split your build function into
/// several widgets. This introduces a new context from which you
/// can obtain the [ExpansionTileController]. With this approach you
/// would have an outer widget that creates the [ExpansionTile]
/// populated by instances of your new inner widgets, and then in
/// these inner widgets you would use [ExpansionTileController.of].
static ExpansionTileController of(BuildContext context) {
final _ExpansionTileState? result = context.findAncestorStateOfType<_ExpansionTileState>();
if (result != null) {
return result._tileController;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'ExpansionTileController.of() called with a context that does not contain a ExpansionTile.',
),
ErrorDescription(
'No ExpansionTile ancestor could be found starting from the context that was passed to ExpansionTileController.of(). '
'This usually happens when the context provided is from the same StatefulWidget as that '
'whose build function actually creates the ExpansionTile widget being sought.',
),
ErrorHint(
'There are several ways to avoid this problem. The simplest is to use a Builder to get a '
'context that is "under" the ExpansionTile. For an example of this, please see the '
'documentation for ExpansionTileController.of():\n'
' https://api.flutter.dev/flutter/material/ExpansionTile/of.html',
),
ErrorHint(
'A more efficient solution is to split your build function into several widgets. This '
'introduces a new context from which you can obtain the ExpansionTile. In this solution, '
'you would have an outer widget that creates the ExpansionTile populated by instances of '
'your new inner widgets, and then in these inner widgets you would use ExpansionTileController.of().\n'
'An other solution is assign a GlobalKey to the ExpansionTile, '
'then use the key.currentState property to obtain the ExpansionTile rather than '
'using the ExpansionTileController.of() function.',
),
context.describeElement('The context used was'),
]);
}
/// Finds the [ExpansionTile] from the closest instance of this class that
/// encloses the given context and returns its [ExpansionTileController].
///
/// If no [ExpansionTile] encloses the given context then return null.
/// To throw an exception instead, use [of] instead of this function.
///
/// See also:
///
/// * [of], a similar function to this one that throws if no [ExpansionTile]
/// encloses the given context. Also includes some sample code in its
/// documentation.
static ExpansionTileController? maybeOf(BuildContext context) {
return context.findAncestorStateOfType<_ExpansionTileState>()?._tileController;
}
}
///
/// Remember to dispose of the [ExpansionTileController] when it is no longer
/// needed. This will ensure we discard any resources used by the object.
typedef ExpansionTileController = ExpansibleController;
/// A single-line [ListTile] with an expansion arrow icon that expands or collapses
/// the tile to reveal or hide the [children].
@@ -293,6 +168,9 @@ class ExpansionTile extends StatefulWidget {
/// When the tile starts expanding, this function is called with the value
/// true. When the tile starts collapsing, this function is called with
/// the value false.
///
/// Instead of providing this property, consider adding this callback as a
/// listener to a provided [controller].
final ValueChanged<bool>? onExpansionChanged;
/// The widgets that are displayed when the tile expands.
@@ -331,7 +209,12 @@ class ExpansionTile extends StatefulWidget {
/// Specifies if the [ExpansionTile] should build a default trailing icon if [trailing] is null.
final bool showTrailingIcon;
/// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
/// Specifies if the list tile is initially expanded (true) or collapsed (false).
///
/// Alternatively, a provided [controller] can be used to initially expand the
/// tile if [ExpansibleController.expand] is called before this widget is built.
///
/// Defaults to false.
final bool initiallyExpanded;
/// Specifies whether the state of the children is maintained when the tile expands and collapses.
@@ -573,85 +456,57 @@ class ExpansionTile extends StatefulWidget {
State<ExpansionTile> createState() => _ExpansionTileState();
}
class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProviderStateMixin {
static final Animatable<double> _easeOutTween = CurveTween(curve: Curves.easeOut);
class _ExpansionTileState extends State<ExpansionTile> {
static final Animatable<double> _easeInTween = CurveTween(curve: Curves.easeIn);
static final Animatable<double> _easeOutTween = CurveTween(curve: Curves.easeOut);
static final Animatable<double> _halfTween = Tween<double>(begin: 0.0, end: 0.5);
final ShapeBorderTween _borderTween = ShapeBorderTween();
final ColorTween _headerColorTween = ColorTween();
final ColorTween _iconColorTween = ColorTween();
final ColorTween _backgroundColorTween = ColorTween();
final Tween<double> _heightFactorTween = Tween<double>(begin: 0.0, end: 1.0);
late AnimationController _animationController;
late Animation<double> _iconTurns;
late CurvedAnimation _heightFactor;
late Animation<ShapeBorder?> _border;
late Animation<Color?> _headerColor;
late Animation<Color?> _iconColor;
late Animation<Color?> _backgroundColor;
bool _isExpanded = false;
late ExpansionTileThemeData _expansionTileTheme;
late ExpansionTileController _tileController;
Timer? _timer;
late Curve _curve;
late Curve? _reverseCurve;
late Duration _duration;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: _kExpand, vsync: this);
_heightFactor = CurvedAnimation(
parent: _animationController.drive(_heightFactorTween),
curve: Curves.easeIn,
);
_iconTurns = _animationController.drive(_halfTween.chain(_easeInTween));
_border = _animationController.drive(_borderTween.chain(_easeOutTween));
_headerColor = _animationController.drive(_headerColorTween.chain(_easeInTween));
_iconColor = _animationController.drive(_iconColorTween.chain(_easeInTween));
_backgroundColor = _animationController.drive(_backgroundColorTween.chain(_easeOutTween));
_isExpanded =
PageStorage.maybeOf(context)?.readState(context) as bool? ?? widget.initiallyExpanded;
if (_isExpanded) {
_animationController.value = 1.0;
}
assert(widget.controller?._state == null);
_curve = Curves.easeIn;
_duration = _kExpand;
_tileController = widget.controller ?? ExpansionTileController();
_tileController._state = this;
if (widget.initiallyExpanded) {
_tileController.expand();
}
_tileController.addListener(_onExpansionChanged);
}
@override
void dispose() {
_tileController._state = null;
_animationController.dispose();
_heightFactor.dispose();
_tileController.removeListener(_onExpansionChanged);
if (widget.controller == null) {
_tileController.dispose();
}
_timer?.cancel();
_timer = null;
super.dispose();
}
void _toggleExpansion() {
void _onExpansionChanged() {
final TextDirection textDirection = WidgetsLocalizations.of(context).textDirection;
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final String stateHint = _isExpanded ? localizations.expandedHint : localizations.collapsedHint;
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_animationController.forward();
} else {
_animationController.reverse().then<void>((void value) {
if (!mounted) {
return;
}
setState(() {
// Rebuild without widget.children.
});
});
}
PageStorage.maybeOf(context)?.writeState(context, _isExpanded);
});
widget.onExpansionChanged?.call(_isExpanded);
final String stateHint =
_tileController.isExpanded ? localizations.collapsedHint : localizations.expandedHint;
if (defaultTargetPlatform == TargetPlatform.iOS) {
// TODO(tahatesser): This is a workaround for VoiceOver interrupting
@@ -665,10 +520,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
} else {
SemanticsService.announce(stateHint, textDirection);
}
}
void _handleTap() {
_toggleExpansion();
widget.onExpansionChanged?.call(_tileController.isExpanded);
}
// Platform or null affinity defaults to trailing.
@@ -685,40 +537,32 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
}
}
Widget? _buildIcon(BuildContext context) {
Widget? _buildIcon(BuildContext context, Animation<double> animation) {
_iconTurns = animation.drive(_halfTween.chain(_easeInTween));
return RotationTransition(turns: _iconTurns, child: const Icon(Icons.expand_more));
}
Widget? _buildLeadingIcon(BuildContext context) {
Widget? _buildLeadingIcon(BuildContext context, Animation<double> animation) {
if (_effectiveAffinity() != ListTileControlAffinity.leading) {
return null;
}
return _buildIcon(context);
return _buildIcon(context, animation);
}
Widget? _buildTrailingIcon(BuildContext context) {
Widget? _buildTrailingIcon(BuildContext context, Animation<double> animation) {
if (_effectiveAffinity() != ListTileControlAffinity.trailing) {
return null;
}
return _buildIcon(context);
return _buildIcon(context, animation);
}
Widget _buildChildren(BuildContext context, Widget? child) {
Widget _buildHeader(BuildContext context, Animation<double> animation) {
_iconColor = animation.drive(_iconColorTween.chain(_easeInTween));
_headerColor = animation.drive(_headerColorTween.chain(_easeInTween));
final ThemeData theme = Theme.of(context);
final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
final Color backgroundColor =
_backgroundColor.value ?? expansionTileTheme.backgroundColor ?? Colors.transparent;
final ShapeBorder expansionTileBorder =
_border.value ??
const Border(
top: BorderSide(color: Colors.transparent),
bottom: BorderSide(color: Colors.transparent),
);
final Clip clipBehavior =
widget.clipBehavior ?? expansionTileTheme.clipBehavior ?? Clip.antiAlias;
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final String onTapHint =
_isExpanded
_tileController.isExpanded
? localizations.expansionTileExpandedTapHint
: localizations.expansionTileCollapsedTapHint;
String? semanticsHint;
@@ -726,7 +570,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
case TargetPlatform.iOS:
case TargetPlatform.macOS:
semanticsHint =
_isExpanded
_tileController.isExpanded
? '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}'
: '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}';
case TargetPlatform.android:
@@ -736,6 +580,66 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
break;
}
return Semantics(
hint: semanticsHint,
onTapHint: onTapHint,
child: ListTileTheme.merge(
iconColor: _iconColor.value ?? _expansionTileTheme.iconColor,
textColor: _headerColor.value,
child: ListTile(
enabled: widget.enabled,
onTap: _tileController.isExpanded ? _tileController.collapse : _tileController.expand,
dense: widget.dense,
visualDensity: widget.visualDensity,
enableFeedback: widget.enableFeedback,
contentPadding: widget.tilePadding ?? _expansionTileTheme.tilePadding,
leading: widget.leading ?? _buildLeadingIcon(context, animation),
title: widget.title,
subtitle: widget.subtitle,
trailing:
widget.showTrailingIcon
? widget.trailing ?? _buildTrailingIcon(context, animation)
: null,
minTileHeight: widget.minTileHeight,
internalAddSemanticForOnTap: widget.internalAddSemanticForOnTap,
),
),
);
}
Widget _buildBody(BuildContext context, Animation<double> animation) {
return Align(
alignment:
widget.expandedAlignment ?? _expansionTileTheme.expandedAlignment ?? Alignment.center,
child: Padding(
padding: widget.childrenPadding ?? _expansionTileTheme.childrenPadding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
children: widget.children,
),
),
);
}
Widget _buildExpansible(
BuildContext context,
Widget header,
Widget body,
Animation<double> animation,
) {
_backgroundColor = animation.drive(_backgroundColorTween.chain(_easeOutTween));
_border = animation.drive(_borderTween.chain(_easeOutTween));
final Color backgroundColor =
_backgroundColor.value ?? _expansionTileTheme.backgroundColor ?? Colors.transparent;
final ShapeBorder expansionTileBorder =
_border.value ??
const Border(
top: BorderSide(color: Colors.transparent),
bottom: BorderSide(color: Colors.transparent),
);
final Clip clipBehavior =
widget.clipBehavior ?? _expansionTileTheme.clipBehavior ?? Clip.antiAlias;
final Decoration decoration = ShapeDecoration(
color: backgroundColor,
shape: expansionTileBorder,
@@ -743,51 +647,14 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
final Widget tile = Padding(
padding: decoration.padding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Semantics(
hint: semanticsHint,
onTapHint: onTapHint,
child: ListTileTheme.merge(
iconColor: _iconColor.value ?? expansionTileTheme.iconColor,
textColor: _headerColor.value,
child: ListTile(
enabled: widget.enabled,
onTap: _handleTap,
dense: widget.dense,
visualDensity: widget.visualDensity,
enableFeedback: widget.enableFeedback,
contentPadding: widget.tilePadding ?? expansionTileTheme.tilePadding,
leading: widget.leading ?? _buildLeadingIcon(context),
title: widget.title,
subtitle: widget.subtitle,
trailing:
widget.showTrailingIcon ? widget.trailing ?? _buildTrailingIcon(context) : null,
minTileHeight: widget.minTileHeight,
internalAddSemanticForOnTap: widget.internalAddSemanticForOnTap,
),
),
),
ClipRect(
child: Align(
alignment:
widget.expandedAlignment ??
expansionTileTheme.expandedAlignment ??
Alignment.center,
heightFactor: _heightFactor.value,
child: child,
),
),
],
),
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[header, body]),
);
final bool isShapeProvided =
widget.shape != null ||
expansionTileTheme.shape != null ||
_expansionTileTheme.shape != null ||
widget.collapsedShape != null ||
expansionTileTheme.collapsedShape != null;
_expansionTileTheme.collapsedShape != null;
if (isShapeProvided) {
return Material(
@@ -805,134 +672,115 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
void didUpdateWidget(covariant ExpansionTile oldWidget) {
super.didUpdateWidget(oldWidget);
final ThemeData theme = Theme.of(context);
final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
_expansionTileTheme = ExpansionTileTheme.of(context);
final ExpansionTileThemeData defaults =
theme.useMaterial3 ? _ExpansionTileDefaultsM3(context) : _ExpansionTileDefaultsM2(context);
if (widget.collapsedShape != oldWidget.collapsedShape || widget.shape != oldWidget.shape) {
_updateShapeBorder(expansionTileTheme, theme);
_updateShapeBorder(theme);
}
if (widget.collapsedTextColor != oldWidget.collapsedTextColor ||
widget.textColor != oldWidget.textColor) {
_updateHeaderColor(expansionTileTheme, defaults);
_updateHeaderColor(defaults);
}
if (widget.collapsedIconColor != oldWidget.collapsedIconColor ||
widget.iconColor != oldWidget.iconColor) {
_updateIconColor(expansionTileTheme, defaults);
_updateIconColor(defaults);
}
if (widget.backgroundColor != oldWidget.backgroundColor ||
widget.collapsedBackgroundColor != oldWidget.collapsedBackgroundColor) {
_updateBackgroundColor(expansionTileTheme);
_updateBackgroundColor();
}
if (widget.expansionAnimationStyle != oldWidget.expansionAnimationStyle) {
_updateAnimationDuration(expansionTileTheme);
_updateHeightFactorCurve(expansionTileTheme);
_updateAnimationDuration();
_updateHeightFactorCurve();
}
}
@override
void didChangeDependencies() {
final ThemeData theme = Theme.of(context);
final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
_expansionTileTheme = ExpansionTileTheme.of(context);
final ExpansionTileThemeData defaults =
theme.useMaterial3 ? _ExpansionTileDefaultsM3(context) : _ExpansionTileDefaultsM2(context);
_updateAnimationDuration(expansionTileTheme);
_updateShapeBorder(expansionTileTheme, theme);
_updateHeaderColor(expansionTileTheme, defaults);
_updateIconColor(expansionTileTheme, defaults);
_updateBackgroundColor(expansionTileTheme);
_updateHeightFactorCurve(expansionTileTheme);
_updateAnimationDuration();
_updateShapeBorder(theme);
_updateHeaderColor(defaults);
_updateIconColor(defaults);
_updateBackgroundColor();
_updateHeightFactorCurve();
super.didChangeDependencies();
}
void _updateAnimationDuration(ExpansionTileThemeData expansionTileTheme) {
_animationController.duration =
void _updateAnimationDuration() {
_duration =
widget.expansionAnimationStyle?.duration ??
expansionTileTheme.expansionAnimationStyle?.duration ??
_kExpand;
_expansionTileTheme.expansionAnimationStyle?.duration ??
const Duration(milliseconds: 200);
}
void _updateShapeBorder(ExpansionTileThemeData expansionTileTheme, ThemeData theme) {
void _updateShapeBorder(ThemeData theme) {
_borderTween
..begin =
widget.collapsedShape ??
expansionTileTheme.collapsedShape ??
_expansionTileTheme.collapsedShape ??
const Border(
top: BorderSide(color: Colors.transparent),
bottom: BorderSide(color: Colors.transparent),
)
..end =
widget.shape ??
expansionTileTheme.shape ??
_expansionTileTheme.shape ??
Border(
top: BorderSide(color: theme.dividerColor),
bottom: BorderSide(color: theme.dividerColor),
);
}
void _updateHeaderColor(
ExpansionTileThemeData expansionTileTheme,
ExpansionTileThemeData defaults,
) {
void _updateHeaderColor(ExpansionTileThemeData defaults) {
_headerColorTween
..begin =
widget.collapsedTextColor ??
expansionTileTheme.collapsedTextColor ??
_expansionTileTheme.collapsedTextColor ??
defaults.collapsedTextColor
..end = widget.textColor ?? expansionTileTheme.textColor ?? defaults.textColor;
..end = widget.textColor ?? _expansionTileTheme.textColor ?? defaults.textColor;
}
void _updateIconColor(
ExpansionTileThemeData expansionTileTheme,
ExpansionTileThemeData defaults,
) {
void _updateIconColor(ExpansionTileThemeData defaults) {
_iconColorTween
..begin =
widget.collapsedIconColor ??
expansionTileTheme.collapsedIconColor ??
_expansionTileTheme.collapsedIconColor ??
defaults.collapsedIconColor
..end = widget.iconColor ?? expansionTileTheme.iconColor ?? defaults.iconColor;
..end = widget.iconColor ?? _expansionTileTheme.iconColor ?? defaults.iconColor;
}
void _updateBackgroundColor(ExpansionTileThemeData expansionTileTheme) {
void _updateBackgroundColor() {
_backgroundColorTween
..begin = widget.collapsedBackgroundColor ?? expansionTileTheme.collapsedBackgroundColor
..end = widget.backgroundColor ?? expansionTileTheme.backgroundColor;
..begin = widget.collapsedBackgroundColor ?? _expansionTileTheme.collapsedBackgroundColor
..end = widget.backgroundColor ?? _expansionTileTheme.backgroundColor;
}
void _updateHeightFactorCurve(ExpansionTileThemeData expansionTileTheme) {
_heightFactor.curve =
void _updateHeightFactorCurve() {
_curve =
widget.expansionAnimationStyle?.curve ??
expansionTileTheme.expansionAnimationStyle?.curve ??
_expansionTileTheme.expansionAnimationStyle?.curve ??
Curves.easeIn;
_heightFactor.reverseCurve =
_reverseCurve =
widget.expansionAnimationStyle?.reverseCurve ??
expansionTileTheme.expansionAnimationStyle?.reverseCurve;
_expansionTileTheme.expansionAnimationStyle?.reverseCurve;
}
@override
Widget build(BuildContext context) {
final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
final bool closed = !_isExpanded && _animationController.isDismissed;
final bool shouldRemoveChildren = closed && !widget.maintainState;
final Widget result = Offstage(
offstage: closed,
child: TickerMode(
enabled: !closed,
child: Padding(
padding: widget.childrenPadding ?? expansionTileTheme.childrenPadding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
children: widget.children,
),
),
),
);
return AnimatedBuilder(
animation: _animationController.view,
builder: _buildChildren,
child: shouldRemoveChildren ? null : result,
return Expansible(
controller: _tileController,
curve: _curve,
duration: _duration,
reverseCurve: _reverseCurve,
maintainState: widget.maintainState,
headerBuilder: _buildHeader,
bodyBuilder: _buildBody,
expansibleBuilder: _buildExpansible,
);
}
}

View File

@@ -0,0 +1,383 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'package:flutter/material.dart';
library;
import 'basic.dart';
import 'framework.dart';
import 'page_storage.dart';
import 'ticker_provider.dart';
import 'transitions.dart';
/// The type of the callback that returns the header or body of an [Expansible].
///
/// The `animation` property exposes the underlying expanding or collapsing
/// animation, which has a value of 0 when the [Expansible] is completely
/// collapsed and 1 when it is completely expanded. This can be used to drive
/// animations that sync up with the expanding or collapsing animation, such as
/// rotating an icon.
///
/// See also:
///
/// * [Expansible.headerBuilder], which is of this type.
/// * [Expansible.bodyBuilder], which is also of this type.
typedef ExpansibleComponentBuilder =
Widget Function(BuildContext context, Animation<double> animation);
/// The type of the callback that uses the header and body of an [Expansible]
/// widget to build the widget.
///
/// The `header` property is the header returned by [Expansible.headerBuilder].
/// The `body` property is the body returned by [Expansible.bodyBuilder] wrapped
/// in an [Offstage] to hide the body when the [Expansible] is collapsed.
///
/// The `animation` property exposes the underlying expanding or collapsing
/// animation, which has a value of 0 when the [Expansible] is completely
/// collapsed and 1 when it is completely expanded. This can be used to drive
/// animations that sync up with the expanding or collapsing animation, such as
/// rotating an icon.
///
/// See also:
///
/// * [Expansible.expansibleBuilder], which is of this type.
typedef ExpansibleBuilder =
Widget Function(BuildContext context, Widget header, Widget body, Animation<double> animation);
/// A controller for managing the expansion state of an [Expansible].
///
/// This class is a [ChangeNotifier] that notifies its listeners if the value of
/// [isExpanded] changes.
///
/// This controller provides methods to programmatically expand or collapse the
/// widget, and it allows external components to query the current expansion
/// state.
///
/// The controller's [expand] and [collapse] methods cause the
/// the [Expansible] to rebuild, so they may not be called from
/// a build method.
///
/// Remember to [dispose] of the [ExpansibleController] when it is no longer
/// needed. This will ensure we discard any resources used by the object.
class ExpansibleController extends ChangeNotifier {
/// Creates a controller to be used with [Expansible.controller].
ExpansibleController();
bool _isExpanded = false;
void _setExpansionState(bool newValue) {
if (newValue != _isExpanded) {
_isExpanded = newValue;
notifyListeners();
}
}
/// Whether the expansible widget built with this controller is in expanded
/// state.
///
/// This property doesn't take the animation into account. It reports `true`
/// even if the expansion animation is not completed.
///
/// To be notified when this property changes, add a listener to the
/// controller using [ExpansibleController.addListener].
///
/// See also:
///
/// * [expand], which expands the expansible widget.
/// * [collapse], which collapses the expansible widget.
bool get isExpanded => _isExpanded;
/// Expands the [Expansible] that was built with this controller.
///
/// If the widget is already in the expanded state (see [isExpanded]), calling
/// this method has no effect.
///
/// Calling this method may cause the [Expansible] to rebuild, so it may
/// not be called from a build method.
///
/// Calling this method will notify registered listeners of this controller
/// that the expansion state has changed.
///
/// See also:
///
/// * [collapse], which collapses the expansible widget.
/// * [isExpanded] to check whether the expansible widget is expanded.
void expand() {
_setExpansionState(true);
}
/// Collapses the [Expansible] that was built with this controller.
///
/// If the widget is already in the collapsed state (see [isExpanded]),
/// calling this method has no effect.
///
/// Calling this method may cause the [Expansible] to rebuild, so it may not
/// be called from a build method.
///
/// Calling this method will notify registered listeners of this controller
/// that the expansion state has changed.
///
/// See also:
///
/// * [expand], which expands the [Expansible].
/// * [isExpanded] to check whether the [Expansible] is expanded.
void collapse() {
_setExpansionState(false);
}
/// Finds the [ExpansibleController] for the closest [Expansible] instance
/// that encloses the given context.
///
/// If no [Expansible] encloses the given context, calling this
/// method will cause an assert in debug mode, and throw an
/// exception in release mode.
///
/// To return null if there is no [Expansible] use [maybeOf] instead.
///
/// Typical usage of the [ExpansibleController.of] function is to call it from
/// within the `build` method of a descendant of an [Expansible].
static ExpansibleController of(BuildContext context) {
final _ExpansibleState? result = context.findAncestorStateOfType<_ExpansibleState>();
assert(() {
if (result == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'ExpansibleController.of() called with a context that does not contain a Expansible.',
),
ErrorDescription(
'No Expansible ancestor could be found starting from the context that was passed to ExpansibleController.of(). '
'This usually happens when the context provided is from the same StatefulWidget as that '
'whose build function actually creates the Expansible widget being sought.',
),
ErrorHint(
'There are several ways to avoid this problem. The simplest is to use a Builder to get a '
'context that is "under" the Expansible. ',
),
ErrorHint(
'A more efficient solution is to split your build function into several widgets. This '
'introduces a new context from which you can obtain the Expansible. In this solution, '
'you would have an outer widget that creates the Expansible populated by instances of '
'your new inner widgets, and then in these inner widgets you would use ExpansibleController.of().\n'
'An other solution is assign a GlobalKey to the Expansible, '
'then use the key.currentState property to obtain the Expansible rather than '
'using the ExpansibleController.of() function.',
),
context.describeElement('The context used was'),
]);
}
return true;
}());
return result!.widget.controller;
}
/// Finds the [Expansible] from the closest instance of this class that
/// encloses the given context and returns its [ExpansibleController].
///
/// If no [Expansible] encloses the given context then return null.
/// To throw an exception instead, use [of] instead of this function.
///
/// See also:
///
/// * [of], a similar function to this one that throws if no [Expansible]
/// encloses the given context.
static ExpansibleController? maybeOf(BuildContext context) {
return context.findAncestorStateOfType<_ExpansibleState>()?.widget.controller;
}
}
/// A [StatefulWidget] that expands and collapses.
///
/// An [Expansible] consists of a header, which is always shown, and a
/// body, which is hidden in its collapsed state and shown in its expanded
/// state.
///
/// The [Expansible] is expanded or collapsed with an animation driven by an
/// [AnimationController]. When the widget is expanded, the height of its body
/// animates from 0 to its fully expanded height.
///
/// This widget is typically used with [ListView] to create an "expand /
/// collapse" list entry. When used with scrolling widgets like [ListView], a
/// unique [PageStorageKey] must be specified as the [key], to enable the
/// [Expansible] to save and restore its expanded state when it is scrolled
/// in and out of view.
///
/// Provide [headerBuilder] and [bodyBuilder] callbacks to
/// build the header and body widgets. An additional [expansibleBuilder]
/// callback can be provided to further customize the layout of the widget.
///
/// The [Expansible] does not inherently toggle the expansion state. To toggle
/// the expansion state, call [ExpansibleController.expand] and
/// [ExpansibleController.collapse] as needed, most typically when the header
/// returned in [headerBuilder] is tapped.
///
/// See also:
///
/// * [ExpansionTile], a Material-styled widget that expands and collapses.
class Expansible extends StatefulWidget {
/// Creates an instance of [Expansible].
const Expansible({
super.key,
required this.headerBuilder,
required this.bodyBuilder,
required this.controller,
this.expansibleBuilder = _defaultExpansibleBuilder,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.ease,
this.reverseCurve,
this.maintainState = true,
});
/// Expands and collapses the widget.
///
/// The controller manages the expansion state and toggles the expansion.
final ExpansibleController controller;
/// Builds the always-displayed header.
///
/// Many use cases involve toggling the expansion state when this header is
/// tapped. To toggle the expansion state, call [ExpansibleController.expand]
/// or [ExpansibleController.collapse].
final ExpansibleComponentBuilder headerBuilder;
/// Builds the collapsible body.
///
/// When this widget is expanded, the height of its body animates from 0 to
/// its fully extended height.
final ExpansibleComponentBuilder bodyBuilder;
/// The duration of the expansion animation.
///
/// Defaults to a duration of 200ms.
final Duration duration;
/// The curve of the expansion animation.
///
/// Defaults to [Curves.ease].
final Curve curve;
/// The reverse curve of the expansion animation.
///
/// If null, uses [curve] in both directions.
final Curve? reverseCurve;
/// Whether the state of the body is maintained when the widget expands or
/// collapses.
///
/// If true, the body is kept in the tree while the widget is
/// collapsed. Otherwise, the body is removed from the tree when the
/// widget is collapsed and recreated upon expansion.
///
/// Defaults to false.
final bool maintainState;
/// Builds the widget with the results of [headerBuilder] and [bodyBuilder].
///
/// Defaults to placing the header and body in a [Column].
final ExpansibleBuilder expansibleBuilder;
static Widget _defaultExpansibleBuilder(
BuildContext context,
Widget header,
Widget body,
Animation<double> animation,
) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[header, body]);
}
@override
State<StatefulWidget> createState() => _ExpansibleState();
}
class _ExpansibleState extends State<Expansible> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late CurvedAnimation _heightFactor;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: widget.duration, vsync: this);
final bool initiallyExpanded =
PageStorage.maybeOf(context)?.readState(context) as bool? ?? widget.controller.isExpanded;
if (initiallyExpanded) {
_animationController.value = 1.0;
widget.controller.expand();
} else {
widget.controller.collapse();
}
final Tween<double> heightFactorTween = Tween<double>(begin: 0.0, end: 1.0);
_heightFactor = CurvedAnimation(
parent: _animationController.drive(heightFactorTween),
curve: widget.curve,
reverseCurve: widget.reverseCurve,
);
widget.controller.addListener(_toggleExpansion);
}
@override
void didUpdateWidget(covariant Expansible oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.curve != oldWidget.curve) {
_heightFactor.curve = widget.curve;
}
if (widget.reverseCurve != oldWidget.reverseCurve) {
_heightFactor.reverseCurve = widget.reverseCurve;
}
if (widget.duration != oldWidget.duration) {
_animationController.duration = widget.duration;
}
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_toggleExpansion);
widget.controller.addListener(_toggleExpansion);
}
}
@override
void dispose() {
widget.controller.removeListener(_toggleExpansion);
_animationController.dispose();
_heightFactor.dispose();
super.dispose();
}
void _toggleExpansion() {
setState(() {
// Rebuild with the header and the animating body.
if (widget.controller.isExpanded) {
_animationController.forward();
} else {
_animationController.reverse().then<void>((void value) {
if (!mounted) {
return;
}
setState(() {
// Rebuild without the body.
});
});
}
PageStorage.maybeOf(context)?.writeState(context, widget.controller.isExpanded);
});
}
@override
Widget build(BuildContext context) {
assert(!_animationController.isDismissed || !widget.controller.isExpanded);
final bool closed = !widget.controller.isExpanded && _animationController.isDismissed;
final bool shouldRemoveBody = closed && !widget.maintainState;
final Widget result = Offstage(
offstage: closed,
child: TickerMode(enabled: !closed, child: widget.bodyBuilder(context, _animationController)),
);
return AnimatedBuilder(
animation: _animationController.view,
builder: (BuildContext context, Widget? child) {
final Widget header = widget.headerBuilder(context, _animationController);
final Widget body = ClipRect(child: Align(heightFactor: _heightFactor.value, child: child));
return widget.expansibleBuilder(context, header, body, _animationController);
},
child: shouldRemoveBody ? null : result,
);
}
}

View File

@@ -51,6 +51,7 @@ export 'src/widgets/drag_target.dart';
export 'src/widgets/draggable_scrollable_sheet.dart';
export 'src/widgets/dual_transition_builder.dart';
export 'src/widgets/editable_text.dart';
export 'src/widgets/expansible.dart';
export 'src/widgets/fade_in_image.dart';
export 'src/widgets/feedback.dart';
export 'src/widgets/flutter_logo.dart';

View File

@@ -1492,6 +1492,8 @@ void main() {
expect(controller.isExpanded, isFalse);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsNothing);
controller.dispose();
});
testWidgets(
@@ -1530,6 +1532,8 @@ void main() {
expect(controller.isExpanded, isFalse);
await tester.pump();
expect(tester.hasRunningAnimations, isFalse);
controller.dispose();
},
);
@@ -1658,6 +1662,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.dispose();
});
testWidgets(

View File

@@ -0,0 +1,261 @@
// Copyright 2014 The Flutter 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/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Controller expands and collapses the widget', (WidgetTester tester) async {
final ExpansibleController controller = ExpansibleController();
await tester.pumpWidget(
MaterialApp(
home: Expansible(
controller: controller,
bodyBuilder: (BuildContext context, Animation<double> animation) => const Text('Body'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller.isExpanded ? controller.collapse : controller.expand,
child: const Text('Header'),
),
),
),
);
expect(find.text('Body'), findsNothing);
controller.expand();
await tester.pumpAndSettle();
expect(find.text('Body'), findsOneWidget);
controller.collapse();
await tester.pumpAndSettle();
expect(find.text('Body'), findsNothing);
controller.dispose();
});
testWidgets('Can listen to the expansion state', (WidgetTester tester) async {
final ExpansibleController controller = ExpansibleController();
bool? expansionState;
controller.addListener(() {
expansionState = controller.isExpanded;
});
await tester.pumpWidget(
MaterialApp(
home: Expansible(
controller: controller,
bodyBuilder: (BuildContext context, Animation<double> animation) => const Text('Body'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller.isExpanded ? controller.collapse : controller.expand,
child: const Text('Header'),
),
),
),
);
// Tap on the header to toggle the expansion.
await tester.tap(find.text('Header'));
await tester.pumpAndSettle();
expect(expansionState, true);
await tester.tap(find.text('Header'));
await tester.pumpAndSettle();
expect(expansionState, false);
// Use the controller to toggle the expansion.
controller.expand();
await tester.pumpAndSettle();
expect(expansionState, true);
controller.collapse();
await tester.pumpAndSettle();
expect(expansionState, false);
controller.dispose();
});
testWidgets('Can set expansible to be initially expanded', (WidgetTester tester) async {
final ExpansibleController controller = ExpansibleController();
controller.expand();
await tester.pumpWidget(
MaterialApp(
home: SingleChildScrollView(
child: Column(
children: <Widget>[
Expansible(
controller: controller,
bodyBuilder:
(BuildContext context, Animation<double> animation) => const Text('Body'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller.isExpanded ? controller.collapse : controller.expand,
child: const Text('Header'),
),
),
],
),
),
),
);
expect(find.text('Body'), findsOneWidget);
await tester.tap(find.text('Header'));
await tester.pumpAndSettle();
expect(find.text('Body'), findsNothing);
controller.dispose();
});
testWidgets('Can compose header and body with expansibleBuilder', (WidgetTester tester) async {
final ExpansibleController controller = ExpansibleController();
await tester.pumpWidget(
MaterialApp(
home: Expansible(
controller: controller,
bodyBuilder: (BuildContext context, Animation<double> animation) => const Text('Body'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller.isExpanded ? controller.collapse : controller.expand,
child: const Text('Header'),
),
expansibleBuilder: (
BuildContext context,
Widget header,
Widget body,
Animation<double> animation,
) {
return header;
},
),
),
);
// Tap on the header to toggle the expansion.
await tester.tap(find.text('Header'));
await tester.pumpAndSettle();
expect(find.text('Header'), findsOneWidget);
expect(find.text('Body'), findsNothing);
await tester.tap(find.text('Header'));
await tester.pumpAndSettle();
expect(find.text('Header'), findsOneWidget);
expect(find.text('Body'), findsNothing);
// Use the controller to toggle the expansion.
controller.expand();
await tester.pumpAndSettle();
expect(find.text('Header'), findsOneWidget);
expect(find.text('Body'), findsNothing);
controller.collapse();
await tester.pumpAndSettle();
expect(find.text('Header'), findsOneWidget);
expect(find.text('Body'), findsNothing);
controller.dispose();
});
testWidgets('Respects maintainState', (WidgetTester tester) async {
final ExpansibleController controller1 = ExpansibleController();
final ExpansibleController controller2 = ExpansibleController();
await tester.pumpWidget(
MaterialApp(
home: SingleChildScrollView(
child: Column(
children: <Widget>[
Expansible(
controller: controller1,
maintainState: false,
bodyBuilder:
(BuildContext context, Animation<double> animation) =>
const Text('Maintaining State'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller1.isExpanded ? controller1.collapse : controller1.expand,
child: const Text('Header'),
),
),
Expansible(
controller: controller2,
bodyBuilder:
(BuildContext context, Animation<double> animation) =>
const Text('Discarding State'),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller2.isExpanded ? controller2.collapse : controller2.expand,
child: const Text('Header'),
),
),
],
),
),
),
);
// This text is not offstage while the expansible widget is collapsed.
expect(find.text('Maintaining State', skipOffstage: false), findsNothing);
expect(find.text('Maintaining State'), findsNothing);
// This text is not displayed while the expansible widget is collapsed.
expect(find.text('Discarding State'), findsNothing);
controller1.dispose();
controller2.dispose();
});
testWidgets('Respects animation duration and curves', (WidgetTester tester) async {
final ExpansibleController controller = ExpansibleController();
await tester.pumpWidget(
MaterialApp(
home: Expansible(
controller: controller,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
bodyBuilder:
(BuildContext context, Animation<double> animation) =>
const SizedBox(height: 50.0, child: Placeholder()),
headerBuilder:
(BuildContext context, Animation<double> animation) => GestureDetector(
onTap: controller.isExpanded ? controller.collapse : controller.expand,
child: const Text('Header'),
),
),
),
);
expect(find.byType(Placeholder), findsNothing);
await tester.tap(find.text('Header'));
// Check that the curve is respected.
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getBottomLeft(find.byType(Placeholder)).dy, 90.08984375);
// The animation has completed.
await tester.pump(const Duration(milliseconds: 60) + const Duration(microseconds: 1));
expect(tester.getBottomLeft(find.byType(Placeholder)).dy, 98.0);
// Since the animation has completed, the vertical position doesn't change.
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getBottomLeft(find.byType(Placeholder)).dy, 98.0);
await tester.pumpAndSettle();
await tester.tap(find.text('Header'));
// Check that the reverse curve is respected.
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getBottomLeft(find.byType(Placeholder)).dy, 80.91015625);
// The animation has completed.
await tester.pump(const Duration(milliseconds: 60) + const Duration(microseconds: 1));
expect(find.byType(Placeholder), findsNothing);
controller.dispose();
});
}