diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart index 4718020cee..8f9d2278b0 100644 --- a/packages/flutter/lib/src/material/expansion_tile.dart +++ b/packages/flutter/lib/src/material/expansion_tile.dart @@ -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([ - 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? 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 createState() => _ExpansionTileState(); } -class _ExpansionTileState extends State with SingleTickerProviderStateMixin { - static final Animatable _easeOutTween = CurveTween(curve: Curves.easeOut); +class _ExpansionTileState extends State { static final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); + static final Animatable _easeOutTween = CurveTween(curve: Curves.easeOut); static final Animatable _halfTween = Tween(begin: 0.0, end: 0.5); final ShapeBorderTween _borderTween = ShapeBorderTween(); final ColorTween _headerColorTween = ColorTween(); final ColorTween _iconColorTween = ColorTween(); final ColorTween _backgroundColorTween = ColorTween(); - final Tween _heightFactorTween = Tween(begin: 0.0, end: 1.0); - late AnimationController _animationController; late Animation _iconTurns; - late CurvedAnimation _heightFactor; late Animation _border; late Animation _headerColor; late Animation _iconColor; late Animation _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 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 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 with SingleTickerProvider } } - Widget? _buildIcon(BuildContext context) { + Widget? _buildIcon(BuildContext context, Animation 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 animation) { if (_effectiveAffinity() != ListTileControlAffinity.leading) { return null; } - return _buildIcon(context); + return _buildIcon(context, animation); } - Widget? _buildTrailingIcon(BuildContext context) { + Widget? _buildTrailingIcon(BuildContext context, Animation 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 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 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 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 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 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 with SingleTickerProvider final Widget tile = Padding( padding: decoration.padding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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: [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 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, ); } } diff --git a/packages/flutter/lib/src/widgets/expansible.dart b/packages/flutter/lib/src/widgets/expansible.dart new file mode 100644 index 0000000000..423a9faf62 --- /dev/null +++ b/packages/flutter/lib/src/widgets/expansible.dart @@ -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 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 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([ + 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 animation, + ) { + return Column(mainAxisSize: MainAxisSize.min, children: [header, body]); + } + + @override + State createState() => _ExpansibleState(); +} + +class _ExpansibleState extends State 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 heightFactorTween = Tween(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 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, + ); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 86f9a8f507..af07fbf279 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/material/expansion_tile_test.dart b/packages/flutter/test/material/expansion_tile_test.dart index 1bdb32afe8..5f9123396c 100644 --- a/packages/flutter/test/material/expansion_tile_test.dart +++ b/packages/flutter/test/material/expansion_tile_test.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( diff --git a/packages/flutter/test/widgets/expansible_test.dart b/packages/flutter/test/widgets/expansible_test.dart new file mode 100644 index 0000000000..35729f5419 --- /dev/null +++ b/packages/flutter/test/widgets/expansible_test.dart @@ -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 animation) => const Text('Body'), + headerBuilder: + (BuildContext context, Animation 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 animation) => const Text('Body'), + headerBuilder: + (BuildContext context, Animation 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: [ + Expansible( + controller: controller, + bodyBuilder: + (BuildContext context, Animation animation) => const Text('Body'), + headerBuilder: + (BuildContext context, Animation 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 animation) => const Text('Body'), + headerBuilder: + (BuildContext context, Animation animation) => GestureDetector( + onTap: controller.isExpanded ? controller.collapse : controller.expand, + child: const Text('Header'), + ), + expansibleBuilder: ( + BuildContext context, + Widget header, + Widget body, + Animation 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: [ + Expansible( + controller: controller1, + maintainState: false, + bodyBuilder: + (BuildContext context, Animation animation) => + const Text('Maintaining State'), + headerBuilder: + (BuildContext context, Animation animation) => GestureDetector( + onTap: controller1.isExpanded ? controller1.collapse : controller1.expand, + child: const Text('Header'), + ), + ), + Expansible( + controller: controller2, + bodyBuilder: + (BuildContext context, Animation animation) => + const Text('Discarding State'), + headerBuilder: + (BuildContext context, Animation 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 animation) => + const SizedBox(height: 50.0, child: Placeholder()), + headerBuilder: + (BuildContext context, Animation 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(); + }); +}