Added ExpansionTileController (#123298)

Added ExpansionTileController
This commit is contained in:
Hans Muller 2023-03-23 17:51:06 -07:00 committed by GitHub
parent f7fb14ec58
commit 59c9d4e845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 434 additions and 15 deletions

View file

@ -0,0 +1,84 @@
// 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.
// Flutter code sample for [ExpansionTile] and [ExpansionTileController]
import 'package:flutter/material.dart';
void main() {
runApp(const ExpansionTileControllerApp());
}
class ExpansionTileControllerApp extends StatefulWidget {
const ExpansionTileControllerApp({ super.key });
@override
State<ExpansionTileControllerApp> createState() => _ExpansionTileControllerAppState();
}
class _ExpansionTileControllerAppState extends State<ExpansionTileControllerApp> {
final ExpansionTileController controller = ExpansionTileController();
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Code Sample for ExpansionTileController.',
theme: ThemeData(useMaterial3: true),
home: Scaffold(
appBar: AppBar(title: const Text('ExpansionTileController Example')),
body: Column(
children: <Widget>[
// A controller has been provided to the ExpansionTile because it's
// going to be accessed from a component that is not within the
// tile's BuildContext.
ExpansionTile(
controller: controller,
title: const Text('ExpansionTile with explicit controller.'),
children: <Widget>[
Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(24),
child: const Text('ExpansionTile Contents'),
),
],
),
const SizedBox(height: 8),
ElevatedButton(
child: const Text('Expand/Collapse the Tile Above'),
onPressed: () {
if (controller.isExpanded) {
controller.collapse();
} else {
controller.expand();
}
},
),
const SizedBox(height: 48),
// A controller has not been provided to the ExpansionTile because
// the automatically created one can be retrieved via the tile's BuildContext.
ExpansionTile(
title: const Text('ExpansionTile with implicit controller.'),
children: <Widget>[
Builder(
builder: (BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
alignment: Alignment.center,
child: ElevatedButton(
child: const Text('Collapse This Tile'),
onPressed: () {
return ExpansionTileController.of(context).collapse();
},
),
);
},
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,31 @@
// 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_api_samples/material/expansion_tile/expansion_tile.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Test the basics of ExpansionTileControllerApp', (WidgetTester tester) async {
await tester.pumpWidget(
const example.ExpansionTileControllerApp(),
);
expect(find.text('ExpansionTile Contents'), findsNothing);
expect(find.text('Collapse This Tile'), findsNothing);
await tester.tap(find.text('Expand/Collapse the Tile Above'));
await tester.pumpAndSettle();
expect(find.text('ExpansionTile Contents'), findsOneWidget);
await tester.tap(find.text('Expand/Collapse the Tile Above'));
await tester.pumpAndSettle();
expect(find.text('ExpansionTile Contents'), findsNothing);
await tester.tap(find.text('ExpansionTile with implicit controller.'));
await tester.pumpAndSettle();
expect(find.text('Collapse This Tile'), findsOneWidget);
await tester.tap(find.text('Collapse This Tile'));
await tester.pumpAndSettle();
expect(find.text('Collapse This Tile'), findsNothing);
});
}

View file

@ -15,6 +15,170 @@ import 'theme.dart';
const Duration _kExpand = Duration(milliseconds: 200);
/// Enables control over a single [ExpansionTile]'s expanded/collapsed state.
///
/// It can be useful to expand or collapse an [ExpansionTile]
/// programatically, for example to reconfigure an existing expansion
/// 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]
///
/// The controller's [expand] and [collapse] methods cause the
/// 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;
}
}
/// A single-line [ListTile] with an expansion arrow icon that expands or collapses
/// the tile to reveal or hide the [children].
///
@ -40,6 +204,13 @@ const Duration _kExpand = Duration(milliseconds: 200);
/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example demonstrates how an [ExpansionTileController] can be used to
/// programatically expand or collapse an [ExpansionTile].
///
/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [ListTile], useful for creating expansion tile [children] when the
@ -74,6 +245,7 @@ class ExpansionTile extends StatefulWidget {
this.collapsedShape,
this.clipBehavior,
this.controlAffinity,
this.controller,
}) : assert(
expandedCrossAxisAlignment != CrossAxisAlignment.baseline,
'CrossAxisAlignment.baseline is not supported since the expanded children '
@ -310,6 +482,13 @@ class ExpansionTile extends StatefulWidget {
/// which means that the expansion arrow icon will appear on the tile's trailing edge.
final ListTileControlAffinity? controlAffinity;
/// If provided, the controller can be used to expand and collapse tiles.
///
/// In cases were control over the tile's state is needed from a callback triggered
/// by a widget within the tile, [ExpansionTileController.of] may be more convenient
/// than supplying a controller.
final ExpansionTileController? controller;
@override
State<ExpansionTile> createState() => _ExpansionTileState();
}
@ -324,7 +503,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
final ColorTween _iconColorTween = ColorTween();
final ColorTween _backgroundColorTween = ColorTween();
late AnimationController _controller;
late AnimationController _animationController;
late Animation<double> _iconTurns;
late Animation<double> _heightFactor;
late Animation<ShapeBorder?> _border;
@ -333,37 +512,43 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
late Animation<Color?> _backgroundColor;
bool _isExpanded = false;
late ExpansionTileController _tileController;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: _kExpand, vsync: this);
_heightFactor = _controller.drive(_easeInTween);
_iconTurns = _controller.drive(_halfTween.chain(_easeInTween));
_border = _controller.drive(_borderTween.chain(_easeOutTween));
_headerColor = _controller.drive(_headerColorTween.chain(_easeInTween));
_iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
_backgroundColor = _controller.drive(_backgroundColorTween.chain(_easeOutTween));
_animationController = AnimationController(duration: _kExpand, vsync: this);
_heightFactor = _animationController.drive(_easeInTween);
_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) {
_controller.value = 1.0;
_animationController.value = 1.0;
}
assert(widget.controller?._state == null);
_tileController = widget.controller ?? ExpansionTileController();
_tileController._state = this;
}
@override
void dispose() {
_controller.dispose();
_tileController._state = null;
_animationController.dispose();
super.dispose();
}
void _handleTap() {
void _toggleExpansion() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_controller.forward();
_animationController.forward();
} else {
_controller.reverse().then<void>((void value) {
_animationController.reverse().then<void>((void value) {
if (!mounted) {
return;
}
@ -377,6 +562,10 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
widget.onExpansionChanged?.call(_isExpanded);
}
void _handleTap() {
_toggleExpansion();
}
// Platform or null affinity defaults to trailing.
ListTileControlAffinity _effectiveAffinity(ListTileControlAffinity? affinity) {
switch (affinity ?? ListTileControlAffinity.trailing) {
@ -491,7 +680,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
@override
Widget build(BuildContext context) {
final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
final bool closed = !_isExpanded && _controller.isDismissed;
final bool closed = !_isExpanded && _animationController.isDismissed;
final bool shouldRemoveChildren = closed && !widget.maintainState;
final Widget result = Offstage(
@ -509,7 +698,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
);
return AnimatedBuilder(
animation: _controller.view,
animation: _animationController.view,
builder: _buildChildren,
child: shouldRemoveChildren ? null : result,
);

View file

@ -729,4 +729,119 @@ void main() {
expect(getTextColor(), theme.colorScheme.primary);
});
});
testWidgets('ExpansionTileController isExpanded, expand() and collapse()', (WidgetTester tester) async {
final ExpansionTileController controller = ExpansionTileController();
await tester.pumpWidget(MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller,
title: const Text('Title'),
children: const <Widget>[
Text('Child 0'),
],
),
),
));
expect(find.text('Child 0'), findsNothing);
expect(controller.isExpanded, isFalse);
controller.expand();
expect(controller.isExpanded, isTrue);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsNothing);
});
testWidgets('Calling ExpansionTileController.expand/collapsed has no effect if it is already expanded/collapsed', (WidgetTester tester) async {
final ExpansionTileController controller = ExpansionTileController();
await tester.pumpWidget(MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller,
title: const Text('Title'),
initiallyExpanded: true,
children: const <Widget>[
Text('Child 0'),
],
),
),
));
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.expand();
expect(controller.isExpanded, isTrue);
await tester.pump();
expect(tester.hasRunningAnimations, isFalse);
expect(find.text('Child 0'), findsOneWidget);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pump();
expect(tester.hasRunningAnimations, isTrue);
await tester.pumpAndSettle();
expect(controller.isExpanded, isFalse);
expect(find.text('Child 0'), findsNothing);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pump();
expect(tester.hasRunningAnimations, isFalse);
});
testWidgets('Call to ExpansionTileController.of()', (WidgetTester tester) async {
final GlobalKey titleKey = GlobalKey();
final GlobalKey childKey = GlobalKey();
await tester.pumpWidget(MaterialApp(
home: Material(
child: ExpansionTile(
initiallyExpanded: true,
title: Text('Title', key: titleKey),
children: <Widget>[
Text('Child 0', key: childKey),
],
),
),
));
final ExpansionTileController controller1 = ExpansionTileController.of(childKey.currentContext!);
expect(controller1.isExpanded, isTrue);
final ExpansionTileController controller2 = ExpansionTileController.of(titleKey.currentContext!);
expect(controller2.isExpanded, isTrue);
expect(controller1, controller2);
});
testWidgets('Call to ExpansionTile.maybeOf()', (WidgetTester tester) async {
final GlobalKey titleKey = GlobalKey();
final GlobalKey nonDescendantKey = GlobalKey();
await tester.pumpWidget(MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(
title: Text('Title', key: titleKey),
children: const <Widget>[
Text('Child 0'),
],
),
Text('Non descendant', key: nonDescendantKey),
],
),
),
));
final ExpansionTileController? controller1 = ExpansionTileController.maybeOf(titleKey.currentContext!);
expect(controller1, isNotNull);
expect(controller1?.isExpanded, isFalse);
final ExpansionTileController? controller2 = ExpansionTileController.maybeOf(nonDescendantKey.currentContext!);
expect(controller2, isNull);
});
}