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
    super.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
       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.',
78
       );
79 80 81 82

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

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

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

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

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

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

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

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

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

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

163 164 165 166
  /// 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
167
  /// [children], and [Align] widget to align the column. The [expandedAlignment]
168 169 170 171 172 173 174 175
  /// 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].
  ///
176
  /// If this property is null then [ExpansionTileThemeData.expandedAlignment]is used. If that
177
  /// is also null then the value of [expandedAlignment] is [Alignment.center].
178 179 180 181 182
  ///
  /// See also:
  ///
  /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s
  ///   [ExpansionTileThemeData].
183
  final Alignment? expandedAlignment;
184 185 186 187 188 189 190 191 192 193 194 195 196 197

  /// 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.
  ///
198
  /// When the value is null, the value of [expandedCrossAxisAlignment] is [CrossAxisAlignment.center].
199
  final CrossAxisAlignment? expandedCrossAxisAlignment;
200

201 202
  /// Specifies padding for [children].
  ///
203
  /// If this property is null then [ExpansionTileThemeData.childrenPadding] is used. If that
204
  /// is also null then the value of [childrenPadding] is [EdgeInsets.zero].
205 206 207 208 209
  ///
  /// See also:
  ///
  /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s
  ///   [ExpansionTileThemeData].
210
  final EdgeInsetsGeometry? childrenPadding;
211

212
  /// The icon color of tile's expansion arrow icon when the sublist is expanded.
213
  ///
214
  /// Used to override to the [ListTileThemeData.iconColor].
215 216 217 218 219 220 221 222
  ///
  /// 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].
223 224
  final Color? iconColor;

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


  /// The color of the tile's titles when the sublist is expanded.
  ///
233
  /// Used to override to the [ListTileThemeData.textColor].
234 235 236 237 238 239 240 241
  ///
  /// 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].
242 243 244 245
  final Color? textColor;

  /// The color of the tile's titles when the sublist is collapsed.
  ///
246
  /// Used to override to the [ListTileThemeData.textColor].
247 248 249 250 251 252 253 254
  ///
  /// 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].
255 256
  final Color? collapsedTextColor;

257 258
  /// Typically used to force the expansion arrow icon to the tile's leading or trailing edge.
  ///
259
  /// By default, the value of [controlAffinity] is [ListTileControlAffinity.platform],
260 261 262
  /// which means that the expansion arrow icon will appear on the tile's trailing edge.
  final ListTileControlAffinity? controlAffinity;

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

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

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

  bool _isExpanded = false;

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

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

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

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

330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
  // 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) {
349
    if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.leading) {
350
      return null;
351
    }
352 353 354 355
    return _buildIcon(context);
  }

  Widget? _buildTrailingIcon(BuildContext context) {
356
    if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.trailing) {
357
      return null;
358
    }
359 360 361
    return _buildIcon(context);
  }

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

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

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

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

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

445
    return AnimatedBuilder(
446 447
      animation: _controller.view,
      builder: _buildChildren,
448
      child: shouldRemoveChildren ? null : result,
449 450 451
    );
  }
}