Commit b8980aeb authored by Dragoș Tiselice's avatar Dragoș Tiselice Committed by GitHub

Added expansion panels. (#5523)

Added ExpansionPanel and ExpansionPanelList. The implementation
is based on the Material design spec, without enforcing every
facet of it, leaving the developer using this the liberty to
enforced it lower down the widget tree. This decision was made
based on the argument that implementing more of the spec would
reduce flexibility to a point where some design would not be
implementable with this widget anymore.
parent c4c80191
......@@ -35,6 +35,7 @@ export 'src/material/drawer_header.dart';
export 'src/material/drawer_item.dart';
export 'src/material/drop_down.dart';
export 'src/material/expand_icon.dart';
export 'src/material/expansion_panels.dart';
export 'src/material/flat_button.dart';
export 'src/material/flexible_space_bar.dart';
export 'src/material/floating_action_button.dart';
......
// 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 'package:meta/meta.dart';
import 'expand_icon.dart';
import 'mergeable_material.dart';
import 'theme.dart';
const double _kPanelHeaderCollapsedHeight = 48.0;
const double _kPanelHeaderExpandedHeight = 64.0;
/// 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].
///
/// None of the arguments can be null.
ExpansionPanel({
@required this.headerBuilder,
@required this.body,
this.isExpanded: false
}) {
assert(this.headerBuilder != null);
assert(this.body != null);
assert(this.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;
}
/// 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 StatelessWidget {
/// Creates an expansion panel list widget. The [expansionCallback] is
/// triggered when an expansion panel expand/collapse button is pushed.
ExpansionPanelList({
Key key,
this.children: const <ExpansionPanel>[],
this.expansionCallback,
this.animationDuration: kThemeAnimationDuration
}) : super(key: key) {
assert(this.children != null);
assert(this.animationDuration != null);
}
/// The children of the expansion panel list. They are layed in a similar
/// fashion to [BlockBody].
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;
bool _isChildExpanded(int index) {
return children[index].isExpanded;
}
@override
Widget build(BuildContext context) {
final List<MergeableMaterialItem> items = <MergeableMaterialItem>[];
const EdgeInsets kExpandedEdgeInsets = const EdgeInsets.symmetric(
vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight
);
for (int i = 0; i < children.length; i += 1) {
if (_isChildExpanded(i) && i != 0 && !_isChildExpanded(i - 1))
items.add(new MaterialGap(key: new ValueKey<int>(i * 2 - 1)));
Row header = new Row(
children: <Widget>[
new Flexible(
child: new AnimatedContainer(
duration: animationDuration,
curve: Curves.fastOutSlowIn,
margin: _isChildExpanded(i) ? kExpandedEdgeInsets : EdgeInsets.zero,
child: new SizedBox(
height: _kPanelHeaderCollapsedHeight,
child: children[i].headerBuilder(
context,
children[i].isExpanded
)
)
)
),
new Container(
margin: const EdgeInsets.only(right: 8.0),
child: new ExpandIcon(
isExpanded: _isChildExpanded(i),
padding: const EdgeInsets.all(16.0),
onPressed: (bool isExpanded) {
if (expansionCallback != null) {
expansionCallback(i, isExpanded);
}
}
)
)
]
);
items.add(
new MaterialSlice(
key: new ValueKey<int>(i * 2),
child: new Column(
children: <Widget>[
header,
new _AnimatedCrossFade(
firstChild: new Container(height: 0.0),
secondChild: children[i].body,
crossFadeState: _isChildExpanded(i) ? _CrossFadeState.showSecond : _CrossFadeState.showFirst,
duration: animationDuration,
curve: Curves.fastOutSlowIn
)
]
)
)
);
if (_isChildExpanded(i) && i != children.length - 1)
items.add(new MaterialGap(key: new ValueKey<int>(i * 2 + 1)));
}
return new MergeableMaterial(
hasDividers: true,
children: items
);
}
}
// The child that is shown will fade in, and while the other will fade out.
enum _CrossFadeState {
showFirst,
showSecond
}
// A widget that cross-fades between two children and animates its bottom while
// clipping the children.
class _AnimatedCrossFade extends StatefulWidget {
_AnimatedCrossFade({
Key key,
this.firstChild,
this.secondChild,
this.crossFadeState,
this.duration,
this.curve
}) : super(key: key);
final Widget firstChild;
final Widget secondChild;
final _CrossFadeState crossFadeState;
final Duration duration;
final Curve curve;
@override
_AnimatedCrossFadeState createState() => new _AnimatedCrossFadeState();
}
class _AnimatedCrossFadeState extends State<_AnimatedCrossFade> {
AnimationController _controller;
Animation<double> _firstAnimation;
Animation<double> _secondAnimation;
@override
void initState() {
super.initState();
_controller = new AnimationController(duration: config.duration);
_firstAnimation = new Tween<double>(
begin: 1.0,
end: 0.0
).animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(0.0, 0.6, curve: config.curve)
)
);
_secondAnimation = new CurvedAnimation(
parent: _controller,
curve: new Interval(0.4, 1.0, curve: config.curve.flipped)
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateConfig(_AnimatedCrossFade oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.crossFadeState != oldConfig.crossFadeState) {
switch (config.crossFadeState) {
case _CrossFadeState.showFirst:
_controller.reverse();
break;
case _CrossFadeState.showSecond:
_controller.forward();
break;
}
}
}
@override
Widget build(BuildContext context) {
Stack stack;
if (_controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.forward) {
stack = new Stack(
overflow: Overflow.visible,
children: <Widget>[
new FadeTransition(
opacity: _secondAnimation,
child: config.secondChild
),
new Positioned(
left: 0.0,
top: 0.0,
right: 0.0,
child: new FadeTransition(
opacity: _firstAnimation,
child: config.firstChild
)
)
]
);
} else {
stack = new Stack(
overflow: Overflow.visible,
children: <Widget>[
new FadeTransition(
opacity: _firstAnimation,
child: config.firstChild
),
new Positioned(
left: 0.0,
top: 0.0,
right: 0.0,
child: new FadeTransition(
opacity: _secondAnimation,
child: config.secondChild
)
)
]
);
}
return new ClipRect(
child: new AnimatedSize(
key: new ValueKey<Key>(config.key),
alignment: FractionalOffset.topCenter,
duration: config.duration,
curve: config.curve,
child: stack
)
);
}
}
// 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/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('ExpansionPanelList test', (WidgetTester tester) async {
int index;
bool isExpanded;
await tester.pumpWidget(
new ScrollableViewport(
child: new ExpansionPanelList(
expansionCallback: (int _index, bool _isExpanded) {
index = _index;
isExpanded = _isExpanded;
},
children: <ExpansionPanel>[
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'B' : 'A');
},
body: new SizedBox(height: 100.0)
)
]
)
)
);
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsNothing);
RenderBox box = tester.renderObject(find.byType(ExpansionPanelList));
double oldHeight = box.size.height;
expect(find.byType(ExpandIcon), findsOneWidget);
await tester.tap(find.byType(ExpandIcon));
expect(index, 0);
expect(isExpanded, isFalse);
box = tester.renderObject(find.byType(ExpansionPanelList));
expect(box.size.height, equals(oldHeight));
await tester.pumpWidget(
new ScrollableViewport(
child: new ExpansionPanelList(
expansionCallback: (int _index, bool _isExpanded) {
index = _index;
isExpanded = _isExpanded;
},
children: <ExpansionPanel>[
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'B' : 'A');
},
body: new SizedBox(height: 100.0),
isExpanded: true
)
]
)
)
);
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('A'), findsNothing);
expect(find.text('B'), findsOneWidget);
box = tester.renderObject(find.byType(ExpansionPanelList));
expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment