expansion_panel.dart 15.7 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 'constants.dart';
8
import 'expand_icon.dart';
9
import 'ink_well.dart';
10
import 'material_localizations.dart';
11
import 'mergeable_material.dart';
12
import 'shadows.dart';
13 14
import 'theme.dart';

15
const double _kPanelHeaderCollapsedHeight = kMinInteractiveDimension;
16
const EdgeInsets _kPanelHeaderExpandedDefaultPadding = EdgeInsets.symmetric(
17
    vertical: 64.0 - _kPanelHeaderCollapsedHeight,
18
);
19
const EdgeInsets _kExpandIconPadding = EdgeInsets.all(12.0);
20

21 22 23 24 25 26 27
class _SaltedKey<S, V> extends LocalKey {
  const _SaltedKey(this.salt, this.value);

  final S salt;
  final V value;

  @override
28
  bool operator ==(Object other) {
29
    if (other.runtimeType != runtimeType) {
30
      return false;
31
    }
32 33 34
    return other is _SaltedKey<S, V>
        && other.salt == salt
        && other.value == value;
35 36 37
  }

  @override
38
  int get hashCode => Object.hash(runtimeType, salt, value);
39 40 41

  @override
  String toString() {
42 43
    final String saltString = S == String ? "<'$salt'>" : '<$salt>';
    final String valueString = V == String ? "<'$value'>" : '<$value>';
44 45 46 47
    return '[$saltString $valueString]';
  }
}

48 49 50 51 52
/// Signature for the callback that's called when an [ExpansionPanel] is
/// expanded or collapsed.
///
/// The position of the panel within an [ExpansionPanelList] is given by
/// [panelIndex].
53
typedef ExpansionPanelCallback = void Function(int panelIndex, bool isExpanded);
54 55 56

/// Signature for the callback that's called when the header of the
/// [ExpansionPanel] needs to rebuild.
57
typedef ExpansionPanelHeaderBuilder = Widget Function(BuildContext context, bool isExpanded);
58

59
/// A material expansion panel. It has a header and a body and can be either
60 61 62
/// expanded or collapsed. The body of the panel is only visible when it is
/// expanded.
///
63 64
/// {@youtube 560 315 https://www.youtube.com/watch?v=2aJZzRMziJc}
///
65 66 67
/// Expansion panels are only intended to be used as children for
/// [ExpansionPanelList].
///
68 69
/// See [ExpansionPanelList] for a sample implementation.
///
70 71 72
/// See also:
///
///  * [ExpansionPanelList]
73
///  * <https://material.io/design/components/lists.html#types>
74 75
class ExpansionPanel {
  /// Creates an expansion panel to be used as a child for [ExpansionPanelList].
76
  /// See [ExpansionPanelList] for an example on how to use this widget.
77
  ///
78
  /// The [headerBuilder], [body], and [isExpanded] arguments must not be null.
79
  ExpansionPanel({
80 81
    required this.headerBuilder,
    required this.body,
82
    this.isExpanded = false,
83
    this.canTapOnHeader = false,
84
    this.backgroundColor,
85
  });
86 87 88 89 90 91 92 93 94 95 96 97 98

  /// The widget builder that builds the expansion panels' header.
  final ExpansionPanelHeaderBuilder headerBuilder;

  /// The body of the expansion panel that's displayed below the header.
  ///
  /// This widget is visible only when the panel is expanded.
  final Widget body;

  /// Whether the panel is expanded.
  ///
  /// Defaults to false.
  final bool isExpanded;
99

100 101 102 103 104
  /// Whether tapping on the panel's header will expand/collapse it.
  ///
  /// Defaults to false.
  final bool canTapOnHeader;

105 106 107 108
  /// Defines the background color of the panel.
  ///
  /// Defaults to [ThemeData.cardColor].
  final Color? backgroundColor;
109 110 111
}

/// An expansion panel that allows for radio-like functionality.
112 113
/// This means that at any given time, at most, one [ExpansionPanelRadio]
/// can remain expanded.
114 115
///
/// A unique identifier [value] must be assigned to each panel.
116 117 118 119
/// This identifier allows the [ExpansionPanelList] to determine
/// which [ExpansionPanelRadio] instance should be expanded.
///
/// See [ExpansionPanelList.radio] for a sample implementation.
120 121 122 123 124 125
class ExpansionPanelRadio extends ExpansionPanel {
  /// An expansion panel that allows for radio functionality.
  ///
  /// A unique [value] must be passed into the constructor. The
  /// [headerBuilder], [body], [value] must not be null.
  ExpansionPanelRadio({
126
    required this.value,
127 128 129 130
    required super.headerBuilder,
    required super.body,
    super.canTapOnHeader,
    super.backgroundColor,
131
  });
132 133 134 135

  /// The value that uniquely identifies a radio panel so that the currently
  /// selected radio panel can be identified.
  final Object value;
136 137
}

138
/// A material expansion panel list that lays out its children and animates
139 140
/// expansions.
///
141
/// The [expansionCallback] is called when the expansion state changes. For
Lioness100's avatar
Lioness100 committed
142
/// normal [ExpansionPanelList] widgets, it is the responsibility of the parent
143 144 145 146 147
/// widget to rebuild the [ExpansionPanelList] with updated values for
/// [ExpansionPanel.isExpanded]. For [ExpansionPanelList.radio] widgets, the
/// open state is tracked internally and the callback is invoked both for the
/// previously open panel, which is closing, and the previously closed panel,
/// which is opening.
148
///
149
/// {@tool dartpad}
150
/// Here is a simple example of how to use [ExpansionPanelList].
151
///
152
/// ** See code in examples/api/lib/material/expansion_panel/expansion_panel_list.0.dart **
153 154
/// {@end-tool}
///
155 156
/// See also:
///
157 158
///  * [ExpansionPanel], which is used in the [children] property.
///  * [ExpansionPanelList.radio], a variant of this widget where only one panel is open at a time.
159
///  * <https://material.io/design/components/lists.html#types>
160
class ExpansionPanelList extends StatefulWidget {
161 162
  /// Creates an expansion panel list widget. The [expansionCallback] is
  /// triggered when an expansion panel expand/collapse button is pushed.
163 164
  ///
  /// The [children] and [animationDuration] arguments must not be null.
165
  const ExpansionPanelList({
166
    super.key,
167
    this.children = const <ExpansionPanel>[],
168
    this.expansionCallback,
169
    this.animationDuration = kThemeAnimationDuration,
170
    this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding,
171
    this.dividerColor,
172
    this.elevation = 2,
173
    this.expandIconColor,
174
    this.materialGapSize = 16.0,
175
  }) : _allowOnlyOnePanelOpen = false,
176
       initialOpenPanelValue = null;
177 178 179 180 181 182 183 184

  /// Creates a radio expansion panel list widget.
  ///
  /// This widget allows for at most one panel in the list to be open.
  /// The expansion panel callback is triggered when an expansion panel
  /// expand/collapse button is pushed. The [children] and [animationDuration]
  /// arguments must not be null. The [children] objects must be instances
  /// of [ExpansionPanelRadio].
185
  ///
186
  /// {@tool dartpad}
187 188
  /// Here is a simple example of how to implement ExpansionPanelList.radio.
  ///
189
  /// ** See code in examples/api/lib/material/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart **
190
  /// {@end-tool}
191
  const ExpansionPanelList.radio({
192
    super.key,
193
    this.children = const <ExpansionPanelRadio>[],
194 195 196
    this.expansionCallback,
    this.animationDuration = kThemeAnimationDuration,
    this.initialOpenPanelValue,
197
    this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding,
198
    this.dividerColor,
199
    this.elevation = 2,
200
    this.expandIconColor,
201
    this.materialGapSize = 16.0,
202
  }) : _allowOnlyOnePanelOpen = true;
203

204
  /// The children of the expansion panel list. They are laid out in a similar
205
  /// fashion to [ListBody].
206 207 208 209
  final List<ExpansionPanel> children;

  /// The callback that gets called whenever one of the expand/collapse buttons
  /// is pressed. The arguments passed to the callback are the index of the
210 211
  /// pressed panel and whether the panel is currently expanded or not.
  ///
212
  /// If [ExpansionPanelList.radio] is used, the callback may be called a
213 214 215 216
  /// second time if a different panel was previously open. The arguments
  /// passed to the second callback are the index of the panel that will close
  /// and false, marking that it will be closed.
  ///
217 218 219 220 221
  /// For [ExpansionPanelList], the callback should call [State.setState] when
  /// it is notified about the closing/opening panel. On the other hand, the
  /// callback for [ExpansionPanelList.radio] is intended to inform the parent
  /// widget of changes, as the radio panels' open/close states are managed
  /// internally.
222 223 224
  ///
  /// This callback is useful in order to keep track of the expanded/collapsed
  /// panels in a parent widget that may need to react to these changes.
225
  final ExpansionPanelCallback? expansionCallback;
226 227 228 229

  /// The duration of the expansion animation.
  final Duration animationDuration;

230 231 232 233 234 235
  // Whether multiple panels can be open simultaneously
  final bool _allowOnlyOnePanelOpen;

  /// The value of the panel that initially begins open. (This value is
  /// only used when initializing with the [ExpansionPanelList.radio]
  /// constructor.)
236
  final Object? initialOpenPanelValue;
237

238 239 240 241 242 243
  /// The padding that surrounds the panel header when expanded.
  ///
  /// By default, 16px of space is added to the header vertically (above and below)
  /// during expansion.
  final EdgeInsets expandedHeaderPadding;

244 245
  /// Defines color for the divider when [ExpansionPanel.isExpanded] is false.
  ///
246
  /// If [dividerColor] is null, then [DividerThemeData.color] is used. If that
247
  /// is null, then [ThemeData.dividerColor] is used.
248
  final Color? dividerColor;
249

250 251 252
  /// Defines elevation for the [ExpansionPanel] while it's expanded.
  ///
  /// By default, the value of elevation is 2.
253
  final double elevation;
254

255 256 257
  /// {@macro flutter.material.ExpandIcon.color}
  final Color? expandIconColor;

258 259 260 261 262 263
  /// Defines the [MaterialGap.size] of the [MaterialGap] which is placed
  /// between the [ExpansionPanelList.children] when they're expanded.
  ///
  /// Defaults to `16.0`.
  final double materialGapSize;

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

class _ExpansionPanelListState extends State<ExpansionPanelList> {
269
  ExpansionPanelRadio? _currentOpenPanel;
270 271 272 273 274

  @override
  void initState() {
    super.initState();
    if (widget._allowOnlyOnePanelOpen) {
275 276
      assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.');
      if (widget.initialOpenPanelValue != null) {
277 278
        _currentOpenPanel =
          searchPanelByValue(widget.children.cast<ExpansionPanelRadio>(), widget.initialOpenPanelValue);
279 280 281 282 283 284 285
      }
    }
  }

  @override
  void didUpdateWidget(ExpansionPanelList oldWidget) {
    super.didUpdateWidget(oldWidget);
286

287
    if (widget._allowOnlyOnePanelOpen) {
288 289 290 291
      assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.');
      // If the previous widget was non-radio ExpansionPanelList, initialize the
      // open panel to widget.initialOpenPanelValue
      if (!oldWidget._allowOnlyOnePanelOpen) {
292 293
        _currentOpenPanel =
          searchPanelByValue(widget.children.cast<ExpansionPanelRadio>(), widget.initialOpenPanelValue);
294
      }
295
    } else {
296 297 298 299 300 301
      _currentOpenPanel = null;
    }
  }

  bool _allIdentifiersUnique() {
    final Map<Object, bool> identifierMap = <Object, bool>{};
302
    for (final ExpansionPanelRadio child in widget.children.cast<ExpansionPanelRadio>()) {
303 304 305 306 307
      identifierMap[child.value] = true;
    }
    return identifierMap.length == widget.children.length;
  }

308
  bool _isChildExpanded(int index) {
309
    if (widget._allowOnlyOnePanelOpen) {
310
      final ExpansionPanelRadio radioWidget = widget.children[index] as ExpansionPanelRadio;
311 312 313 314 315 316
      return _currentOpenPanel?.value == radioWidget.value;
    }
    return widget.children[index].isExpanded;
  }

  void _handlePressed(bool isExpanded, int index) {
317
    widget.expansionCallback?.call(index, isExpanded);
318 319

    if (widget._allowOnlyOnePanelOpen) {
320
      final ExpansionPanelRadio pressedChild = widget.children[index] as ExpansionPanelRadio;
321

322 323
      // If another ExpansionPanelRadio was already open, apply its
      // expansionCallback (if any) to false, because it's closing.
324
      for (int childIndex = 0; childIndex < widget.children.length; childIndex += 1) {
325
        final ExpansionPanelRadio child = widget.children[childIndex] as ExpansionPanelRadio;
326 327
        if (widget.expansionCallback != null &&
            childIndex != index &&
328
            child.value == _currentOpenPanel?.value) {
329
          widget.expansionCallback!(childIndex, false);
330
        }
331
      }
332 333 334 335 336 337 338

      setState(() {
        _currentOpenPanel = isExpanded ? null : pressedChild;
      });
    }
  }

339
  ExpansionPanelRadio? searchPanelByValue(List<ExpansionPanelRadio> panels, Object? value)  {
340
    for (final ExpansionPanelRadio panel in panels) {
341
      if (panel.value == value) {
342
        return panel;
343
      }
344
    }
345
    return null;
346 347 348 349
  }

  @override
  Widget build(BuildContext context) {
350 351
    assert(kElevationToShadow.containsKey(widget.elevation),
      'Invalid value for elevation. See the kElevationToShadow constant for'
352
      ' possible elevation values.',
353 354
    );

355 356
    final List<MergeableMaterialItem> items = <MergeableMaterialItem>[];

357
    for (int index = 0; index < widget.children.length; index += 1) {
358
      if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1)) {
359
        items.add(MaterialGap(key: _SaltedKey<BuildContext, int>(context, index * 2 - 1), size: widget.materialGapSize));
360
      }
361

362
      final ExpansionPanel child = widget.children[index];
363 364 365 366
      final Widget headerWidget = child.headerBuilder(
        context,
        _isChildExpanded(index),
      );
367 368 369 370

      Widget expandIconContainer = Container(
        margin: const EdgeInsetsDirectional.only(end: 8.0),
        child: ExpandIcon(
371
          color: widget.expandIconColor,
372
          isExpanded: _isChildExpanded(index),
373
          padding: _kExpandIconPadding,
374 375 376 377 378 379
          onPressed: !child.canTapOnHeader
              ? (bool isExpanded) => _handlePressed(isExpanded, index)
              : null,
        ),
      );
      if (!child.canTapOnHeader) {
380
        final MaterialLocalizations localizations = MaterialLocalizations.of(context);
381 382 383
        expandIconContainer = Semantics(
          label: _isChildExpanded(index)? localizations.expandedIconTapHint : localizations.collapsedIconTapHint,
          container: true,
384
          child: expandIconContainer,
385 386 387
        );
      }
      Widget header = Row(
388
        children: <Widget>[
389 390
          Expanded(
            child: AnimatedContainer(
391
              duration: widget.animationDuration,
392
              curve: Curves.fastOutSlowIn,
393
              margin: _isChildExpanded(index) ? widget.expandedHeaderPadding : EdgeInsets.zero,
394
              child: ConstrainedBox(
395
                constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight),
396
                child: headerWidget,
397 398
              ),
            ),
399
          ),
400
          expandIconContainer,
401
        ],
402
      );
403 404 405 406 407
      if (child.canTapOnHeader) {
        header = MergeSemantics(
          child: InkWell(
            onTap: () => _handlePressed(_isChildExpanded(index), index),
            child: header,
408
          ),
409 410
        );
      }
411
      items.add(
412 413
        MaterialSlice(
          key: _SaltedKey<BuildContext, int>(context, index * 2),
414
          color: child.backgroundColor,
415
          child: Column(
416
            children: <Widget>[
417
              header,
418 419
              AnimatedCrossFade(
                firstChild: Container(height: 0.0),
420
                secondChild: child.body,
421 422
                firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
                secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
423
                sizeCurve: Curves.fastOutSlowIn,
424
                crossFadeState: _isChildExpanded(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
425
                duration: widget.animationDuration,
426 427 428 429
              ),
            ],
          ),
        ),
430 431
      );

432
      if (_isChildExpanded(index) && index != widget.children.length - 1) {
433
        items.add(MaterialGap(key: _SaltedKey<BuildContext, int>(context, index * 2 + 1), size: widget.materialGapSize));
434
      }
435 436
    }

437
    return MergeableMaterial(
438
      hasDividers: true,
439
      dividerColor: widget.dividerColor,
440
      elevation: widget.elevation,
441
      children: items,
442 443 444
    );
  }
}