diff --git a/examples/api/lib/material/expansion_tile/expansion_tile.2.dart b/examples/api/lib/material/expansion_tile/expansion_tile.2.dart new file mode 100644 index 00000000000..1d6f6c4e4a5 --- /dev/null +++ b/examples/api/lib/material/expansion_tile/expansion_tile.2.dart @@ -0,0 +1,78 @@ +// 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'; + +/// Flutter code sample for [ExpansionTile] and [AnimationStyle]. + +void main() { + runApp(const ExpansionTileAnimationStyleApp()); +} + +enum AnimationStyles { defaultStyle, custom, none } +const List<(AnimationStyles, String)> animationStyleSegments = <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), +]; + +class ExpansionTileAnimationStyleApp extends StatefulWidget { + const ExpansionTileAnimationStyleApp({super.key}); + + @override + State createState() => _ExpansionTileAnimationStyleAppState(); +} + +class _ExpansionTileAnimationStyleAppState extends State { + Set _animationStyleSelection = {AnimationStyles.defaultStyle}; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + selected: _animationStyleSelection, + onSelectionChanged: (Set styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = AnimationStyle( + curve: Easing.emphasizedAccelerate, + duration: Durations.extralong1, + ); + case AnimationStyles.none: + _animationStyle = AnimationStyle.noAnimation; + } + }); + }, + segments: animationStyleSegments + .map>(((AnimationStyles, String) shirt) { + return ButtonSegment(value: shirt.$1, label: Text(shirt.$2)); + }) + .toList(), + ), + const SizedBox(height: 20), + ExpansionTile( + expansionAnimationStyle: _animationStyle, + title: const Text('ExpansionTile'), + children: const [ + ListTile(title: Text('Expanded Item 1')), + ListTile(title: Text('Expanded Item 2')), + ], + ) + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/test/material/expansion_tile/expansion_tile.2_test.dart b/examples/api/test/material/expansion_tile/expansion_tile.2_test.dart new file mode 100644 index 00000000000..97eeb14c989 --- /dev/null +++ b/examples/api/test/material/expansion_tile/expansion_tile.2_test.dart @@ -0,0 +1,63 @@ +// 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_api_samples/material/expansion_tile/expansion_tile.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ExpansionTile animation can be customized using AnimationStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const example.ExpansionTileAnimationStyleApp(), + ); + + double getHeight(WidgetTester tester) { + return tester.getSize(find.byType(ExpansionTile)).height; + } + + expect(getHeight(tester), 58.0); + + // Test the default animation style. + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(getHeight(tester), closeTo(93.4, 0.1)); + + await tester.pumpAndSettle(); + + expect(getHeight(tester), 170.0); + + // Tap to collapse. + await tester.tap(find.text('ExpansionTile')); + await tester.pumpAndSettle(); + + // Test the custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(getHeight(tester), closeTo(59.2, 0.1)); + + await tester.pumpAndSettle(); + + expect(getHeight(tester), 170.0); + + // Tap to collapse. + await tester.tap(find.text('ExpansionTile')); + await tester.pumpAndSettle(); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + + expect(getHeight(tester), 170.0); + }); +} diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart index cfd820f388b..b7ecd5d9a00 100644 --- a/packages/flutter/lib/src/material/expansion_tile.dart +++ b/packages/flutter/lib/src/material/expansion_tile.dart @@ -252,6 +252,7 @@ class ExpansionTile extends StatefulWidget { this.dense, this.visualDensity, this.enableFeedback = true, + this.expansionAnimationStyle, }) : assert( expandedCrossAxisAlignment != CrossAxisAlignment.baseline, 'CrossAxisAlignment.baseline is not supported since the expanded children ' @@ -506,6 +507,28 @@ class ExpansionTile extends StatefulWidget { /// {@macro flutter.material.ListTile.enableFeedback} final bool? enableFeedback; + /// Used to override the expansion animation curve and duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the expansion animation duration. If it is null, then [AnimationStyle.duration] + /// from the [ExpansionTileThemeData.expansionAnimationStyle] will be used. + /// Otherwise, defaults to 200ms. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the expansion animation curve. If it is null, then [AnimationStyle.curve] + /// from the [ExpansionTileThemeData.expansionAnimationStyle] will be used. + /// Otherwise, defaults to [Curves.easeIn]. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the [ExpansionTile] expansion + /// animation curve and duration using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.2.dart ** + /// {@end-tool} + final AnimationStyle? expansionAnimationStyle; + @override State createState() => _ExpansionTileState(); } @@ -519,6 +542,7 @@ class _ExpansionTileState extends State with SingleTickerProvider final ColorTween _headerColorTween = ColorTween(); final ColorTween _iconColorTween = ColorTween(); final ColorTween _backgroundColorTween = ColorTween(); + final CurveTween _heightFactorTween = CurveTween(curve: Curves.easeIn); late AnimationController _animationController; late Animation _iconTurns; @@ -535,7 +559,7 @@ class _ExpansionTileState extends State with SingleTickerProvider void initState() { super.initState(); _animationController = AnimationController(duration: _kExpand, vsync: this); - _heightFactor = _animationController.drive(_easeInTween); + _heightFactor = _animationController.drive(_heightFactorTween); _iconTurns = _animationController.drive(_halfTween.chain(_easeInTween)); _border = _animationController.drive(_borderTween.chain(_easeOutTween)); _headerColor = _animationController.drive(_headerColorTween.chain(_easeInTween)); @@ -711,6 +735,10 @@ class _ExpansionTileState extends State with SingleTickerProvider || widget.collapsedBackgroundColor != oldWidget.collapsedBackgroundColor) { _updateBackgroundColor(expansionTileTheme); } + if (widget.expansionAnimationStyle != oldWidget.expansionAnimationStyle) { + _updateAnimationDuration(expansionTileTheme); + _updateHeightFactorCurve(expansionTileTheme); + } } @override @@ -720,13 +748,21 @@ class _ExpansionTileState extends State with SingleTickerProvider final ExpansionTileThemeData defaults = theme.useMaterial3 ? _ExpansionTileDefaultsM3(context) : _ExpansionTileDefaultsM2(context); + _updateAnimationDuration(expansionTileTheme); _updateShapeBorder(expansionTileTheme, theme); _updateHeaderColor(expansionTileTheme, defaults); _updateIconColor(expansionTileTheme, defaults); _updateBackgroundColor(expansionTileTheme); + _updateHeightFactorCurve(expansionTileTheme); super.didChangeDependencies(); } + void _updateAnimationDuration(ExpansionTileThemeData expansionTileTheme) { + _animationController.duration = widget.expansionAnimationStyle?.duration + ?? expansionTileTheme.expansionAnimationStyle?.duration + ?? _kExpand; + } + void _updateShapeBorder(ExpansionTileThemeData expansionTileTheme, ThemeData theme) { _borderTween ..begin = widget.collapsedShape @@ -765,6 +801,12 @@ class _ExpansionTileState extends State with SingleTickerProvider ..end = widget.backgroundColor ?? expansionTileTheme.backgroundColor; } + void _updateHeightFactorCurve(ExpansionTileThemeData expansionTileTheme) { + _heightFactorTween.curve = widget.expansionAnimationStyle?.curve + ?? expansionTileTheme.expansionAnimationStyle?.curve + ?? Curves.easeIn; + } + @override Widget build(BuildContext context) { final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context); diff --git a/packages/flutter/lib/src/material/expansion_tile_theme.dart b/packages/flutter/lib/src/material/expansion_tile_theme.dart index 01d045ed692..b0109b23cc0 100644 --- a/packages/flutter/lib/src/material/expansion_tile_theme.dart +++ b/packages/flutter/lib/src/material/expansion_tile_theme.dart @@ -52,6 +52,7 @@ class ExpansionTileThemeData with Diagnosticable { this.shape, this.collapsedShape, this.clipBehavior, + this.expansionAnimationStyle, }); /// Overrides the default value of [ExpansionTile.backgroundColor]. @@ -90,6 +91,9 @@ class ExpansionTileThemeData with Diagnosticable { /// Overrides the default value of [ExpansionTile.clipBehavior]. final Clip? clipBehavior; + /// Overrides the default value of [ExpansionTile.expansionAnimationStyle]. + final AnimationStyle? expansionAnimationStyle; + /// Creates a copy of this object with the given fields replaced with the /// new values. ExpansionTileThemeData copyWith({ @@ -105,6 +109,7 @@ class ExpansionTileThemeData with Diagnosticable { ShapeBorder? shape, ShapeBorder? collapsedShape, Clip? clipBehavior, + AnimationStyle? expansionAnimationStyle, }) { return ExpansionTileThemeData( backgroundColor: backgroundColor ?? this.backgroundColor, @@ -119,6 +124,7 @@ class ExpansionTileThemeData with Diagnosticable { shape: shape ?? this.shape, collapsedShape: collapsedShape ?? this.collapsedShape, clipBehavior: clipBehavior ?? this.clipBehavior, + expansionAnimationStyle: expansionAnimationStyle ?? this.expansionAnimationStyle, ); } @@ -139,6 +145,8 @@ class ExpansionTileThemeData with Diagnosticable { collapsedTextColor: Color.lerp(a?.collapsedTextColor, b?.collapsedTextColor, t), shape: ShapeBorder.lerp(a?.shape, b?.shape, t), collapsedShape: ShapeBorder.lerp(a?.collapsedShape, b?.collapsedShape, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + expansionAnimationStyle: t < 0.5 ? a?.expansionAnimationStyle : b?.expansionAnimationStyle, ); } @@ -157,6 +165,7 @@ class ExpansionTileThemeData with Diagnosticable { shape, collapsedShape, clipBehavior, + expansionAnimationStyle, ); } @@ -180,7 +189,8 @@ class ExpansionTileThemeData with Diagnosticable { && other.collapsedTextColor == collapsedTextColor && other.shape == shape && other.collapsedShape == collapsedShape - && other.clipBehavior == clipBehavior; + && other.clipBehavior == clipBehavior + && other.expansionAnimationStyle == expansionAnimationStyle; } @override @@ -198,6 +208,7 @@ class ExpansionTileThemeData with Diagnosticable { properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); properties.add(DiagnosticsProperty('collapsedShape', collapsedShape, defaultValue: null)); properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: null)); + properties.add(DiagnosticsProperty('expansionAnimationStyle', expansionAnimationStyle, defaultValue: null)); } } diff --git a/packages/flutter/test/material/expansion_tile_test.dart b/packages/flutter/test/material/expansion_tile_test.dart index 79ba4356bfe..01f3862b1d7 100644 --- a/packages/flutter/test/material/expansion_tile_test.dart +++ b/packages/flutter/test/material/expansion_tile_test.dart @@ -1049,6 +1049,107 @@ void main() { expect(tester.state(find.byType(TestText)).textStyle.color, const Color(0xffffffff)); }); + testWidgetsWithLeakTracking('Override ExpansionTile animation using AnimationStyle', (WidgetTester tester) async { + const Key expansionTileKey = Key('expansionTileKey'); + + Widget buildExpansionTile({ AnimationStyle? animationStyle }) { + return MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + key: expansionTileKey, + expansionAnimationStyle: animationStyle, + title: const TestText('title'), + children: const [ + SizedBox(height: 100, width: 100), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildExpansionTile()); + + double getHeight(Key key) => tester.getSize(find.byKey(key)).height; + + // Test initial ExpansionTile height. + expect(getHeight(expansionTileKey), 58.0); + + // Test the default expansion animation. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle(duration: const Duration(milliseconds: 800)))); + await tester.pumpAndSettle(); + + // Test the overridden animation duration. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle(curve: Easing.emphasizedDecelerate))); + await tester.pumpAndSettle(); + + // Test the overridden animation curve. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(141.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(153, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + + // Test no animation. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle.noAnimation)); + + // Tap to expand the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pump(); + + expect(getHeight(expansionTileKey), 158.0); + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests diff --git a/packages/flutter/test/material/expansion_tile_theme_test.dart b/packages/flutter/test/material/expansion_tile_theme_test.dart index f21b0951564..076e1876809 100644 --- a/packages/flutter/test/material/expansion_tile_theme_test.dart +++ b/packages/flutter/test/material/expansion_tile_theme_test.dart @@ -68,6 +68,7 @@ void main() { expect(theme.shape, null); expect(theme.collapsedShape, null); expect(theme.clipBehavior, null); + expect(theme.expansionAnimationStyle, null); }); testWidgetsWithLeakTracking('Default ExpansionTileThemeData debugFillProperties', (WidgetTester tester) async { @@ -84,19 +85,20 @@ void main() { testWidgetsWithLeakTracking('ExpansionTileThemeData implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); - const ExpansionTileThemeData( - backgroundColor: Color(0xff000000), - collapsedBackgroundColor: Color(0xff6f83fc), - tilePadding: EdgeInsets.all(20.0), + ExpansionTileThemeData( + backgroundColor: const Color(0xff000000), + collapsedBackgroundColor: const Color(0xff6f83fc), + tilePadding: const EdgeInsets.all(20.0), expandedAlignment: Alignment.bottomCenter, - childrenPadding: EdgeInsets.all(10.0), - iconColor: Color(0xffa7c61c), - collapsedIconColor: Color(0xffdd0b1f), - textColor: Color(0xffffffff), - collapsedTextColor: Color(0xff522bab), - shape: Border(), - collapsedShape: Border(), + childrenPadding: const EdgeInsets.all(10.0), + iconColor: const Color(0xffa7c61c), + collapsedIconColor: const Color(0xffdd0b1f), + textColor: const Color(0xffffffff), + collapsedTextColor: const Color(0xff522bab), + shape: const Border(), + collapsedShape: const Border(), clipBehavior: Clip.antiAlias, + expansionAnimationStyle: AnimationStyle(curve: Curves.easeInOut), ).debugFillProperties(builder); final List description = builder.properties @@ -104,7 +106,7 @@ void main() { .map((DiagnosticsNode node) => node.toString()) .toList(); - expect(description, [ + expect(description, equalsIgnoringHashCodes([ 'backgroundColor: Color(0xff000000)', 'collapsedBackgroundColor: Color(0xff6f83fc)', 'tilePadding: EdgeInsets.all(20.0)', @@ -117,7 +119,8 @@ void main() { 'shape: Border.all(BorderSide(width: 0.0, style: none))', 'collapsedShape: Border.all(BorderSide(width: 0.0, style: none))', 'clipBehavior: Clip.antiAlias', - ]); + 'expansionAnimationStyle: AnimationStyle#983ac(curve: Cubic(0.42, 0.00, 0.58, 1.00))', + ])); }); testWidgetsWithLeakTracking('ExpansionTileTheme - collapsed', (WidgetTester tester) async { @@ -305,4 +308,109 @@ void main() { expect(childRect.right, paddingRect.right - 20); expect(childRect.bottom, paddingRect.bottom - 20); }); + + testWidgetsWithLeakTracking('Override ExpansionTile animation using ExpansionTileThemeData.AnimationStyle', (WidgetTester tester) async { + const Key expansionTileKey = Key('expansionTileKey'); + + Widget buildExpansionTile({ AnimationStyle? animationStyle }) { + return MaterialApp( + theme: ThemeData( + expansionTileTheme: ExpansionTileThemeData( + expansionAnimationStyle: animationStyle, + ), + ), + home: const Material( + child: Center( + child: ExpansionTile( + key: expansionTileKey, + title: TestText('title'), + children: [ + SizedBox(height: 100, width: 100), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildExpansionTile()); + + double getHeight(Key key) => tester.getSize(find.byKey(key)).height; + + // Test initial ExpansionTile height. + expect(getHeight(expansionTileKey), 58.0); + + // Test the default expansion animation. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle(duration: const Duration(milliseconds: 800)))); + await tester.pumpAndSettle(); + + // Test the overridden animation duration. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle(curve: Easing.emphasizedDecelerate))); + await tester.pumpAndSettle(); + + // Test the overridden animation curve. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(141.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(153, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + + // Test no animation. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle.noAnimation)); + + // Tap to expand the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pump(); + + expect(getHeight(expansionTileKey), 158.0); + }); }