expansion_tile.dart 10.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12
// 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 'colors.dart';
import 'icons.dart';
import 'list_tile.dart';
import 'theme.dart';
import 'theme_data.dart';

13
const Duration _kExpand = Duration(milliseconds: 200);
14 15 16 17 18

/// A single-line [ListTile] with a trailing button that expands or collapses
/// the tile to reveal or hide the [children].
///
/// This widget is typically used with [ListView] to create an
19
/// "expand / collapse" list entry. When used with scrolling widgets like
20 21 22
/// [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.
23 24 25 26 27 28 29 30 31
///
/// See also:
///
///  * [ListTile], useful for creating expansion tile [children] when the
///    expansion tile represents a sublist.
///  * The "Expand/collapse" section of
///    <https://material.io/guidelines/components/lists-controls.html>.
class ExpansionTile extends StatefulWidget {
  /// Creates a single-line [ListTile] with a trailing button that expands or collapses
32 33
  /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
  /// be non-null.
34
  const ExpansionTile({
35
    Key? key,
36
    this.leading,
37
    required this.title,
38
    this.subtitle,
39 40
    this.backgroundColor,
    this.onExpansionChanged,
41
    this.children = const <Widget>[],
42
    this.trailing,
43
    this.initiallyExpanded = false,
44
    this.maintainState = false,
45
    this.tilePadding,
46 47
    this.expandedCrossAxisAlignment,
    this.expandedAlignment,
48
    this.childrenPadding,
49
  }) : assert(initiallyExpanded != null),
50
       assert(maintainState != null),
51 52 53 54 55
       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.',
       ),
56
       super(key: key);
57 58 59 60

  /// A widget to display before the title.
  ///
  /// Typically a [CircleAvatar] widget.
61
  final Widget? leading;
62 63 64 65 66 67

  /// The primary content of the list item.
  ///
  /// Typically a [Text] widget.
  final Widget title;

68 69 70
  /// Additional content displayed below the title.
  ///
  /// Typically a [Text] widget.
71
  final Widget? subtitle;
72

73 74 75
  /// Called when the tile expands or collapses.
  ///
  /// When the tile starts expanding, this function is called with the value
76
  /// true. When the tile starts collapsing, this function is called with
77
  /// the value false.
78
  final ValueChanged<bool>? onExpansionChanged;
79 80 81 82 83 84 85

  /// 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.
86
  final Color? backgroundColor;
87

88
  /// A widget to display instead of a rotating arrow icon.
89
  final Widget? trailing;
90

91 92 93
  /// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
  final bool initiallyExpanded;

94 95 96 97 98 99 100
  /// 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;

101 102 103 104 105 106 107
  /// 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)`.
108
  final EdgeInsetsGeometry? tilePadding;
109

110 111 112 113 114 115 116 117 118 119 120 121 122 123
  /// 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].
124
  final Alignment? expandedAlignment;
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139

  /// 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].
140
  final CrossAxisAlignment? expandedCrossAxisAlignment;
141

142 143 144
  /// Specifies padding for [children].
  ///
  /// When the value is null, the value of `childrenPadding` is [EdgeInsets.zero].
145
  final EdgeInsetsGeometry? childrenPadding;
146

147
  @override
148
  _ExpansionTileState createState() => _ExpansionTileState();
149 150 151
}

class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProviderStateMixin {
152 153 154 155 156 157 158 159 160
  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();

161 162 163 164 165 166 167
  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;
168 169 170 171 172 173

  bool _isExpanded = false;

  @override
  void initState() {
    super.initState();
174
    _controller = AnimationController(duration: _kExpand, vsync: this);
175 176 177 178 179 180
    _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));
181

182
    _isExpanded = PageStorage.of(context)?.readState(context) as bool? ?? widget.initiallyExpanded;
183 184 185 186 187 188 189 190 191 192 193 194 195
    if (_isExpanded)
      _controller.value = 1.0;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _handleTap() {
    setState(() {
      _isExpanded = !_isExpanded;
196
      if (_isExpanded) {
197
        _controller.forward();
198 199 200 201
      } else {
        _controller.reverse().then<void>((void value) {
          if (!mounted)
            return;
202 203 204 205
          setState(() {
            // Rebuild without widget.children.
          });
        });
206
      }
207 208 209
      PageStorage.of(context)?.writeState(context, _isExpanded);
    });
    if (widget.onExpansionChanged != null)
210
      widget.onExpansionChanged!(_isExpanded);
211 212
  }

213
  Widget _buildChildren(BuildContext context, Widget? child) {
214
    final Color borderSideColor = _borderColor.value ?? Colors.transparent;
215

216 217
    return Container(
      decoration: BoxDecoration(
218
        color: _backgroundColor.value ?? Colors.transparent,
219 220 221
        border: Border(
          top: BorderSide(color: borderSideColor),
          bottom: BorderSide(color: borderSideColor),
222
        ),
223
      ),
224
      child: Column(
225 226
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
227 228 229
          ListTileTheme.merge(
            iconColor: _iconColor.value,
            textColor: _headerColor.value,
230
            child: ListTile(
231
              onTap: _handleTap,
232
              contentPadding: widget.tilePadding,
233
              leading: widget.leading,
234
              title: widget.title,
235
              subtitle: widget.subtitle,
236
              trailing: widget.trailing ?? RotationTransition(
237 238 239 240 241
                turns: _iconTurns,
                child: const Icon(Icons.expand_more),
              ),
            ),
          ),
242 243
          ClipRect(
            child: Align(
244
              alignment: widget.expandedAlignment ?? Alignment.center,
245
              heightFactor: _heightFactor.value,
246 247 248 249 250 251 252 253 254
              child: child,
            ),
          ),
        ],
      ),
    );
  }

  @override
255
  void didChangeDependencies() {
256
    final ThemeData theme = Theme.of(context)!;
257
    _borderColorTween.end = theme.dividerColor;
258
    _headerColorTween
259
      ..begin = theme.textTheme.subtitle1!.color
260
      ..end = theme.accentColor;
261
    _iconColorTween
262 263
      ..begin = theme.unselectedWidgetColor
      ..end = theme.accentColor;
264
    _backgroundColorTween.end = widget.backgroundColor;
265 266
    super.didChangeDependencies();
  }
267

268 269
  @override
  Widget build(BuildContext context) {
270
    final bool closed = !_isExpanded && _controller.isDismissed;
271 272 273 274
    final bool shouldRemoveChildren = closed && !widget.maintainState;

    final Widget result = Offstage(
      child: TickerMode(
275 276 277 278 279 280
        child: Padding(
          padding: widget.childrenPadding ?? EdgeInsets.zero,
          child: Column(
            crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
            children: widget.children,
          ),
281 282 283 284 285 286
        ),
        enabled: !closed,
      ),
      offstage: closed
    );

287
    return AnimatedBuilder(
288 289
      animation: _controller.view,
      builder: _buildChildren,
290
      child: shouldRemoveChildren ? null : result,
291 292 293
    );
  }
}