diff --git a/packages/flutter/lib/src/widgets/animated_cross_fade.dart b/packages/flutter/lib/src/widgets/animated_cross_fade.dart index b64447099f..80297665d5 100644 --- a/packages/flutter/lib/src/widgets/animated_cross_fade.dart +++ b/packages/flutter/lib/src/widgets/animated_cross_fade.dart @@ -151,15 +151,26 @@ class _AnimatedCrossFadeState extends State with TickerProvid } Animation _initAnimation(Curve curve, bool inverted) { - final CurvedAnimation animation = new CurvedAnimation( + Animation animation = new CurvedAnimation( parent: _controller, curve: curve ); - return inverted ? new Tween( - begin: 1.0, - end: 0.0 - ).animate(animation) : animation; + if (inverted) { + animation = new Tween( + begin: 1.0, + end: 0.0 + ).animate(animation); + } + + animation.addStatusListener((AnimationStatus status) { + setState(() { + // Trigger a rebuild because it depends on _isTransitioning, which + // changes its value together with animation status. + }); + }); + + return animation; } @override @@ -189,49 +200,73 @@ class _AnimatedCrossFadeState extends State with TickerProvid } } - @override - Widget build(BuildContext context) { - List children; + /// Whether we're in the middle of cross-fading this frame. + bool get _isTransitioning => _controller.status == AnimationStatus.forward || _controller.status == AnimationStatus.reverse; - if (_controller.status == AnimationStatus.completed || - _controller.status == AnimationStatus.forward) { - children = [ - new FadeTransition( - opacity: _secondAnimation, - child: widget.secondChild, - ), - new Positioned( - // TODO(dragostis): Add a way to crop from top right for - // right-to-left languages. - left: 0.0, - top: 0.0, - right: 0.0, - child: new FadeTransition( - opacity: _firstAnimation, - child: widget.firstChild, - ), - ), - ]; + List _buildCrossFadedChildren() { + const Key kFirstChildKey = const ValueKey(CrossFadeState.showFirst); + const Key kSecondChildKey = const ValueKey(CrossFadeState.showSecond); + final bool transitioningForwards = _controller.status == AnimationStatus.completed || _controller.status == AnimationStatus.forward; + + Key topKey; + Widget topChild; + Animation topAnimation; + Key bottomKey; + Widget bottomChild; + Animation bottomAnimation; + if (transitioningForwards) { + topKey = kSecondChildKey; + topChild = widget.secondChild; + topAnimation = _secondAnimation; + bottomKey = kFirstChildKey; + bottomChild = widget.firstChild; + bottomAnimation = _firstAnimation; } else { - children = [ - new FadeTransition( - opacity: _firstAnimation, - child: widget.firstChild, - ), - new Positioned( - // TODO(dragostis): Add a way to crop from top right for - // right-to-left languages. - left: 0.0, - top: 0.0, - right: 0.0, - child: new FadeTransition( - opacity: _secondAnimation, - child: widget.secondChild, - ), - ), - ]; + topKey = kFirstChildKey; + topChild = widget.firstChild; + topAnimation = _firstAnimation; + bottomKey = kSecondChildKey; + bottomChild = widget.secondChild; + bottomAnimation = _secondAnimation; } + return [ + new TickerMode( + key: bottomKey, + enabled: _isTransitioning, + child: new Positioned( + // TODO(dragostis): Add a way to crop from top right for + // right-to-left languages. + left: 0.0, + top: 0.0, + right: 0.0, + child: new ExcludeSemantics( + excluding: true, // always exclude the semantics of the widget that's fading out + child: new FadeTransition( + opacity: bottomAnimation, + child: bottomChild, + ), + ), + ), + ), + new TickerMode( + key: topKey, + enabled: true, // top widget always has its animations enabled + child: new Positioned( + child: new ExcludeSemantics( + excluding: false, // always publish semantics for the widget that's fading in + child: new FadeTransition( + opacity: topAnimation, + child: topChild, + ), + ), + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { return new ClipRect( child: new AnimatedSize( key: new ValueKey(widget.key), @@ -241,7 +276,7 @@ class _AnimatedCrossFadeState extends State with TickerProvid vsync: this, child: new Stack( overflow: Overflow.visible, - children: children, + children: _buildCrossFadedChildren(), ), ), ); diff --git a/packages/flutter/test/widgets/animated_cross_fade_test.dart b/packages/flutter/test/widgets/animated_cross_fade_test.dart index d0a9dfc434..18c2a2afa8 100644 --- a/packages/flutter/test/widgets/animated_cross_fade_test.dart +++ b/packages/flutter/test/widgets/animated_cross_fade_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -131,4 +132,81 @@ void main() { expect(box2.localToGlobal(Offset.zero), const Offset(275.0, 175.0)); }); + Widget crossFadeWithWatcher({bool towardsSecond: false}) { + return new AnimatedCrossFade( + firstChild: const _TickerWatchingWidget(), + secondChild: new Container(), + crossFadeState: towardsSecond ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 50), + ); + } + + testWidgets('AnimatedCrossFade preserves widget state', (WidgetTester tester) async { + await tester.pumpWidget(crossFadeWithWatcher()); + + _TickerWatchingWidgetState findState() => tester.state(find.byType(_TickerWatchingWidget)); + final _TickerWatchingWidgetState state = findState(); + + await tester.pumpWidget(crossFadeWithWatcher(towardsSecond: true)); + for (int i = 0; i < 3; i += 1) { + await tester.pump(const Duration(milliseconds: 25)); + expect(findState(), same(state)); + } + }); + + testWidgets('AnimatedCrossFade switches off TickerMode and semantics on faded out widget', (WidgetTester tester) async { + ExcludeSemantics findSemantics() { + return tester.widget(find.descendant( + of: find.byKey(const ValueKey(CrossFadeState.showFirst)), + matching: find.byType(ExcludeSemantics), + )); + } + + await tester.pumpWidget(crossFadeWithWatcher()); + + final _TickerWatchingWidgetState state = tester.state(find.byType(_TickerWatchingWidget)); + expect(state.ticker.muted, false); + expect(findSemantics().excluding, false); + + await tester.pumpWidget(crossFadeWithWatcher(towardsSecond: true)); + for (int i = 0; i < 2; i += 1) { + await tester.pump(const Duration(milliseconds: 25)); + // Animations are kept alive in the middle of cross-fade + expect(state.ticker.muted, false); + // Semantics are turned off immediately on the widget that's fading out + expect(findSemantics().excluding, true); + } + + // In the final state both animations and semantics should be off on the + // widget that's faded out. + await tester.pump(const Duration(milliseconds: 25)); + expect(state.ticker.muted, true); + expect(findSemantics().excluding, true); + }); +} + +class _TickerWatchingWidget extends StatefulWidget { + const _TickerWatchingWidget(); + + @override + State createState() => new _TickerWatchingWidgetState(); +} + +class _TickerWatchingWidgetState extends State<_TickerWatchingWidget> with SingleTickerProviderStateMixin { + Ticker ticker; + + @override + void initState() { + super.initState(); + ticker = createTicker((_) {})..start(); + } + + @override + Widget build(BuildContext context) => new Container(); + + @override + void dispose() { + ticker.dispose(); + super.dispose(); + } }