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();
+ });
+}