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
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7 8 9 10 11 12 13 14
import 'package:flutter/widgets.dart';

import 'colors.dart';
import 'icons.dart';
import 'list_tile.dart';
import 'theme.dart';
import 'theme_data.dart';

15
const Duration _kExpand = Duration(milliseconds: 200);
16 17 18 19 20

/// 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
21
/// "expand / collapse" list entry. When used with scrolling widgets like
22 23 24
/// [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.
25 26 27 28 29 30 31 32 33
///
/// 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
34 35
  /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
  /// be non-null.
36 37 38 39
  const ExpansionTile({
    Key key,
    this.leading,
    @required this.title,
40
    this.subtitle,
41 42
    this.backgroundColor,
    this.onExpansionChanged,
43
    this.children = const <Widget>[],
44
    this.trailing,
45
    this.initiallyExpanded = false,
46
    this.maintainState = false,
47
    this.tilePadding,
48 49
    this.expandedCrossAxisAlignment,
    this.expandedAlignment,
50
    this.childrenPadding,
51
  }) : assert(initiallyExpanded != null),
52
       assert(maintainState != null),
53 54 55 56 57
       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.',
       ),
58
       super(key: key);
59 60 61 62 63 64 65 66 67 68 69

  /// A widget to display before the title.
  ///
  /// Typically a [CircleAvatar] widget.
  final Widget leading;

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

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

75 76 77
  /// Called when the tile expands or collapses.
  ///
  /// When the tile starts expanding, this function is called with the value
78
  /// true. When the tile starts collapsing, this function is called with
79 80 81 82 83 84 85 86 87 88
  /// 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;
89

90 91
  /// A widget to display instead of a rotating arrow icon.
  final Widget trailing;
92

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

96 97 98 99 100 101 102
  /// 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;

103 104 105 106 107 108 109 110 111
  /// 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;

112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
  /// 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;

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

149
  @override
150
  _ExpansionTileState createState() => _ExpansionTileState();
151 152 153
}

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

163 164
  AnimationController _controller;
  Animation<double> _iconTurns;
165 166 167 168 169
  Animation<double> _heightFactor;
  Animation<Color> _borderColor;
  Animation<Color> _headerColor;
  Animation<Color> _iconColor;
  Animation<Color> _backgroundColor;
170 171 172 173 174 175

  bool _isExpanded = false;

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

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

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

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

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

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

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

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

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

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