blob: 2e8f5f1dac0be872b310913caf93e138fa7a9a5f [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'expand_icon.dart';
import 'mergeable_material.dart';
import 'theme.dart';
const double _kPanelHeaderCollapsedHeight = 48.0;
const double _kPanelHeaderExpandedHeight = 64.0;
class _SaltedKey<S, V> extends LocalKey {
const _SaltedKey(this.salt, this.value);
final S salt;
final V value;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final _SaltedKey<S, V> typedOther = other;
return salt == typedOther.salt
&& value == typedOther.value;
}
@override
int get hashCode => hashValues(runtimeType, salt, value);
@override
String toString() {
final String saltString = S == String ? '<\'$salt\'>' : '<$salt>';
final String valueString = V == String ? '<\'$value\'>' : '<$value>';
return '[$saltString $valueString]';
}
}
/// 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].
typedef void ExpansionPanelCallback(int panelIndex, bool isExpanded);
/// Signature for the callback that's called when the header of the
/// [ExpansionPanel] needs to rebuild.
typedef Widget ExpansionPanelHeaderBuilder(BuildContext context, bool isExpanded);
/// A material expansion panel. It has a header and a body and can be either
/// expanded or collapsed. The body of the panel is only visible when it is
/// expanded.
///
/// Expansion panels are only intended to be used as children for
/// [ExpansionPanelList].
///
/// See also:
///
/// * [ExpansionPanelList]
/// * <https://material.google.com/components/expansion-panels.html>
class ExpansionPanel {
/// Creates an expansion panel to be used as a child for [ExpansionPanelList].
///
/// The [headerBuilder], [body], and [isExpanded] arguments must not be null.
ExpansionPanel({
@required this.headerBuilder,
@required this.body,
this.isExpanded = false
}) : assert(headerBuilder != null),
assert(body != null),
assert(isExpanded != null);
/// 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;
}
/// An expansion panel that allows for radio-like functionality.
///
/// A unique identifier [value] must be assigned to each panel.
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({
@required this.value,
@required ExpansionPanelHeaderBuilder headerBuilder,
@required Widget body,
}) : assert(value != null),
super(body: body, headerBuilder: headerBuilder);
/// The value that uniquely identifies a radio panel so that the currently
/// selected radio panel can be identified.
final Object value;
}
/// A material expansion panel list that lays out its children and animates
/// expansions.
///
/// See also:
///
/// * [ExpansionPanel]
/// * <https://material.google.com/components/expansion-panels.html>
class ExpansionPanelList extends StatefulWidget {
/// Creates an expansion panel list widget. The [expansionCallback] is
/// triggered when an expansion panel expand/collapse button is pushed.
///
/// The [children] and [animationDuration] arguments must not be null.
const ExpansionPanelList({
Key key,
this.children = const <ExpansionPanel>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration,
}) : assert(children != null),
assert(animationDuration != null),
_allowOnlyOnePanelOpen = false,
this.initialOpenPanelValue = null,
super(key: key);
/// 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].
const ExpansionPanelList.radio({
Key key,
List<ExpansionPanelRadio> children = const <ExpansionPanelRadio>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration,
this.initialOpenPanelValue,
}) : children = children, // ignore: prefer_initializing_formals
assert(children != null),
assert(animationDuration != null),
_allowOnlyOnePanelOpen = true,
super(key: key);
/// The children of the expansion panel list. They are laid out in a similar
/// fashion to [ListBody].
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
/// to-be-expanded panel in the list and whether the panel is currently
/// expanded or not.
///
/// 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.
final ExpansionPanelCallback expansionCallback;
/// The duration of the expansion animation.
final Duration animationDuration;
// 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.)
final Object initialOpenPanelValue;
@override
State<StatefulWidget> createState() => new _ExpansionPanelListState();
}
class _ExpansionPanelListState extends State<ExpansionPanelList> {
ExpansionPanelRadio _currentOpenPanel;
@override
void initState() {
super.initState();
if (widget._allowOnlyOnePanelOpen) {
assert(_allIdentifiersUnique(), 'All object identifiers are not unique!');
for (ExpansionPanelRadio child in widget.children) {
if (widget.initialOpenPanelValue != null &&
child.value == widget.initialOpenPanelValue)
_currentOpenPanel = child;
}
}
}
@override
void didUpdateWidget(ExpansionPanelList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget._allowOnlyOnePanelOpen) {
assert(_allIdentifiersUnique(), 'All object identifiers are not unique!');
for (ExpansionPanelRadio newChild in widget.children) {
if (widget.initialOpenPanelValue != null &&
newChild.value == widget.initialOpenPanelValue)
_currentOpenPanel = newChild;
}
} else if(oldWidget._allowOnlyOnePanelOpen) {
_currentOpenPanel = null;
}
}
bool _allIdentifiersUnique() {
final Map<Object, bool> identifierMap = <Object, bool>{};
for (ExpansionPanelRadio child in widget.children) {
identifierMap[child.value] = true;
}
return identifierMap.length == widget.children.length;
}
bool _isChildExpanded(int index) {
if (widget._allowOnlyOnePanelOpen) {
final ExpansionPanelRadio radioWidget = widget.children[index];
return _currentOpenPanel?.value == radioWidget.value;
}
return widget.children[index].isExpanded;
}
void _handlePressed(bool isExpanded, int index) {
if (widget.expansionCallback != null)
widget.expansionCallback(index, isExpanded);
if (widget._allowOnlyOnePanelOpen) {
final ExpansionPanelRadio pressedChild = widget.children[index];
for (int childIndex = 0; childIndex < widget.children.length; childIndex += 1) {
final ExpansionPanelRadio child = widget.children[childIndex];
if (widget.expansionCallback != null &&
childIndex != index &&
child.value == _currentOpenPanel?.value)
widget.expansionCallback(childIndex, false);
}
_currentOpenPanel = isExpanded ? null : pressedChild;
}
setState((){});
}
@override
Widget build(BuildContext context) {
final List<MergeableMaterialItem> items = <MergeableMaterialItem>[];
const EdgeInsets kExpandedEdgeInsets = EdgeInsets.symmetric(
vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight
);
for (int index = 0; index < widget.children.length; index += 1) {
if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1))
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 - 1)));
final ExpansionPanel child = widget.children[index];
final Row header = new Row(
children: <Widget>[
new Expanded(
child: new AnimatedContainer(
duration: widget.animationDuration,
curve: Curves.fastOutSlowIn,
margin: _isChildExpanded(index) ? kExpandedEdgeInsets : EdgeInsets.zero,
child: new ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight),
child: child.headerBuilder(
context,
_isChildExpanded(index),
),
),
),
),
new Container(
margin: const EdgeInsetsDirectional.only(end: 8.0),
child: new ExpandIcon(
isExpanded: _isChildExpanded(index),
padding: const EdgeInsets.all(16.0),
onPressed: (bool isExpanded) => _handlePressed(isExpanded, index),
),
),
],
);
items.add(
new MaterialSlice(
key: new _SaltedKey<BuildContext, int>(context, index * 2),
child: new Column(
children: <Widget>[
new MergeSemantics(child: header),
new AnimatedCrossFade(
firstChild: new Container(height: 0.0),
secondChild: child.body,
firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
sizeCurve: Curves.fastOutSlowIn,
crossFadeState: _isChildExpanded(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: widget.animationDuration,
),
],
),
),
);
if (_isChildExpanded(index) && index != widget.children.length - 1)
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 + 1)));
}
return new MergeableMaterial(
hasDividers: true,
children: items,
);
}
}