| // 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 'icons.dart'; |
| import 'list_tile.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 different configurations of ExpansionTile. |
| /// |
| /// ** 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({ |
| Key? 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.controlAffinity, |
| }) : assert(initiallyExpanded != null), |
| assert(maintainState != null), |
| 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.', |
| ), |
| super(key: key); |
| |
| /// A widget to display before the title. |
| /// |
| /// Typically a [CircleAvatar] widget. |
| /// |
| /// Note that 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. |
| final Color? backgroundColor; |
| |
| /// When not null, defines the background color of tile when the sublist is collapsed. |
| final Color? collapsedBackgroundColor; |
| |
| /// A widget to display after the title. |
| /// |
| /// Note that 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. |
| /// |
| /// When the value is null, the tile's padding is `EdgeInsets.symmetric(horizontal: 16.0)`. |
| 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]. |
| /// |
| /// When the value is null, the value of `expandedAlignment` is [Alignment.center]. |
| 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]. Note that the width of the [Column] that houses |
| /// [children] will be the same as the widest child widget in [children]. It is |
| /// not necessarily the width of [Column] is equal to the width of 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]. |
| /// |
| /// When the value is null, the value of `childrenPadding` is [EdgeInsets.zero]. |
| final EdgeInsetsGeometry? childrenPadding; |
| |
| /// The icon color of tile's expansion arrow icon when the sublist is expanded. |
| /// |
| /// Used to override to the [ListTileThemeData.iconColor]. |
| final Color? iconColor; |
| |
| /// The icon color of tile's expansion arrow icon when the sublist is collapsed. |
| /// |
| /// Used to override to the [ListTileThemeData.iconColor]. |
| final Color? collapsedIconColor; |
| |
| |
| /// The color of the tile's titles when the sublist is expanded. |
| /// |
| /// Used to override to the [ListTileThemeData.textColor]. |
| final Color? textColor; |
| |
| /// The color of the tile's titles when the sublist is collapsed. |
| /// |
| /// Used to override to the [ListTileThemeData.textColor]. |
| final Color? collapsedTextColor; |
| |
| /// 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 ColorTween _borderColorTween = ColorTween(); |
| 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<Color?> _borderColor; |
| 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)); |
| _borderColor = _controller.drive(_borderColorTween.chain(_easeOutTween)); |
| _headerColor = _controller.drive(_headerColorTween.chain(_easeInTween)); |
| _iconColor = _controller.drive(_iconColorTween.chain(_easeInTween)); |
| _backgroundColor = _controller.drive(_backgroundColorTween.chain(_easeOutTween)); |
| |
| _isExpanded = PageStorage.of(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.of(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 Color borderSideColor = _borderColor.value ?? Colors.transparent; |
| |
| return Container( |
| decoration: BoxDecoration( |
| color: _backgroundColor.value ?? Colors.transparent, |
| border: Border( |
| top: BorderSide(color: borderSideColor), |
| bottom: BorderSide(color: borderSideColor), |
| ), |
| ), |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| ListTileTheme.merge( |
| iconColor: _iconColor.value, |
| textColor: _headerColor.value, |
| child: ListTile( |
| onTap: _handleTap, |
| contentPadding: widget.tilePadding, |
| leading: widget.leading ?? _buildLeadingIcon(context), |
| title: widget.title, |
| subtitle: widget.subtitle, |
| trailing: widget.trailing ?? _buildTrailingIcon(context), |
| ), |
| ), |
| ClipRect( |
| child: Align( |
| alignment: widget.expandedAlignment ?? Alignment.center, |
| heightFactor: _heightFactor.value, |
| child: child, |
| ), |
| ), |
| ], |
| ), |
| ); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| final ThemeData theme = Theme.of(context); |
| final ColorScheme colorScheme = theme.colorScheme; |
| _borderColorTween.end = theme.dividerColor; |
| _headerColorTween |
| ..begin = widget.collapsedTextColor ?? theme.textTheme.subtitle1!.color |
| ..end = widget.textColor ?? colorScheme.primary; |
| _iconColorTween |
| ..begin = widget.collapsedIconColor ?? theme.unselectedWidgetColor |
| ..end = widget.iconColor ?? colorScheme.primary; |
| _backgroundColorTween |
| ..begin = widget.collapsedBackgroundColor |
| ..end = widget.backgroundColor; |
| super.didChangeDependencies(); |
| } |
| |
| @override |
| Widget build(BuildContext 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 ?? EdgeInsets.zero, |
| child: Column( |
| crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center, |
| children: widget.children, |
| ), |
| ), |
| ), |
| ); |
| |
| return AnimatedBuilder( |
| animation: _controller.view, |
| builder: _buildChildren, |
| child: shouldRemoveChildren ? null : result, |
| ); |
| } |
| } |