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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
383
packages/flutter/lib/src/widgets/expansible.dart
Normal file
383
packages/flutter/lib/src/widgets/expansible.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
261
packages/flutter/test/widgets/expansible_test.dart
Normal file
261
packages/flutter/test/widgets/expansible_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user