blob: cc9257ffe6e21e89321e14910cc9cc531982918c [file] [log] [blame]
// 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/foundation.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'icons.dart';
import 'list_tile.dart';
import 'localizations.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].
///
/// {@tool dartpad}
/// This example shows how to use [CupertinoExpansionTile] with different transition modes.
///
/// ** See code in examples/api/lib/cupertino/expansion_tile/cupertino_expansion_tile.0.dart **
/// {@end-tool}
///
/// 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) {
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final String onTapHint = _tileController.isExpanded
? localizations.expansionTileExpandedTapHint
: localizations.expansionTileCollapsedTapHint;
String? semanticsHint;
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
semanticsHint = _tileController.isExpanded
? '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}'
: '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}';
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
break;
}
return Semantics(
hint: semanticsHint,
onTapHint: onTapHint,
child: 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 overlay = Overlay.of(headerContext).context.findRenderObject()! as RenderBox;
final 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,
);
}
}