mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
6dc16e8d0b
commit
5bd0fb73f1
@ -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';
|
||||
|
||||
233
packages/flutter/lib/src/cupertino/expansion_tile.dart
Normal file
233
packages/flutter/lib/src/cupertino/expansion_tile.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
214
packages/flutter/test/cupertino/expansion_tile_test.dart
Normal file
214
packages/flutter/test/cupertino/expansion_tile_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user