From 5bd0fb73f15999919e5b47bc5756437ba8335836 Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Thu, 1 May 2025 14:47:22 -0700 Subject: [PATCH] Implement CupertinoCollapsible/CupertinoExpansionTile (#165606) ### Flutter fade https://github.com/user-attachments/assets/0cfd3759-3e8d-4e5e-af55-20dfa1494bb5 ### Native iOS fade https://github.com/user-attachments/assets/ce8799d8-78bd-49e7-9d64-bb818fc6667e ### Flutter scroll https://github.com/user-attachments/assets/04291449-147c-4b8c-900e-c6e3989ef0e8 ### Native iOS scroll https://github.com/user-attachments/assets/a5917e64-6e67-45f0-a580-2fda80d16c32
Sample code ```dart import 'package:flutter/cupertino.dart'; void main() => runApp(const CollapsibleApp()); class CollapsibleApp extends StatelessWidget { const CollapsibleApp({super.key}); @override Widget build(BuildContext context) { return CupertinoApp( home: CupertinoPageScaffold( backgroundColor: CupertinoColors.systemGroupedBackground, child: CustomScrollView( slivers: [ CupertinoSliverNavigationBar( stretch: true, largeTitle: Text('Lists'), leading: CupertinoButton( alignment: Alignment.centerLeft, padding: EdgeInsets.zero, child: const Text('Edit'), onPressed: () {}, ), trailing: CupertinoButton( alignment: Alignment.centerRight, padding: EdgeInsets.zero, child: const Text('Add List'), onPressed: () {}, ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: const CollapsibleExample(), ), ), ], ), ), ); } } class CollapsibleExample extends StatefulWidget { const CollapsibleExample({super.key}); @override State createState() => _CollapsibleExampleState(); } class _CollapsibleExampleState extends State { @override Widget build(BuildContext context) { return CupertinoCollapsible( title: Text( ' iPhone', style: CupertinoTheme.of(context) .textTheme .navLargeTitleTextStyle .copyWith(fontSize: 21, fontWeight: FontWeight.w600), ), child: CupertinoListSection.insetGrouped( margin: EdgeInsets.symmetric(horizontal: 0.0), children: [ CupertinoListTile( title: const Text('All iPhone'), leading: Icon( CupertinoIcons.person_3, size: 30.0, ), trailing: const CupertinoListTileChevron(), additionalInfo: const Text('6'), onTap: () {}), CupertinoListTile( title: const Text('Friends'), leading: Icon(CupertinoIcons.person_2), trailing: const CupertinoListTileChevron(), additionalInfo: const Text('1'), onTap: () {}), CupertinoListTile( title: const Text('Work'), leading: Icon(CupertinoIcons.person_2), trailing: const CupertinoListTileChevron(), additionalInfo: const Text('0'), onTap: () {}), ], ), ); } } ```
Fixes https://github.com/flutter/flutter/issues/153937 --- packages/flutter/lib/cupertino.dart | 1 + .../lib/src/cupertino/expansion_tile.dart | 233 ++++++++++++++++++ .../lib/src/material/expansion_tile.dart | 6 +- .../test/cupertino/expansion_tile_test.dart | 214 ++++++++++++++++ 4 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 packages/flutter/lib/src/cupertino/expansion_tile.dart create mode 100644 packages/flutter/test/cupertino/expansion_tile_test.dart diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 593d8cb404a..0fc3e411e10 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -38,6 +38,7 @@ export 'src/cupertino/desktop_text_selection.dart'; export 'src/cupertino/desktop_text_selection_toolbar.dart'; export 'src/cupertino/desktop_text_selection_toolbar_button.dart'; export 'src/cupertino/dialog.dart'; +export 'src/cupertino/expansion_tile.dart'; export 'src/cupertino/form_row.dart'; export 'src/cupertino/form_section.dart'; export 'src/cupertino/icon_theme_data.dart'; diff --git a/packages/flutter/lib/src/cupertino/expansion_tile.dart b/packages/flutter/lib/src/cupertino/expansion_tile.dart new file mode 100644 index 00000000000..a477275cff1 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/expansion_tile.dart @@ -0,0 +1,233 @@ +// 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'; +/// +/// @docImport 'list_section.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'icons.dart'; +import 'list_tile.dart'; +import 'theme.dart'; + +/// The curve of the animation used to expand or collapse the +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const Curve _kAnimationCurve = Curves.easeInOut; + +/// The duration of the animation used to expand or collapse the +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const Duration _kAnimationDuration = Duration(milliseconds: 250); + +/// The font size of the rotating trailing icon in the header of a +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const double _kIconFontSize = 15.0; + +/// The height of the header in a [CupertinoExpansionTile], which is the default +/// [CupertinoListTile]. +const double _kHeaderHeight = 44.0; + +/// Defines how a [CupertinoExpansionTile] should transition its child between +/// its collapsed state and its expanded state. +enum ExpansionTileTransitionMode { + /// Transition by fading a fully extended [CupertinoExpansionTile.child]. + /// + /// When the [CupertinoExpansionTile] expands, the child appears fully extended + /// and fades into view. When the [CupertinoExpansionTile] collapses, the child + /// remains fully extended and fades out of view. + fade, + + /// Transition by scrolling [CupertinoExpansionTile.child] under the header. + /// + /// When the [CupertinoExpansionTile] expands, the child scrolls from under the + /// header until it becomes fully extended. When the [CupertinoExpansionTile] + /// collapses, the child scrolls under the header until it is fully collapsed. + scroll, +} + +/// A single-line [CupertinoListTile] with an expansion arrow icon that expands +/// or collapses the tile to reveal or hide the [child]. +/// +/// See also: +/// +/// * [ExpansionTile], the Material Design equivalent. +/// * [CupertinoListSection], useful for creating an expansion tile [child]. +/// * [CupertinoListTile], the header of a [CupertinoExpansionTile]. +/// * +class CupertinoExpansionTile extends StatefulWidget { + /// Creates a single-line [CupertinoListTile] with an expansion arrow icon + /// that expands or collapses the tile to reveal or hide the [child]. + const CupertinoExpansionTile({ + super.key, + required this.title, + required this.child, + this.controller, + this.transitionMode = ExpansionTileTransitionMode.fade, + }); + + /// Used to convey the central information. + /// + /// Usually a [Text]. + final Widget title; + + /// Programmatically expands and collapses the [CupertinoExpansionTile]. + /// + /// In cases where control over the tile's state is needed from a + /// callback triggered by a widget within the tile, [ExpansibleController.of] + /// may be more convenient than supplying a controller. + final ExpansibleController? controller; + + /// The body of the [CupertinoExpansionTile]. + final Widget child; + + /// How the [CupertinoExpansionTile] should transition its child between its + /// collapsed state and its expanded state. + /// + /// Defaults to [ExpansionTileTransitionMode.fade]. + final ExpansionTileTransitionMode transitionMode; + + @override + State createState() => _CupertinoExpansionTileState(); +} + +class _CupertinoExpansionTileState extends State { + final GlobalKey _headerKey = GlobalKey(); + final OverlayPortalController _fadeController = OverlayPortalController(); + static final Animatable _quarterTween = Tween(begin: 0.0, end: 0.25); + + late ExpansibleController _tileController; + late Animation _iconTurns; + + @override + void initState() { + super.initState(); + _tileController = widget.controller ?? ExpansibleController(); + } + + @override + void didUpdateWidget(CupertinoExpansionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (oldWidget.controller == null) { + _tileController.dispose(); + } + _tileController = widget.controller ?? ExpansibleController(); + } + } + + @override + void dispose() { + if (widget.controller == null) { + _tileController.dispose(); + } + super.dispose(); + } + + Widget? _buildIcon(BuildContext context, Animation animation) { + _iconTurns = animation.drive(_quarterTween.chain(CurveTween(curve: _kAnimationCurve))); + final double? size = CupertinoTheme.of(context).textTheme.textStyle.fontSize; + return RotationTransition( + turns: _iconTurns, + child: SizedBox( + width: size, + height: size, + child: const Center( + child: Icon( + CupertinoIcons.right_chevron, + color: CupertinoColors.activeBlue, + size: _kIconFontSize, + fontWeight: FontWeight.w900, + ), + ), + ), + ); + } + + void _onHeaderTap() { + if (_tileController.isExpanded) { + _tileController.collapse(); + } else { + _tileController.expand(); + } + _fadeController.show(); + } + + Widget _buildHeader(BuildContext context, Animation animation) { + return CupertinoListTile( + key: _headerKey, + onTap: _onHeaderTap, + title: widget.title, + trailing: _buildIcon(context, animation), + backgroundColorActivated: CupertinoColors.transparent, + ); + } + + Widget _buildExpansible( + BuildContext context, + Widget header, + Widget body, + Animation animation, + ) { + final Widget child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + header, + if (animation.isAnimating && widget.transitionMode == ExpansionTileTransitionMode.fade) + Opacity(opacity: 0.0, child: body) + else + body, + ], + ); + if (widget.transitionMode == ExpansionTileTransitionMode.scroll) { + return child; + } + assert(widget.transitionMode == ExpansionTileTransitionMode.fade); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return OverlayPortal( + controller: _fadeController, + overlayChildBuilder: (BuildContext context) { + final BuildContext headerContext = _headerKey.currentContext!; + final RenderBox overlay = + Overlay.of(headerContext).context.findRenderObject()! as RenderBox; + final RenderBox headerBox = headerContext.findRenderObject()! as RenderBox; + final Offset headerOffset = headerBox.localToGlobal(Offset.zero, ancestor: overlay); + return Positioned( + top: headerOffset.dy + _kHeaderHeight, + left: headerOffset.dx, + child: ConstrainedBox( + constraints: constraints, + child: Visibility( + visible: animation.isAnimating, + child: FadeTransition(opacity: animation, child: widget.child), + ), + ), + ); + }, + child: child, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Expansible( + controller: _tileController, + duration: _kAnimationDuration, + curve: _kAnimationCurve, + headerBuilder: _buildHeader, + bodyBuilder: (BuildContext context, Animation animation) => widget.child, + expansibleBuilder: _buildExpansible, + ); + } +} diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart index e5d438f8c02..b3995113979 100644 --- a/packages/flutter/lib/src/material/expansion_tile.dart +++ b/packages/flutter/lib/src/material/expansion_tile.dart @@ -394,9 +394,9 @@ class ExpansionTile extends StatefulWidget { /// 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. + /// In cases where control over the tile's state is needed from a callback + /// triggered by a widget within the tile, [ExpansibleController.of] may be + /// more convenient than supplying a controller. final ExpansionTileController? controller; /// {@macro flutter.material.ListTile.dense} diff --git a/packages/flutter/test/cupertino/expansion_tile_test.dart b/packages/flutter/test/cupertino/expansion_tile_test.dart new file mode 100644 index 00000000000..63260457145 --- /dev/null +++ b/packages/flutter/test/cupertino/expansion_tile_test.dart @@ -0,0 +1,214 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const Duration expansionDuration = Duration(milliseconds: 250); + const Duration infinitesimalDuration = Duration(microseconds: 1); + testWidgets('Toggles expansion on tap', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile(title: Text('Title'), child: Text('Content')), + ), + ), + ); + + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Title')); + await tester.pump(); + // The child animating its height and a clone fading in. + expect(find.text('Content'), findsNWidgets(2)); + + await tester.tap(find.text('Title')); + await tester.pump(); + expect(find.text('Content'), findsNothing); + }); + + testWidgets('Can be controlled by ExpansibleController', (WidgetTester tester) async { + final ExpansibleController controller = ExpansibleController(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + controller: controller, + title: const Text('Title'), + child: const Text('Content'), + ), + ), + ), + ); + + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.expand(); + await tester.pump(); + expect(controller.isExpanded, isTrue); + expect(find.text('Content'), findsOneWidget); + + controller.collapse(); + await tester.pump(); + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.dispose(); + }); + + testWidgets('Controller can set the tile to be initially expanded', (WidgetTester tester) async { + final ExpansibleController controller = ExpansibleController(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + controller: controller, + title: const Text('Title'), + child: const Text('Content'), + ), + ), + ), + ); + + controller.expand(); + await tester.pump(); + + expect(controller.isExpanded, isTrue); + expect(find.text('Content'), findsOneWidget); + + await tester.tap(find.text('Title')); + await tester.pump(); + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.dispose(); + }); + + testWidgets('Nested expansion tile', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + title: Text('Outer'), + child: CupertinoExpansionTile(title: Text('Inner'), child: Text('Content')), + ), + ), + ), + ); + + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Outer')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Inner')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsOneWidget); + + await tester.tap(find.text('Inner')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Outer')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + }); + + testWidgets('Default expansion animation and icon rotation', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoExpansionTile( + title: Text('Title'), + child: SizedBox(height: 50.0, child: ColoredBox(color: Color(0xffff0000))), + ), + ), + ), + ), + ); + + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.collapsed.png'), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.forward.png'), + ); + + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.expanded.png'), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.reverse.png'), + ); + }); + + testWidgets('Expansion animation in scroll mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoExpansionTile( + title: Text('Title'), + transitionMode: ExpansionTileTransitionMode.scroll, + child: SizedBox(height: 50.0, child: ColoredBox(color: Color(0xffff0000))), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.scroll_mode.forward.png'), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.scroll_mode.reverse.png'), + ); + await tester.pumpAndSettle(); + }); +}