// 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. import 'package:flutter/widgets.dart'; import 'color_scheme.dart'; import 'colors.dart'; import 'expansion_tile_theme.dart'; import 'icons.dart'; import 'list_tile.dart'; import 'list_tile_theme.dart'; import 'material.dart'; import 'theme.dart'; const Duration _kExpand = Duration(milliseconds: 200); /// A single-line [ListTile] with an expansion arrow icon that expands or collapses /// the tile to reveal or hide the [children]. /// /// This widget is typically used with [ListView] to create an /// "expand / collapse" list entry. When used with scrolling widgets like /// [ListView], a unique [PageStorageKey] must be specified to enable the /// [ExpansionTile] to save and restore its expanded state when it is scrolled /// in and out of view. /// /// This class overrides the [ListTileThemeData.iconColor] and [ListTileThemeData.textColor] /// theme properties for its [ListTile]. These colors animate between values when /// the tile is expanded and collapsed: between [iconColor], [collapsedIconColor] and /// between [textColor] and [collapsedTextColor]. /// /// The expansion arrow icon is shown on the right by default in left-to-right languages /// (i.e. the trailing edge). This can be changed using [controlAffinity]. This maps /// to the [leading] and [trailing] properties of [ExpansionTile]. /// /// {@tool dartpad} /// This example demonstrates how the [ExpansionTile] icon's location and appearance /// can be customized. /// /// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.0.dart ** /// {@end-tool} /// /// See also: /// /// * [ListTile], useful for creating expansion tile [children] when the /// expansion tile represents a sublist. /// * The "Expand and collapse" section of /// <https://material.io/components/lists#types> class ExpansionTile extends StatefulWidget { /// Creates a single-line [ListTile] with an expansion arrow icon that expands or collapses /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must /// be non-null. const ExpansionTile({ super.key, this.leading, required this.title, this.subtitle, this.onExpansionChanged, this.children = const <Widget>[], this.trailing, this.initiallyExpanded = false, this.maintainState = false, this.tilePadding, this.expandedCrossAxisAlignment, this.expandedAlignment, this.childrenPadding, this.backgroundColor, this.collapsedBackgroundColor, this.textColor, this.collapsedTextColor, this.iconColor, this.collapsedIconColor, this.shape, this.collapsedShape, this.clipBehavior, this.controlAffinity, }) : assert( expandedCrossAxisAlignment != CrossAxisAlignment.baseline, 'CrossAxisAlignment.baseline is not supported since the expanded children ' 'are aligned in a column, not a row. Try to use another constant.', ); /// A widget to display before the title. /// /// Typically a [CircleAvatar] widget. /// /// Depending on the value of [controlAffinity], the [leading] widget /// may replace the rotating expansion arrow icon. final Widget? leading; /// The primary content of the list item. /// /// Typically a [Text] widget. final Widget title; /// Additional content displayed below the title. /// /// Typically a [Text] widget. final Widget? subtitle; /// Called when the tile expands or collapses. /// /// When the tile starts expanding, this function is called with the value /// true. When the tile starts collapsing, this function is called with /// the value false. final ValueChanged<bool>? onExpansionChanged; /// The widgets that are displayed when the tile expands. /// /// Typically [ListTile] widgets. final List<Widget> children; /// The color to display behind the sublist when expanded. /// /// If this property is null then [ExpansionTileThemeData.backgroundColor] is used. If that /// is also null then Colors.transparent is used. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? backgroundColor; /// When not null, defines the background color of tile when the sublist is collapsed. /// /// If this property is null then [ExpansionTileThemeData.collapsedBackgroundColor] is used. /// If that is also null then Colors.transparent is used. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? collapsedBackgroundColor; /// A widget to display after the title. /// /// Depending on the value of [controlAffinity], the [trailing] widget /// may replace the rotating expansion arrow icon. final Widget? trailing; /// Specifies if the list tile is initially expanded (true) or collapsed (false, the default). final bool initiallyExpanded; /// Specifies whether the state of the children is maintained when the tile expands and collapses. /// /// When true, the children are kept in the tree while the tile is collapsed. /// When false (default), the children are removed from the tree when the tile is /// collapsed and recreated upon expansion. final bool maintainState; /// Specifies padding for the [ListTile]. /// /// Analogous to [ListTile.contentPadding], this property defines the insets for /// the [leading], [title], [subtitle] and [trailing] widgets. It does not inset /// the expanded [children] widgets. /// /// If this property is null then [ExpansionTileThemeData.tilePadding] is used. If that /// is also null then the tile's padding is `EdgeInsets.symmetric(horizontal: 16.0)`. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final EdgeInsetsGeometry? tilePadding; /// Specifies the alignment of [children], which are arranged in a column when /// the tile is expanded. /// /// The internals of the expanded tile make use of a [Column] widget for /// [children], and [Align] widget to align the column. The [expandedAlignment] /// parameter is passed directly into the [Align]. /// /// Modifying this property controls the alignment of the column within the /// expanded tile, not the alignment of [children] widgets within the column. /// To align each child within [children], see [expandedCrossAxisAlignment]. /// /// The width of the column is the width of the widest child widget in [children]. /// /// If this property is null then [ExpansionTileThemeData.expandedAlignment]is used. If that /// is also null then the value of [expandedAlignment] is [Alignment.center]. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Alignment? expandedAlignment; /// Specifies the alignment of each child within [children] when the tile is expanded. /// /// The internals of the expanded tile make use of a [Column] widget for /// [children], and the `crossAxisAlignment` parameter is passed directly into /// the [Column]. /// /// Modifying this property controls the cross axis alignment of each child /// within its [Column]. The width of the [Column] that houses [children] will /// be the same as the widest child widget in [children]. The width of the /// [Column] might not be equal to the width of the expanded tile. /// /// To align the [Column] along the expanded tile, use the [expandedAlignment] /// property instead. /// /// When the value is null, the value of [expandedCrossAxisAlignment] is /// [CrossAxisAlignment.center]. final CrossAxisAlignment? expandedCrossAxisAlignment; /// Specifies padding for [children]. /// /// If this property is null then [ExpansionTileThemeData.childrenPadding] is used. If that /// is also null then the value of [childrenPadding] is [EdgeInsets.zero]. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final EdgeInsetsGeometry? childrenPadding; /// The icon color of tile's expansion arrow icon when the sublist is expanded. /// /// Used to override to the [ListTileThemeData.iconColor]. /// /// If this property is null then [ExpansionTileThemeData.iconColor] is used. If that /// is also null then the value of [ColorScheme.primary] is used. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? iconColor; /// The icon color of tile's expansion arrow icon when the sublist is collapsed. /// /// Used to override to the [ListTileThemeData.iconColor]. /// /// If this property is null then [ExpansionTileThemeData.collapsedIconColor] is used. If that /// is also null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurface] is used. Otherwise, /// defaults to [ThemeData.unselectedWidgetColor] color. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? collapsedIconColor; /// The color of the tile's titles when the sublist is expanded. /// /// Used to override to the [ListTileThemeData.textColor]. /// /// If this property is null then [ExpansionTileThemeData.textColor] is used. If that /// is also null then and [ThemeData.useMaterial3] is true, color of the [TextTheme.bodyLarge] /// will be used for the [title] and [subtitle]. Otherwise, defaults to [ColorScheme.primary] color. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? textColor; /// The color of the tile's titles when the sublist is collapsed. /// /// Used to override to the [ListTileThemeData.textColor]. /// /// If this property is null then [ExpansionTileThemeData.collapsedTextColor] is used. /// If that is also null and [ThemeData.useMaterial3] is true, color of the /// [TextTheme.bodyLarge] will be used for the [title] and [subtitle]. Otherwise, /// defaults to color of the [TextTheme.titleMedium]. /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Color? collapsedTextColor; /// The tile's border shape when the sublist is expanded. /// /// If this property is null, the [ExpansionTileThemeData.shape] is used. If that /// is also null, a [Border] with vertical sides default to [ThemeData.dividerColor] is used /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final ShapeBorder? shape; /// The tile's border shape when the sublist is collapsed. /// /// If this property is null, the [ExpansionTileThemeData.collapsedShape] is used. If that /// is also null, a [Border] with vertical sides default to Color [Colors.transparent] is used /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final ShapeBorder? collapsedShape; /// {@macro flutter.material.Material.clipBehavior} /// /// If this property is null, the [ExpansionTileThemeData.clipBehavior] is used. If that /// is also null, a [Clip.none] is used /// /// See also: /// /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s /// [ExpansionTileThemeData]. final Clip? clipBehavior; /// Typically used to force the expansion arrow icon to the tile's leading or trailing edge. /// /// By default, the value of [controlAffinity] is [ListTileControlAffinity.platform], /// which means that the expansion arrow icon will appear on the tile's trailing edge. final ListTileControlAffinity? controlAffinity; @override State<ExpansionTile> createState() => _ExpansionTileState(); } class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProviderStateMixin { static final Animatable<double> _easeOutTween = CurveTween(curve: Curves.easeOut); static final Animatable<double> _easeInTween = CurveTween(curve: Curves.easeIn); static final Animatable<double> _halfTween = Tween<double>(begin: 0.0, end: 0.5); final ShapeBorderTween _borderTween = ShapeBorderTween(); final ColorTween _headerColorTween = ColorTween(); final ColorTween _iconColorTween = ColorTween(); final ColorTween _backgroundColorTween = ColorTween(); late AnimationController _controller; late Animation<double> _iconTurns; late Animation<double> _heightFactor; late Animation<ShapeBorder?> _border; late Animation<Color?> _headerColor; late Animation<Color?> _iconColor; late Animation<Color?> _backgroundColor; bool _isExpanded = false; @override void initState() { super.initState(); _controller = AnimationController(duration: _kExpand, vsync: this); _heightFactor = _controller.drive(_easeInTween); _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); _border = _controller.drive(_borderTween.chain(_easeOutTween)); _headerColor = _controller.drive(_headerColorTween.chain(_easeInTween)); _iconColor = _controller.drive(_iconColorTween.chain(_easeInTween)); _backgroundColor = _controller.drive(_backgroundColorTween.chain(_easeOutTween)); _isExpanded = PageStorage.maybeOf(context)?.readState(context) as bool? ?? widget.initiallyExpanded; if (_isExpanded) { _controller.value = 1.0; } } @override void dispose() { _controller.dispose(); super.dispose(); } void _handleTap() { setState(() { _isExpanded = !_isExpanded; if (_isExpanded) { _controller.forward(); } else { _controller.reverse().then<void>((void value) { if (!mounted) { return; } setState(() { // Rebuild without widget.children. }); }); } PageStorage.maybeOf(context)?.writeState(context, _isExpanded); }); widget.onExpansionChanged?.call(_isExpanded); } // Platform or null affinity defaults to trailing. ListTileControlAffinity _effectiveAffinity(ListTileControlAffinity? affinity) { switch (affinity ?? ListTileControlAffinity.trailing) { case ListTileControlAffinity.leading: return ListTileControlAffinity.leading; case ListTileControlAffinity.trailing: case ListTileControlAffinity.platform: return ListTileControlAffinity.trailing; } } Widget? _buildIcon(BuildContext context) { return RotationTransition( turns: _iconTurns, child: const Icon(Icons.expand_more), ); } Widget? _buildLeadingIcon(BuildContext context) { if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.leading) { return null; } return _buildIcon(context); } Widget? _buildTrailingIcon(BuildContext context) { if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.trailing) { return null; } return _buildIcon(context); } Widget _buildChildren(BuildContext context, Widget? child) { final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context); final ShapeBorder expansionTileBorder = _border.value ?? const Border( top: BorderSide(color: Colors.transparent), bottom: BorderSide(color: Colors.transparent), ); final Clip clipBehavior = widget.clipBehavior ?? expansionTileTheme.clipBehavior ?? Clip.none; return Container( clipBehavior: clipBehavior, decoration: ShapeDecoration( color: _backgroundColor.value ?? expansionTileTheme.backgroundColor ?? Colors.transparent, shape: expansionTileBorder, ), child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ ListTileTheme.merge( iconColor: _iconColor.value ?? expansionTileTheme.iconColor, textColor: _headerColor.value, child: ListTile( onTap: _handleTap, contentPadding: widget.tilePadding ?? expansionTileTheme.tilePadding, leading: widget.leading ?? _buildLeadingIcon(context), title: widget.title, subtitle: widget.subtitle, trailing: widget.trailing ?? _buildTrailingIcon(context), ), ), ClipRect( child: Align( alignment: widget.expandedAlignment ?? expansionTileTheme.expandedAlignment ?? Alignment.center, heightFactor: _heightFactor.value, child: child, ), ), ], ), ); } @override void didChangeDependencies() { final ThemeData theme = Theme.of(context); final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context); final ExpansionTileThemeData defaults = theme.useMaterial3 ? _ExpansionTileDefaultsM3(context) : _ExpansionTileDefaultsM2(context); _borderTween ..begin = widget.collapsedShape ?? expansionTileTheme.collapsedShape ?? const Border( top: BorderSide(color: Colors.transparent), bottom: BorderSide(color: Colors.transparent), ) ..end = widget.shape ?? expansionTileTheme.collapsedShape ?? Border( top: BorderSide(color: theme.dividerColor), bottom: BorderSide(color: theme.dividerColor), ); _headerColorTween ..begin = widget.collapsedTextColor ?? expansionTileTheme.collapsedTextColor ?? defaults.collapsedTextColor ..end = widget.textColor ?? expansionTileTheme.textColor ?? defaults.textColor; _iconColorTween ..begin = widget.collapsedIconColor ?? expansionTileTheme.collapsedIconColor ?? defaults.collapsedIconColor ..end = widget.iconColor ?? expansionTileTheme.iconColor ?? defaults.iconColor; _backgroundColorTween ..begin = widget.collapsedBackgroundColor ?? expansionTileTheme.collapsedBackgroundColor ..end = widget.backgroundColor ?? expansionTileTheme.backgroundColor; super.didChangeDependencies(); } @override Widget build(BuildContext context) { final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context); final bool closed = !_isExpanded && _controller.isDismissed; final bool shouldRemoveChildren = closed && !widget.maintainState; final Widget result = Offstage( offstage: closed, child: TickerMode( enabled: !closed, child: Padding( padding: widget.childrenPadding ?? expansionTileTheme.childrenPadding ?? EdgeInsets.zero, child: Column( crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center, children: widget.children, ), ), ), ); return AnimatedBuilder( animation: _controller.view, builder: _buildChildren, child: shouldRemoveChildren ? null : result, ); } } class _ExpansionTileDefaultsM2 extends ExpansionTileThemeData { _ExpansionTileDefaultsM2(this.context); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colorScheme = _theme.colorScheme; @override Color? get textColor => _colorScheme.primary; @override Color? get iconColor => _colorScheme.primary; @override Color? get collapsedTextColor => _theme.textTheme.titleMedium!.color; @override Color? get collapsedIconColor => _theme.unselectedWidgetColor; } // BEGIN GENERATED TOKEN PROPERTIES - ExpansionTile // Do not edit by hand. The code between the "BEGIN GENERATED" and // "END GENERATED" comments are generated from data in the Material // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. // Token database version: v0_162 class _ExpansionTileDefaultsM3 extends ExpansionTileThemeData { _ExpansionTileDefaultsM3(this.context); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colors = _theme.colorScheme; @override Color? get textColor => _colors.onSurface; @override Color? get iconColor => _colors.primary; @override Color? get collapsedTextColor => _colors.onSurface; @override Color? get collapsedIconColor => _colors.onSurfaceVariant; } // END GENERATED TOKEN PROPERTIES - ExpansionTile