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



<details>
<summary>Sample code</summary>

```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<CollapsibleExample> createState() => _CollapsibleExampleState();
}

class _CollapsibleExampleState extends State<CollapsibleExample> {
  @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>[
          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: () {}),
        ],
      ),
    );
  }
}
```

</details>

Fixes https://github.com/flutter/flutter/issues/153937
This commit is contained in:
Victor Sanni 2025-05-01 14:47:22 -07:00 committed by GitHub
parent 6dc16e8d0b
commit 5bd0fb73f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 451 additions and 3 deletions

View File

@ -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';

View File

@ -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].
/// * <https://developer.apple.com/design/human-interface-guidelines/disclosure-controls/>
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<CupertinoExpansionTile> createState() => _CupertinoExpansionTileState();
}
class _CupertinoExpansionTileState extends State<CupertinoExpansionTile> {
final GlobalKey _headerKey = GlobalKey();
final OverlayPortalController _fadeController = OverlayPortalController();
static final Animatable<double> _quarterTween = Tween<double>(begin: 0.0, end: 0.25);
late ExpansibleController _tileController;
late Animation<double> _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<double> 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<double> 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<double> animation,
) {
final Widget child = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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<double> animation) => widget.child,
expansibleBuilder: _buildExpansible,
);
}
}

View File

@ -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}

View File

@ -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(<String>['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();
});
}