Unverified Commit c97fc206 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

ExpansionPanel animation fixes (#13032)

Previously, ExpansionPanel would do weird things if interacted with
when it was already animating. This is fixed and there's now a test.

Also:

 * Minor fixes to make the gallery work in RTL, not that there's
   any way to see that without hard-coding the framework to RTL.
   But at least I'll be less annoyed when doing that.

 * Some trivial code and documentation cleanup.
parent 19cac927
...@@ -142,8 +142,8 @@ class GalleryHomeState extends State<GalleryHome> with SingleTickerProviderState ...@@ -142,8 +142,8 @@ class GalleryHomeState extends State<GalleryHome> with SingleTickerProviderState
new MergeSemantics( new MergeSemantics(
child: new Container( child: new Container(
height: 48.0, height: 48.0,
padding: const EdgeInsets.only(left: 16.0), padding: const EdgeInsetsDirectional.only(start: 16.0),
alignment: Alignment.centerLeft, alignment: AlignmentDirectional.centerStart,
child: new Text(galleryItem.category, style: headerStyle) child: new Text(galleryItem.category, style: headerStyle)
), ),
) )
......
...@@ -64,7 +64,7 @@ typedef Widget ExpansionPanelHeaderBuilder(BuildContext context, bool isExpanded ...@@ -64,7 +64,7 @@ typedef Widget ExpansionPanelHeaderBuilder(BuildContext context, bool isExpanded
class ExpansionPanel { class ExpansionPanel {
/// Creates an expansion panel to be used as a child for [ExpansionPanelList]. /// Creates an expansion panel to be used as a child for [ExpansionPanelList].
/// ///
/// None of the arguments can be null. /// The [headerBuilder], [body], and [isExpanded] arguments must not be null.
ExpansionPanel({ ExpansionPanel({
@required this.headerBuilder, @required this.headerBuilder,
@required this.body, @required this.body,
...@@ -133,9 +133,9 @@ class ExpansionPanelList extends StatelessWidget { ...@@ -133,9 +133,9 @@ class ExpansionPanelList extends StatelessWidget {
vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight
); );
for (int i = 0; i < children.length; i += 1) { for (int index = 0; index < children.length; index += 1) {
if (_isChildExpanded(i) && i != 0 && !_isChildExpanded(i - 1)) if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1))
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, i * 2 - 1))); items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 - 1)));
final Row header = new Row( final Row header = new Row(
children: <Widget>[ children: <Widget>[
...@@ -143,58 +143,57 @@ class ExpansionPanelList extends StatelessWidget { ...@@ -143,58 +143,57 @@ class ExpansionPanelList extends StatelessWidget {
child: new AnimatedContainer( child: new AnimatedContainer(
duration: animationDuration, duration: animationDuration,
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
margin: _isChildExpanded(i) ? kExpandedEdgeInsets : EdgeInsets.zero, margin: _isChildExpanded(index) ? kExpandedEdgeInsets : EdgeInsets.zero,
child: new SizedBox( child: new SizedBox(
height: _kPanelHeaderCollapsedHeight, height: _kPanelHeaderCollapsedHeight,
child: children[i].headerBuilder( child: children[index].headerBuilder(
context, context,
children[i].isExpanded children[index].isExpanded,
) ),
) ),
) ),
), ),
new Container( new Container(
margin: const EdgeInsetsDirectional.only(end: 8.0), margin: const EdgeInsetsDirectional.only(end: 8.0),
child: new ExpandIcon( child: new ExpandIcon(
isExpanded: _isChildExpanded(i), isExpanded: _isChildExpanded(index),
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
onPressed: (bool isExpanded) { onPressed: (bool isExpanded) {
if (expansionCallback != null) { if (expansionCallback != null)
expansionCallback(i, isExpanded); expansionCallback(index, isExpanded);
} },
} ),
) ),
) ],
]
); );
items.add( items.add(
new MaterialSlice( new MaterialSlice(
key: new _SaltedKey<BuildContext, int>(context, i * 2), key: new _SaltedKey<BuildContext, int>(context, index * 2),
child: new Column( child: new Column(
children: <Widget>[ children: <Widget>[
header, header,
new AnimatedCrossFade( new AnimatedCrossFade(
firstChild: new Container(height: 0.0), firstChild: new Container(height: 0.0),
secondChild: children[i].body, secondChild: children[index].body,
firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn), firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn), secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
sizeCurve: Curves.fastOutSlowIn, sizeCurve: Curves.fastOutSlowIn,
crossFadeState: _isChildExpanded(i) ? CrossFadeState.showSecond : CrossFadeState.showFirst, crossFadeState: _isChildExpanded(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: animationDuration, duration: animationDuration,
) ),
] ],
) ),
) ),
); );
if (_isChildExpanded(i) && i != children.length - 1) if (_isChildExpanded(index) && index != children.length - 1)
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, i * 2 + 1))); items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 + 1)));
} }
return new MergeableMaterial( return new MergeableMaterial(
hasDividers: true, hasDividers: true,
children: items children: items,
); );
} }
} }
...@@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart';
/// Identifiers for the supported material design icons. /// Identifiers for the supported material design icons.
/// ///
/// Use with with the [Icon] class to show specific icons. /// Use with the [Icon] class to show specific icons.
/// ///
/// Icons are identified by their name as listed below. /// Icons are identified by their name as listed below.
/// ///
......
...@@ -456,6 +456,9 @@ class _MergeableMaterialState extends State<MergeableMaterial> with TickerProvid ...@@ -456,6 +456,9 @@ class _MergeableMaterialState extends State<MergeableMaterial> with TickerProvid
} }
BorderRadius _borderRadius(int index, bool start, bool end) { BorderRadius _borderRadius(int index, bool start, bool end) {
assert(kMaterialEdges[MaterialType.card].topLeft == kMaterialEdges[MaterialType.card].topRight);
assert(kMaterialEdges[MaterialType.card].topLeft == kMaterialEdges[MaterialType.card].bottomLeft);
assert(kMaterialEdges[MaterialType.card].topLeft == kMaterialEdges[MaterialType.card].bottomRight);
final Radius cardRadius = kMaterialEdges[MaterialType.card].topLeft; final Radius cardRadius = kMaterialEdges[MaterialType.card].topLeft;
Radius startRadius = Radius.zero; Radius startRadius = Radius.zero;
......
...@@ -16,32 +16,37 @@ enum RenderAnimatedSizeState { ...@@ -16,32 +16,37 @@ enum RenderAnimatedSizeState {
/// The initial state, when we do not yet know what the starting and target /// The initial state, when we do not yet know what the starting and target
/// sizes are to animate. /// sizes are to animate.
/// ///
/// Next possible state is [stable]. /// The next state is [stable].
start, start,
/// At this state the child's size is assumed to be stable and we are either /// At this state the child's size is assumed to be stable and we are either
/// animating, or waiting for the child's size to change. /// animating, or waiting for the child's size to change.
/// ///
/// Next possible state is [changed]. /// If the child's size changes, the state will become [changed]. Otherwise,
/// it remains [stable].
stable, stable,
/// At this state we know that the child has changed once after being assumed /// At this state we know that the child has changed once after being assumed
/// [stable]. /// [stable].
/// ///
/// Next possible states are: /// The next state will be one of:
/// ///
/// - [stable] - if the child's size stabilized immediately, this is a signal /// * [stable] if the child's size stabilized immediately. This is a signal
/// for us to begin animating the size towards the child's new size. /// for the render object to begin animating the size towards the child's new
/// - [unstable] - if the child's size continues to change, we assume it is /// size.
/// not stable and enter the [unstable] state. ///
/// * [unstable] if the child's size continues to change.
changed, changed,
/// At this state the child's size is assumed to be unstable. /// At this state the child's size is assumed to be unstable (changing each
/// frame).
/// ///
/// Instead of chasing the child's size in this state we tightly track the /// Instead of chasing the child's size in this state, the render object
/// child's size until it stabilizes. /// tightly tracks the child's size until it stabilizes.
/// ///
/// Next possible state is [stable]. /// The render object remains in this state until a frame where the child's
/// size remains the same as the previous frame. At that time, the next state
/// is [stable].
unstable, unstable,
} }
...@@ -144,7 +149,6 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { ...@@ -144,7 +149,6 @@ class RenderAnimatedSize extends RenderAligningShiftedBox {
@override @override
void detach() { void detach() {
_controller.stop(); _controller.stop();
_state = RenderAnimatedSizeState.start;
super.detach(); super.detach();
} }
...@@ -212,12 +216,15 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { ...@@ -212,12 +216,15 @@ class RenderAnimatedSize extends RenderAligningShiftedBox {
/// animation. /// animation.
void _layoutStable() { void _layoutStable() {
if (_sizeTween.end != child.size) { if (_sizeTween.end != child.size) {
_sizeTween.begin = size;
_sizeTween.end = debugAdoptSize(child.size); _sizeTween.end = debugAdoptSize(child.size);
_restartAnimation(); _restartAnimation();
_state = RenderAnimatedSizeState.changed; _state = RenderAnimatedSizeState.changed;
} else if (_controller.value == _controller.upperBound) { } else if (_controller.value == _controller.upperBound) {
// Animation finished. Reset target sizes. // Animation finished. Reset target sizes.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size); _sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size);
} else if (!_controller.isAnimating) {
_controller.forward(); // resume the animation after being detached
} }
} }
...@@ -236,6 +243,8 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { ...@@ -236,6 +243,8 @@ class RenderAnimatedSize extends RenderAligningShiftedBox {
} else { } else {
// Child size stabilized. // Child size stabilized.
_state = RenderAnimatedSizeState.stable; _state = RenderAnimatedSizeState.stable;
if (!_controller.isAnimating)
_controller.forward(); // resume the animation after being detached
} }
} }
......
...@@ -604,6 +604,10 @@ class RenderConstrainedOverflowBox extends RenderAligningShiftedBox { ...@@ -604,6 +604,10 @@ class RenderConstrainedOverflowBox extends RenderAligningShiftedBox {
/// passes its original constraints through to its child, which it allows to /// passes its original constraints through to its child, which it allows to
/// overflow. /// overflow.
class RenderUnconstrainedBox extends RenderAligningShiftedBox with DebugOverflowIndicatorMixin { class RenderUnconstrainedBox extends RenderAligningShiftedBox with DebugOverflowIndicatorMixin {
/// Create a render object that sizes itself to the child but does not
/// pass the [constraints] down to that child.
///
/// The [alignment] must not be null.
RenderUnconstrainedBox({ RenderUnconstrainedBox({
@required AlignmentGeometry alignment, @required AlignmentGeometry alignment,
@required TextDirection textDirection, @required TextDirection textDirection,
......
...@@ -363,7 +363,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ...@@ -363,7 +363,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
int _framesEmitted; int _framesEmitted;
Timer _timer; Timer _timer;
void _handleCodecReady(ui.Codec codec){ void _handleCodecReady(ui.Codec codec) {
_codec = codec; _codec = codec;
_decodeNextFrameAndSchedule(); _decodeNextFrameAndSchedule();
} }
......
...@@ -67,8 +67,8 @@ typedef Widget AnimatedCrossFadeBuilder(Widget topChild, Key topChildKey, Widget ...@@ -67,8 +67,8 @@ typedef Widget AnimatedCrossFadeBuilder(Widget topChild, Key topChildKey, Widget
/// [firstCurve] and [secondCurve] represent the opacity curves of the two /// [firstCurve] and [secondCurve] represent the opacity curves of the two
/// children. The [firstCurve] is inverted, i.e. it fades out when providing a /// children. The [firstCurve] is inverted, i.e. it fades out when providing a
/// growing curve like [Curves.linear]. The [sizeCurve] is the curve used to /// growing curve like [Curves.linear]. The [sizeCurve] is the curve used to
/// animated between the size of the fading out child and the size of the fading /// animate between the size of the fading-out child and the size of the
/// in child. /// fading-in child.
/// ///
/// This widget is intended to be used to fade a pair of widgets with the same /// This widget is intended to be used to fade a pair of widgets with the same
/// width. In the case where the two children have different heights, the /// width. In the case where the two children have different heights, the
......
...@@ -88,7 +88,7 @@ abstract class ScrollNotification extends LayoutChangedNotification with Viewpor ...@@ -88,7 +88,7 @@ abstract class ScrollNotification extends LayoutChangedNotification with Viewpor
@required this.context, @required this.context,
}); });
// A description of a [Scrollable]'s contents, useful for modeling the state /// A description of a [Scrollable]'s contents, useful for modeling the state
/// of its viewport. /// of its viewport.
final ScrollMetrics metrics; final ScrollMetrics metrics;
......
...@@ -289,8 +289,8 @@ void main() { ...@@ -289,8 +289,8 @@ void main() {
final AnimationController controller = new AnimationController( final AnimationController controller = new AnimationController(
vsync: const TestVSync(), vsync: const TestVSync(),
); );
expect((){ controller.repeat(); }, throwsFlutterError); expect(() { controller.repeat(); }, throwsFlutterError);
expect((){ controller.repeat(period: null); }, throwsFlutterError); expect(() { controller.repeat(period: null); }, throwsFlutterError);
}); });
test('Do not animate if already at target', () { test('Do not animate if already at target', () {
......
...@@ -124,7 +124,7 @@ void main() { ...@@ -124,7 +124,7 @@ void main() {
child: new Center( child: new Center(
child: new CupertinoButton( child: new CupertinoButton(
child: const Text('Back'), child: const Text('Back'),
onPressed: (){ onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
......
...@@ -13,7 +13,7 @@ import '../widgets/semantics_tester.dart'; ...@@ -13,7 +13,7 @@ import '../widgets/semantics_tester.dart';
const List<String> menuItems = const <String>['one', 'two', 'three', 'four']; const List<String> menuItems = const <String>['one', 'two', 'three', 'four'];
final Type dropdownButtonType = new DropdownButton<String>( final Type dropdownButtonType = new DropdownButton<String>(
onChanged: (_){ }, onChanged: (_) { },
items: const <DropdownMenuItem<String>>[] items: const <DropdownMenuItem<String>>[]
).runtimeType; ).runtimeType;
......
...@@ -71,6 +71,7 @@ void main() { ...@@ -71,6 +71,7 @@ void main() {
box = tester.renderObject(find.byType(ExpansionPanelList)); box = tester.renderObject(find.byType(ExpansionPanelList));
expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin
}); });
testWidgets('Multiple Panel List test', (WidgetTester tester) async { testWidgets('Multiple Panel List test', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
new MaterialApp( new MaterialApp(
...@@ -90,7 +91,7 @@ void main() { ...@@ -90,7 +91,7 @@ void main() {
new ExpansionPanelList( new ExpansionPanelList(
children: <ExpansionPanel>[ children: <ExpansionPanel>[
new ExpansionPanel( new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded){ headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'D' : 'C'); return new Text(isExpanded ? 'D' : 'C');
}, },
body: const SizedBox(height: 100.0), body: const SizedBox(height: 100.0),
...@@ -109,4 +110,90 @@ void main() { ...@@ -109,4 +110,90 @@ void main() {
expect(find.text('C'), findsNothing); expect(find.text('C'), findsNothing);
expect(find.text('D'), findsOneWidget); expect(find.text('D'), findsOneWidget);
}); });
testWidgets('Open/close animations', (WidgetTester tester) async {
const Duration kSizeAnimationDuration = const Duration(milliseconds: 1000);
// The MaterialGaps animate in using kThemeAnimationDuration (hardcoded),
// which should be less than our test size animation length. So we can assume that they
// appear immediately. Here we just verify that our assumption is true.
expect(kThemeAnimationDuration, lessThan(kSizeAnimationDuration ~/ 2));
Widget build(bool a, bool b, bool c) {
return new MaterialApp(
home: new Column(
children: <Widget>[
new ExpansionPanelList(
animationDuration: kSizeAnimationDuration,
children: <ExpansionPanel>[
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) => const Placeholder(),
body: const SizedBox(height: 100.0, child: const Placeholder()),
isExpanded: a,
),
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) => const Placeholder(),
body: const SizedBox(height: 100.0, child: const Placeholder()),
isExpanded: b,
),
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) => const Placeholder(),
body: const SizedBox(height: 100.0, child: const Placeholder()),
isExpanded: c,
),
],
),
],
),
);
}
await tester.pumpWidget(build(false, false, false));
expect(tester.renderObjectList(find.byType(AnimatedSize)), hasLength(3));
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), new Rect.fromLTWH(0.0, 113.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(2)), new Rect.fromLTWH(0.0, 170.0, 800.0, 0.0));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), new Rect.fromLTWH(0.0, 113.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(2)), new Rect.fromLTWH(0.0, 170.0, 800.0, 0.0));
await tester.pumpWidget(build(false, true, false));
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), new Rect.fromLTWH(0.0, 113.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(2)), new Rect.fromLTWH(0.0, 170.0, 800.0, 0.0));
await tester.pump(kSizeAnimationDuration ~/ 2);
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
final Rect rect1 = tester.getRect(find.byType(AnimatedSize).at(1));
expect(rect1.left, 0.0);
expect(rect1.top, inExclusiveRange(113.0, 113.0 + 16.0 + 32.0)); // 16.0 material gap, plus 16.0 top and bottom margins added to the header
expect(rect1.width, 800.0);
expect(rect1.height, inExclusiveRange(0.0, 100.0));
final Rect rect2 = tester.getRect(find.byType(AnimatedSize).at(2));
expect(rect2, new Rect.fromLTWH(0.0, rect1.bottom + 16.0 + 56.0, 800.0, 0.0)); // the 16.0 comes from the MaterialGap being introduced, the 56.0 is the header height.
await tester.pumpWidget(build(false, false, false));
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1);
expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2);
await tester.pumpWidget(build(false, false, true));
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1);
expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2);
// a few no-op pumps to make sure there's nothing fishy going on
await tester.pump();
await tester.pump();
await tester.pump();
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1);
expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(AnimatedSize).at(0)), new Rect.fromLTWH(0.0, 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(1)), new Rect.fromLTWH(0.0, 56.0 + 1.0 + 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(2)), new Rect.fromLTWH(0.0, 56.0 + 1.0 + 56.0 + 16.0 + 16.0 + 48.0 + 16.0, 800.0, 100.0));
});
} }
...@@ -30,7 +30,7 @@ void main() { ...@@ -30,7 +30,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(buildCount, equals(1)); expect(buildCount, equals(1));
bottomSheet.setState((){ }); bottomSheet.setState(() { });
await tester.pump(); await tester.pump();
expect(buildCount, equals(2)); expect(buildCount, equals(2));
}); });
......
...@@ -98,7 +98,7 @@ void main() { ...@@ -98,7 +98,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
new TestWidget((BuildContext context) { new TestWidget((BuildContext context) {
disposeCalled = true; disposeCalled = true;
context.visitAncestorElements((Element element){ }); context.visitAncestorElements((Element element) { });
}), }),
); );
await tester.pumpWidget(new Container()); await tester.pumpWidget(new Container());
......
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