expansion_tile.dart 16.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// 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';

7
import 'color_scheme.dart';
8
import 'colors.dart';
9
import 'expansion_tile_theme.dart';
10 11
import 'icons.dart';
import 'list_tile.dart';
12
import 'list_tile_theme.dart';
13 14
import 'theme.dart';

15
const Duration _kExpand = Duration(milliseconds: 200);
16

17
/// A single-line [ListTile] with an expansion arrow icon that expands or collapses
18 19 20
/// 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
/// This class overrides the [ListTileThemeData.iconColor] and [ListTileThemeData.textColor]
27 28 29 30
/// 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].
///
31 32 33 34
/// 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].
///
35
/// {@tool dartpad}
36 37
/// This example demonstrates different configurations of ExpansionTile.
///
38
/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.0.dart **
39 40
/// {@end-tool}
///
41 42 43 44
/// See also:
///
///  * [ListTile], useful for creating expansion tile [children] when the
///    expansion tile represents a sublist.
45 46
///  * The "Expand and collapse" section of
///    <https://material.io/components/lists#types>
47
class ExpansionTile extends StatefulWidget {
48
  /// Creates a single-line [ListTile] with an expansion arrow icon that expands or collapses
49 50
  /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
  /// be non-null.
51
  const ExpansionTile({
52
    Key? key,
53
    this.leading,
54
    required this.title,
55
    this.subtitle,
56
    this.onExpansionChanged,
57
    this.children = const <Widget>[],
58
    this.trailing,
59
    this.initiallyExpanded = false,
60
    this.maintainState = false,
61
    this.tilePadding,
62 63
    this.expandedCrossAxisAlignment,
    this.expandedAlignment,
64
    this.childrenPadding,
65
    this.backgroundColor,
66
    this.collapsedBackgroundColor,
67 68 69 70
    this.textColor,
    this.collapsedTextColor,
    this.iconColor,
    this.collapsedIconColor,
71
    this.controlAffinity,
72
  }) : assert(initiallyExpanded != null),
73
       assert(maintainState != null),
74 75 76 77 78
       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.',
       ),
79
       super(key: key);
80 81 82 83

  /// A widget to display before the title.
  ///
  /// Typically a [CircleAvatar] widget.
84 85 86
  ///
  /// Note that depending on the value of [controlAffinity], the [leading] widget
  /// may replace the rotating expansion arrow icon.
87
  final Widget? leading;
88 89 90 91 92 93

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

94 95 96
  /// Additional content displayed below the title.
  ///
  /// Typically a [Text] widget.
97
  final Widget? subtitle;
98

99 100 101
  /// Called when the tile expands or collapses.
  ///
  /// When the tile starts expanding, this function is called with the value
102
  /// true. When the tile starts collapsing, this function is called with
103
  /// the value false.
104
  final ValueChanged<bool>? onExpansionChanged;
105 106 107 108 109 110 111

  /// 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.
112 113 114 115 116 117 118 119
  ///
  /// 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].
120
  final Color? backgroundColor;
121

122
  /// When not null, defines the background color of tile when the sublist is collapsed.
123 124 125 126 127 128 129 130
  ///
  /// 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].
131 132
  final Color? collapsedBackgroundColor;

133 134 135 136
  /// 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.
137
  final Widget? trailing;
138

139 140 141
  /// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
  final bool initiallyExpanded;

142 143 144 145 146 147 148
  /// 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;

149 150 151 152 153 154
  /// 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.
  ///
155 156 157 158 159 160 161
  /// 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].
162
  final EdgeInsetsGeometry? tilePadding;
163

164 165 166 167 168 169 170 171 172 173 174 175 176
  /// 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].
  ///
177 178 179 180 181 182 183
  /// 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].
184
  final Alignment? expandedAlignment;
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199

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

202 203
  /// Specifies padding for [children].
  ///
204 205 206 207 208 209 210
  /// 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].
211
  final EdgeInsetsGeometry? childrenPadding;
212

213
  /// The icon color of tile's expansion arrow icon when the sublist is expanded.
214
  ///
215
  /// Used to override to the [ListTileThemeData.iconColor].
216 217 218 219 220 221 222 223
  ///
  /// If this property is null then [ExpansionTileThemeData.iconColor] is used. If that
  /// is also null then the value of [ListTileThemeData.iconColor] is used.
  ///
  /// See also:
  ///
  /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s
  ///   [ExpansionTileThemeData].
224 225
  final Color? iconColor;

226
  /// The icon color of tile's expansion arrow icon when the sublist is collapsed.
227
  ///
228
  /// Used to override to the [ListTileThemeData.iconColor].
229 230 231 232 233
  final Color? collapsedIconColor;


  /// The color of the tile's titles when the sublist is expanded.
  ///
234
  /// Used to override to the [ListTileThemeData.textColor].
235 236 237 238 239 240 241 242
  ///
  /// If this property is null then [ExpansionTileThemeData.textColor] is used. If that
  /// is also null then the value of [ListTileThemeData.textColor] is used.
  ///
  /// See also:
  ///
  /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s
  ///   [ExpansionTileThemeData].
243 244 245 246
  final Color? textColor;

  /// The color of the tile's titles when the sublist is collapsed.
  ///
247
  /// Used to override to the [ListTileThemeData.textColor].
248 249 250 251 252 253 254 255
  ///
  /// If this property is null then [ExpansionTileThemeData.collapsedTextColor] is used. If that
  /// is also null then the value of [ListTileThemeData.textColor] is used.
  ///
  /// See also:
  ///
  /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s
  ///   [ExpansionTileThemeData].
256 257
  final Color? collapsedTextColor;

258 259 260 261 262 263
  /// 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;

264
  @override
265
  State<ExpansionTile> createState() => _ExpansionTileState();
266 267 268
}

class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProviderStateMixin {
269 270 271 272 273 274 275 276 277
  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();

278 279 280 281 282 283 284
  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;
285 286 287 288 289 290

  bool _isExpanded = false;

  @override
  void initState() {
    super.initState();
291
    _controller = AnimationController(duration: _kExpand, vsync: this);
292 293 294 295 296 297
    _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));
298

299
    _isExpanded = PageStorage.of(context)?.readState(context) as bool? ?? widget.initiallyExpanded;
300 301 302 303 304 305 306 307 308 309 310 311 312
    if (_isExpanded)
      _controller.value = 1.0;
  }

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

  void _handleTap() {
    setState(() {
      _isExpanded = !_isExpanded;
313
      if (_isExpanded) {
314
        _controller.forward();
315 316 317 318
      } else {
        _controller.reverse().then<void>((void value) {
          if (!mounted)
            return;
319 320 321 322
          setState(() {
            // Rebuild without widget.children.
          });
        });
323
      }
324 325
      PageStorage.of(context)?.writeState(context, _isExpanded);
    });
326
    widget.onExpansionChanged?.call(_isExpanded);
327 328
  }

329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
  // 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);
  }

359
  Widget _buildChildren(BuildContext context, Widget? child) {
360
    final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
361
    final Color borderSideColor = _borderColor.value ?? Colors.transparent;
362

363 364
    return Container(
      decoration: BoxDecoration(
365
        color: _backgroundColor.value ?? expansionTileTheme.backgroundColor ?? Colors.transparent,
366 367 368
        border: Border(
          top: BorderSide(color: borderSideColor),
          bottom: BorderSide(color: borderSideColor),
369
        ),
370
      ),
371
      child: Column(
372 373
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
374
          ListTileTheme.merge(
375
            iconColor: _iconColor.value ?? expansionTileTheme.iconColor,
376
            textColor: _headerColor.value,
377
            child: ListTile(
378
              onTap: _handleTap,
379
              contentPadding: widget.tilePadding ?? expansionTileTheme.tilePadding,
380
              leading: widget.leading ?? _buildLeadingIcon(context),
381
              title: widget.title,
382
              subtitle: widget.subtitle,
383
              trailing: widget.trailing ?? _buildTrailingIcon(context),
384 385
            ),
          ),
386 387
          ClipRect(
            child: Align(
388 389 390
              alignment: widget.expandedAlignment
                ?? expansionTileTheme.expandedAlignment
                ?? Alignment.center,
391
              heightFactor: _heightFactor.value,
392 393 394 395 396 397 398 399 400
              child: child,
            ),
          ),
        ],
      ),
    );
  }

  @override
401
  void didChangeDependencies() {
402
    final ThemeData theme = Theme.of(context);
403
    final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
404
    final ColorScheme colorScheme = theme.colorScheme;
405
    _borderColorTween.end = theme.dividerColor;
406
    _headerColorTween
407 408 409 410
      ..begin = widget.collapsedTextColor
        ?? expansionTileTheme.collapsedTextColor
        ?? theme.textTheme.subtitle1!.color
      ..end = widget.textColor ?? expansionTileTheme.textColor ?? colorScheme.primary;
411
    _iconColorTween
412 413 414 415
      ..begin = widget.collapsedIconColor
        ?? expansionTileTheme.collapsedIconColor
        ?? theme.unselectedWidgetColor
      ..end = widget.iconColor ?? expansionTileTheme.iconColor ?? colorScheme.primary;
416
    _backgroundColorTween
417 418
      ..begin = widget.collapsedBackgroundColor ?? expansionTileTheme.collapsedBackgroundColor
      ..end = widget.backgroundColor ?? expansionTileTheme.backgroundColor;
419 420
    super.didChangeDependencies();
  }
421

422 423
  @override
  Widget build(BuildContext context) {
424
    final ExpansionTileThemeData expansionTileTheme = ExpansionTileTheme.of(context);
425
    final bool closed = !_isExpanded && _controller.isDismissed;
426 427 428
    final bool shouldRemoveChildren = closed && !widget.maintainState;

    final Widget result = Offstage(
429
      offstage: closed,
430
      child: TickerMode(
431
        enabled: !closed,
432
        child: Padding(
433
          padding: widget.childrenPadding ?? expansionTileTheme.childrenPadding ?? EdgeInsets.zero,
434 435 436 437
          child: Column(
            crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
            children: widget.children,
          ),
438 439 440 441
        ),
      ),
    );

442
    return AnimatedBuilder(
443 444
      animation: _controller.view,
      builder: _buildChildren,
445
      child: shouldRemoveChildren ? null : result,
446 447 448
    );
  }
}