Cupertino nav bar transitions between routes (#20322)

This commit is contained in:
xster 2018-08-28 16:44:34 -07:00 committed by GitHub
parent 05b4bd748c
commit f23c9ae5f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2827 additions and 342 deletions

View File

@ -55,7 +55,11 @@ class _CupertinoRefreshControlDemoState extends State<CupertinoRefreshControlDem
new CupertinoSliverRefreshControl(
onRefresh: () {
return new Future<void>.delayed(const Duration(seconds: 2))
..then((_) => setState(() => repopulateList()));
..then((_) {
if (mounted) {
setState(() => repopulateList());
}
});
},
),
new SliverSafeArea(

View File

@ -320,13 +320,10 @@ class _AlwaysCupertinoScrollBehavior extends ScrollBehavior {
}
class _CupertinoAppState extends State<CupertinoApp> {
HeroController _heroController;
List<NavigatorObserver> _navigatorObservers;
@override
void initState() {
super.initState();
_heroController = new HeroController(); // Linear tweening.
_updateNavigator();
}
@ -342,9 +339,6 @@ class _CupertinoAppState extends State<CupertinoApp> {
widget.routes.isNotEmpty ||
widget.onGenerateRoute != null ||
widget.onUnknownRoute != null;
_navigatorObservers =
new List<NavigatorObserver>.from(widget.navigatorObservers)
..add(_heroController);
}
Widget defaultBuilder(BuildContext context, Widget child) {
@ -361,7 +355,7 @@ class _CupertinoAppState extends State<CupertinoApp> {
routes: widget.routes,
onGenerateRoute: widget.onGenerateRoute,
onUnknownRoute: widget.onUnknownRoute,
navigatorObservers: _navigatorObservers,
navigatorObservers: widget.navigatorObservers,
);
if (widget.builder != null) {
return widget.builder(context, navigator);

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@ import 'route.dart';
/// * [CupertinoTabScaffold], a typical host that supports switching between tabs.
/// * [CupertinoPageRoute], a typical modal page route pushed onto the
/// [CupertinoTabView]'s [Navigator].
class CupertinoTabView extends StatelessWidget {
class CupertinoTabView extends StatefulWidget {
/// Creates the content area for a tab in a [CupertinoTabScaffold].
const CupertinoTabView({
Key key,
@ -101,12 +101,41 @@ class CupertinoTabView extends StatelessWidget {
/// This list of observers is not shared with ancestor or descendant [Navigator]s.
final List<NavigatorObserver> navigatorObservers;
@override
_CupertinoTabViewState createState() {
return new _CupertinoTabViewState();
}
}
class _CupertinoTabViewState extends State<CupertinoTabView> {
HeroController _heroController;
List<NavigatorObserver> _navigatorObservers;
@override
void initState() {
super.initState();
_heroController = new HeroController(); // Linear tweening.
_updateObservers();
}
@override
void didUpdateWidget(CupertinoTabView oldWidget) {
super.didUpdateWidget(oldWidget);
_updateObservers();
}
void _updateObservers() {
_navigatorObservers =
new List<NavigatorObserver>.from(widget.navigatorObservers)
..add(_heroController);
}
@override
Widget build(BuildContext context) {
return new Navigator(
onGenerateRoute: _onGenerateRoute,
onUnknownRoute: _onUnknownRoute,
observers: navigatorObservers,
observers: _navigatorObservers,
);
}
@ -114,12 +143,12 @@ class CupertinoTabView extends StatelessWidget {
final String name = settings.name;
WidgetBuilder routeBuilder;
String title;
if (name == Navigator.defaultRouteName && builder != null) {
routeBuilder = builder;
title = defaultTitle;
if (name == Navigator.defaultRouteName && widget.builder != null) {
routeBuilder = widget.builder;
title = widget.defaultTitle;
}
else if (routes != null)
routeBuilder = routes[name];
else if (widget.routes != null)
routeBuilder = widget.routes[name];
if (routeBuilder != null) {
return new CupertinoPageRoute<dynamic>(
builder: routeBuilder,
@ -127,14 +156,14 @@ class CupertinoTabView extends StatelessWidget {
settings: settings,
);
}
if (onGenerateRoute != null)
return onGenerateRoute(settings);
if (widget.onGenerateRoute != null)
return widget.onGenerateRoute(settings);
return null;
}
Route<dynamic> _onUnknownRoute(RouteSettings settings) {
assert(() {
if (onUnknownRoute == null) {
if (widget.onUnknownRoute == null) {
throw new FlutterError(
'Could not find a generator for route $settings in the $runtimeType.\n'
'Generators for routes are searched for in the following order:\n'
@ -149,7 +178,7 @@ class CupertinoTabView extends StatelessWidget {
}
return true;
}());
final Route<dynamic> result = onUnknownRoute(settings);
final Route<dynamic> result = widget.onUnknownRoute(settings);
assert(() {
if (result == null) {
throw new FlutterError(

View File

@ -20,11 +20,36 @@ import 'transitions.dart';
/// [MaterialRectArcTween].
typedef Tween<Rect> CreateRectTween(Rect begin, Rect end);
/// A function that lets [Hero]s self supply a [Widget] that is shown during the
/// hero's flight from one route to another instead of default (which is to
/// show the destination route's instance of the Hero).
typedef Widget HeroFlightShuttleBuilder(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
);
typedef void _OnFlightEnded(_HeroFlight flight);
enum _HeroFlightType {
push, // Fly the "to" hero and animate with the "to" route.
pop, // Fly the "to" hero and animate with the "from" route.
/// Direction of the hero's flight based on the navigation operation.
enum HeroFlightDirection {
/// A flight triggered by a route push.
///
/// The animation goes from 0 to 1.
///
/// If no custom [HeroFlightShuttleBuilder] is supplied, the top route's
/// [Hero] child is shown in flight.
push,
/// A flight triggered by a route pop.
///
/// The animation goes from 1 to 0.
///
/// If no custom [HeroFlightShuttleBuilder] is supplied, the bottom route's
/// [Hero] child is shown in flight.
pop,
}
// The bounding box for context in global coordinates.
@ -42,8 +67,8 @@ Rect _globalBoundingBoxFor(BuildContext context) {
/// be helpful for orienting the user for the feature to physically move from
/// one page to the other during the routes' transition. Such an animation
/// is called a *hero animation*. The hero widgets "fly" in the Navigator's
/// overlay during the transition and while they're in-flight they're
/// not shown in their original locations in the old and new routes.
/// overlay during the transition and while they're in-flight they're, by
/// default, not shown in their original locations in the old and new routes.
///
/// To label a widget as such a feature, wrap it in a [Hero] widget. When
/// navigation happens, the [Hero] widgets on each route are identified
@ -52,6 +77,9 @@ Rect _globalBoundingBoxFor(BuildContext context) {
///
/// If a [Hero] is already in flight when navigation occurs, its
/// flight animation will be redirected to its new destination.
/// The widget shown in-flight during the transition is, by default, the
/// destination route's [Hero]'s child.
///
/// Routes must not contain more than one [Hero] for each [tag].
///
@ -67,12 +95,24 @@ Rect _globalBoundingBoxFor(BuildContext context) {
///
/// To make the animations look good, it's critical that the widget tree for the
/// hero in both locations be essentially identical. The widget of the *target*
/// is used to do the transition: when going from route A to route B, route B's
/// hero's widget is placed over route A's hero's widget, and route A's hero is
/// hidden. Then the widget is animated to route B's hero's position, and then
/// the widget is inserted into route B. When going back from B to A, route A's
/// hero's widget is placed over where route B's hero's widget was, and then the
/// animation goes the other way.
/// is, by default, used to do the transition: when going from route A to route
/// B, route B's hero's widget is placed over route A's hero's widget. If a
/// [flightShuttleBuilder] is supplied, its output widget is shown during the
/// flight transition instead.
///
/// By default, both route A and route B's heroes are hidden while the
/// transitioning widget is animating in-flight above the 2 routes.
/// [placeholderBuilder] can be used to show a custom widget in their place
/// instead once the transition has taken flight.
///
/// During the transition, the transition widget is animated to route B's hero's
/// position, and then the widget is inserted into route B. When going back from
/// B to A, route A's hero's widget is, by default, placed over where route B's
/// hero's widget was, and then the animation goes the other way.
///
/// ## Parts of a Hero Transition
///
/// ![Diagrams with parts of the Hero transition.](https://flutter.github.io/assets-for-api-docs/assets/interaction/heroes.png)
class Hero extends StatefulWidget {
/// Create a hero.
///
@ -81,6 +121,8 @@ class Hero extends StatefulWidget {
Key key,
@required this.tag,
this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
@required this.child,
}) : assert(tag != null),
assert(child != null),
@ -115,6 +157,25 @@ class Hero extends StatefulWidget {
/// {@macro flutter.widgets.child}
final Widget child;
/// Optional override to supply a widget that's shown during the hero's flight.
///
/// This in-flight widget can depend on the route transition's animation as
/// well as the incoming and outgoing routes' [Hero] descendants' widgets and
/// layout.
///
/// When both the source and destination [Hero]s provide a [flightShuttleBuilder],
/// the destination's [flightShuttleBuilder] takes precedence.
///
/// If none is provided, the destination route's Hero child is shown in-flight
/// by default.
final HeroFlightShuttleBuilder flightShuttleBuilder;
/// Placeholder widget left in place as the Hero's child once the flight takes off.
///
/// By default, an empty SizedBox keeping the Hero child's original size is
/// left in place once the Hero shuttle has taken flight.
final TransitionBuilder placeholderBuilder;
// Returns a map of all of the heroes in context, indexed by hero tag.
static Map<Object, _HeroState> _allHeroesFor(BuildContext context) {
assert(context != null);
@ -141,6 +202,10 @@ class Hero extends StatefulWidget {
final _HeroState heroState = hero.state;
result[tag] = heroState;
}
// Don't perform transitions across different Navigators.
if (element.widget is Navigator) {
return;
}
element.visitChildren(visitor);
}
context.visitChildElements(visitor);
@ -181,10 +246,14 @@ class _HeroState extends State<Hero> {
@override
Widget build(BuildContext context) {
if (_placeholderSize != null) {
return new SizedBox(
width: _placeholderSize.width,
height: _placeholderSize.height
);
if (widget.placeholderBuilder == null) {
return new SizedBox(
width: _placeholderSize.width,
height: _placeholderSize.height
);
} else {
return widget.placeholderBuilder(context, widget.child);
}
}
return new KeyedSubtree(
key: _key,
@ -204,9 +273,10 @@ class _HeroFlightManifest {
@required this.fromHero,
@required this.toHero,
@required this.createRectTween,
@required this.shuttleBuilder,
}) : assert(fromHero.widget.tag == toHero.widget.tag);
final _HeroFlightType type;
final HeroFlightDirection type;
final OverlayState overlay;
final Rect navigatorRect;
final PageRoute<dynamic> fromRoute;
@ -214,19 +284,21 @@ class _HeroFlightManifest {
final _HeroState fromHero;
final _HeroState toHero;
final CreateRectTween createRectTween;
final HeroFlightShuttleBuilder shuttleBuilder;
Object get tag => fromHero.widget.tag;
Animation<double> get animation {
return new CurvedAnimation(
parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation,
parent: (type == HeroFlightDirection.push) ? toRoute.animation : fromRoute.animation,
curve: Curves.fastOutSlowIn,
);
}
@override
String toString() {
return '_HeroFlightManifest($type hero: $tag from: ${fromRoute.settings} to: ${toRoute.settings})';
return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
'to route: ${toRoute.settings} with hero: $fromHero to $toHero)';
}
}
@ -238,7 +310,9 @@ class _HeroFlight {
final _OnFlightEnded onFlightEnded;
Tween<Rect> heroRect;
Tween<Rect> heroRectTween;
Widget shuttle;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest;
@ -255,9 +329,18 @@ class _HeroFlight {
// The OverlayEntry WidgetBuilder callback for the hero's overlay.
Widget _buildOverlay(BuildContext context) {
assert(manifest != null);
shuttle ??= manifest.shuttleBuilder(
context,
manifest.animation,
manifest.type,
manifest.fromHero.context,
manifest.toHero.context,
);
assert(shuttle != null);
return new AnimatedBuilder(
animation: _proxyAnimation,
child: manifest.toHero.widget,
child: shuttle,
builder: (BuildContext context, Widget child) {
final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject();
if (_aborted || toHeroBox == null || !toHeroBox.attached) {
@ -273,13 +356,13 @@ class _HeroFlight {
// supposed to end up then recreate the heroRect tween.
final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (toHeroOrigin != heroRect.end.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRect.end.size;
heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd);
if (toHeroOrigin != heroRectTween.end.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRectTween.end.size;
heroRectTween = _doCreateRectTween(heroRectTween.begin, heroRectEnd);
}
}
final Rect rect = heroRect.evaluate(_proxyAnimation);
final Rect rect = heroRectTween.evaluate(_proxyAnimation);
final Size size = manifest.navigatorRect.size;
final RelativeRect offsets = new RelativeRect.fromSize(rect, size);
@ -291,7 +374,6 @@ class _HeroFlight {
child: new IgnorePointer(
child: new RepaintBoundary(
child: new Opacity(
key: manifest.toHero._key,
opacity: _heroOpacity.value,
child: child,
),
@ -322,12 +404,12 @@ class _HeroFlight {
assert(() {
final Animation<double> initial = initialManifest.animation;
assert(initial != null);
final _HeroFlightType type = initialManifest.type;
final HeroFlightDirection type = initialManifest.type;
assert(type != null);
switch (type) {
case _HeroFlightType.pop:
case HeroFlightDirection.pop:
return initial.value == 1.0 && initial.status == AnimationStatus.reverse;
case _HeroFlightType.push:
case HeroFlightDirection.push:
return initial.value == 0.0 && initial.status == AnimationStatus.forward;
}
return null;
@ -335,7 +417,7 @@ class _HeroFlight {
manifest = initialManifest;
if (manifest.type == _HeroFlightType.pop)
if (manifest.type == HeroFlightDirection.pop)
_proxyAnimation.parent = new ReverseAnimation(manifest.animation);
else
_proxyAnimation.parent = manifest.animation;
@ -343,7 +425,7 @@ class _HeroFlight {
manifest.fromHero.startFlight();
manifest.toHero.startFlight();
heroRect = _doCreateRectTween(
heroRectTween = _doCreateRectTween(
_globalBoundingBoxFor(manifest.fromHero.context),
_globalBoundingBoxFor(manifest.toHero.context),
);
@ -357,7 +439,7 @@ class _HeroFlight {
void divert(_HeroFlightManifest newManifest) {
assert(manifest.tag == newManifest.tag);
if (manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.pop) {
if (manifest.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) {
// A push flight was interrupted by a pop.
assert(newManifest.animation.status == AnimationStatus.reverse);
assert(manifest.fromHero == newManifest.toHero);
@ -371,8 +453,8 @@ class _HeroFlight {
// path for swapped begin and end parameters. We want the pop flight
// path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
heroRect = new ReverseTween<Rect>(heroRect);
} else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) {
heroRectTween = new ReverseTween<Rect>(heroRectTween);
} else if (manifest.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) {
// A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward);
assert(manifest.toHero == newManifest.fromHero);
@ -386,10 +468,10 @@ class _HeroFlight {
if (manifest.fromHero != newManifest.toHero) {
manifest.fromHero.endFlight();
newManifest.toHero.startFlight();
heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context));
heroRectTween = _doCreateRectTween(heroRectTween.end, _globalBoundingBoxFor(newManifest.toHero.context));
} else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRect = _doCreateRectTween(heroRect.end, heroRect.begin);
heroRectTween = _doCreateRectTween(heroRectTween.end, heroRectTween.begin);
}
} else {
// A push or a pop flight is heading to a new route, i.e.
@ -398,17 +480,24 @@ class _HeroFlight {
assert(manifest.fromHero != newManifest.fromHero);
assert(manifest.toHero != newManifest.toHero);
heroRect = _doCreateRectTween(heroRect.evaluate(_proxyAnimation), _globalBoundingBoxFor(newManifest.toHero.context));
heroRectTween = _doCreateRectTween(heroRectTween.evaluate(_proxyAnimation), _globalBoundingBoxFor(newManifest.toHero.context));
shuttle = null;
if (newManifest.type == _HeroFlightType.pop)
if (newManifest.type == HeroFlightDirection.pop)
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
else
_proxyAnimation.parent = newManifest.animation;
manifest.fromHero.endFlight();
manifest.toHero.endFlight();
// Let the heroes in each of the routes rebuild with their placeholders.
newManifest.fromHero.startFlight();
newManifest.toHero.startFlight();
// Let the transition overlay on top of the routes also rebuild since
// we cleared the old shuttle.
overlayEntry.markNeedsBuild();
}
_aborted = false;
@ -455,14 +544,14 @@ class HeroController extends NavigatorObserver {
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(previousRoute, route, _HeroFlightType.push);
_maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push);
}
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(route, previousRoute, _HeroFlightType.pop);
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop);
}
@override
@ -477,14 +566,14 @@ class HeroController extends NavigatorObserver {
// If we're transitioning between different page routes, start a hero transition
// after the toRoute has been laid out with its animation's value at 1.0.
void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, _HeroFlightType flightType) {
void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, HeroFlightDirection flightType) {
if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
final PageRoute<dynamic> from = fromRoute;
final PageRoute<dynamic> to = toRoute;
final Animation<double> animation = (flightType == _HeroFlightType.push) ? to.animation : from.animation;
final Animation<double> animation = (flightType == HeroFlightDirection.push) ? to.animation : from.animation;
// A user gesture may have already completed the pop.
if (flightType == _HeroFlightType.pop && animation.status == AnimationStatus.dismissed)
if (flightType == HeroFlightDirection.pop && animation.status == AnimationStatus.dismissed)
return;
// Putting a route offstage changes its animation value to 1.0. Once this
@ -493,14 +582,19 @@ class HeroController extends NavigatorObserver {
to.offstage = to.animation.value == 0.0;
WidgetsBinding.instance.addPostFrameCallback((Duration value) {
_startHeroTransition(from, to, flightType);
_startHeroTransition(from, to, animation, flightType);
});
}
}
// Find the matching pairs of heros in from and to and either start or a new
// hero flight, or divert an existing one.
void _startHeroTransition(PageRoute<dynamic> from, PageRoute<dynamic> to, _HeroFlightType flightType) {
void _startHeroTransition(
PageRoute<dynamic> from,
PageRoute<dynamic> to,
Animation<double> animation,
HeroFlightDirection flightType,
) {
// If the navigator or one of the routes subtrees was removed before this
// end-of-frame callback was called, then don't actually start a transition.
if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
@ -520,6 +614,9 @@ class HeroController extends NavigatorObserver {
for (Object tag in fromHeroes.keys) {
if (toHeroes[tag] != null) {
final HeroFlightShuttleBuilder fromShuttleBuilder = fromHeroes[tag].widget.flightShuttleBuilder;
final HeroFlightShuttleBuilder toShuttleBuilder = toHeroes[tag].widget.flightShuttleBuilder;
final _HeroFlightManifest manifest = new _HeroFlightManifest(
type: flightType,
overlay: navigator.overlay,
@ -529,7 +626,10 @@ class HeroController extends NavigatorObserver {
fromHero: fromHeroes[tag],
toHero: toHeroes[tag],
createRectTween: createRectTween,
shuttleBuilder:
toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder,
);
if (_flights[tag] != null)
_flights[tag].divert(manifest);
else
@ -543,4 +643,15 @@ class HeroController extends NavigatorObserver {
void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest.tag);
}
static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final Hero toHero = toHeroContext.widget;
return toHero.child;
};
}

View File

@ -127,6 +127,24 @@ class BorderRadiusTween extends Tween<BorderRadius> {
BorderRadius lerp(double t) => BorderRadius.lerp(begin, end, t);
}
/// An interpolation between two [Border]s.
///
/// This class specializes the interpolation of [Tween<Border>] to use
/// [Border.lerp].
///
/// See [Tween] for a discussion on how to use interpolation objects.
class BorderTween extends Tween<Border> {
/// Creates a [Border] tween.
///
/// The [begin] and [end] properties may be null; the null value
/// is treated as having no border.
BorderTween({ Border begin, Border end }) : super(begin: begin, end: end);
/// Returns the value this variable has at the given animation clock value.
@override
Border lerp(double t) => Border.lerp(begin, end, t);
}
/// An interpolation between two [Matrix4]s.
///
/// This class specializes the interpolation of [Tween<Matrix4>] to be

View File

@ -188,6 +188,7 @@ class _SliverPersistentHeaderElement extends RenderObjectElement {
@override
void performRebuild() {
super.performRebuild();
renderObject.triggerRebuild();
}

View File

@ -10,6 +10,7 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4;
import 'basic.dart';
import 'container.dart';
import 'framework.dart';
import 'text.dart';
export 'package:flutter/rendering.dart' show RelativeRect;
@ -671,6 +672,64 @@ class AlignTransition extends AnimatedWidget {
}
}
/// Animated version of a [DefaultTextStyle] that animates the different properties
/// of its [TextStyle].
///
/// See also:
///
/// * [DefaultTextStyle], which also defines a [TextStyle] for its descendants
/// but is not animated.
class DefaultTextStyleTransition extends AnimatedWidget {
/// Creates an animated [DefaultTextStyle] whose [TextStyle] animation updates
/// the widget.
const DefaultTextStyleTransition({
Key key,
@required Animation<TextStyle> style,
@required this.child,
this.textAlign,
this.softWrap = true,
this.overflow = TextOverflow.clip,
this.maxLines,
}) : super(key: key, listenable: style);
/// The animation that controls the descendants' text style.
Animation<TextStyle> get style => listenable;
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// Whether the text should break at soft line breaks.
///
/// See [DefaultTextStyle.softWrap] for more details.
final bool softWrap;
/// How visual overflow should be handled.
///
final TextOverflow overflow;
/// An optional maximum number of lines for the text to span, wrapping if necessary.
///
/// See [DefaultTextStyle.maxLines] for more details.
final int maxLines;
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.child}
final Widget child;
@override
Widget build(BuildContext context) {
return new DefaultTextStyle(
style: style.value,
textAlign: textAlign,
softWrap: softWrap,
overflow: overflow,
maxLines: maxLines,
child: child,
);
}
}
/// A general-purpose widget for building animations.
///
/// AnimatedBuilder is useful for more complex widgets that wish to include

View File

@ -306,6 +306,65 @@ void main() {
expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 0.0);
});
testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
final Key segmentedControlsKey = new UniqueKey();
await tester.pumpWidget(
new CupertinoApp(
home: new CupertinoPageScaffold(
child: new CustomScrollView(
controller: scrollController,
slivers: <Widget>[
new CupertinoSliverNavigationBar(
middle: new ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200.0),
child: new CupertinoSegmentedControl<int>(
key: segmentedControlsKey,
children: const <int, Widget>{
0: Text('Option A'),
1: Text('Option B'),
},
onValueChanged: (int selected) { },
groupValue: 0,
),
),
largeTitle: const Text('Title'),
),
new SliverToBoxAdapter(
child: new Container(
height: 1200.0,
),
),
],
),
),
),
);
expect(scrollController.offset, 0.0);
expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0);
expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0);
expect(find.text('Title'), findsOneWidget);
expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0);
expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 52.0);
scrollController.jumpTo(600.0);
await tester.pump(); // Once to trigger the opacity animation.
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0);
// The large title is invisible now.
expect(
tester.renderObject<RenderAnimatedOpacity>(
find.widgetWithText(AnimatedOpacity, 'Title')
).opacity.value,
0.0,
);
});
testWidgets('Small title can be overridden', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
@ -390,7 +449,7 @@ void main() {
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoButton), findsOneWidget);
expect(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget);
@ -405,23 +464,22 @@ void main() {
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoButton), findsNWidgets(2));
expect(find.text('Close'), findsOneWidget);
expect(find.widgetWithText(CupertinoButton, 'Close'), findsOneWidget);
// Test popping goes back correctly.
await tester.tap(find.text('Close'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Page 2'), findsOneWidget);
await tester.tap(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Home page'), findsOneWidget);
});
@ -438,7 +496,7 @@ void main() {
builder: (BuildContext context) {
return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
previousPageTitle: '0123456789',
previousPageTitle: '012345678901',
),
child: Placeholder(),
);
@ -449,14 +507,14 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.widgetWithText(CupertinoButton, '0123456789'), findsOneWidget);
expect(find.widgetWithText(CupertinoButton, '012345678901'), findsOneWidget);
tester.state<NavigatorState>(find.byType(Navigator)).push(
new CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
previousPageTitle: '01234567890',
previousPageTitle: '0123456789012',
),
child: Placeholder(),
);

View File

@ -0,0 +1,811 @@
// Copyright 2018 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/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> startTransitionBetween(
WidgetTester tester, {
Widget from,
Widget to,
String fromTitle,
String toTitle,
}) async {
await tester.pumpWidget(
new CupertinoApp(
home: const Placeholder(),
),
);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: fromTitle,
builder: (BuildContext context) => scaffoldForNavBar(from),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: toTitle,
builder: (BuildContext context) => scaffoldForNavBar(to),
));
await tester.pump();
}
CupertinoPageScaffold scaffoldForNavBar(Widget navBar) {
if (navBar is CupertinoNavigationBar || navBar == null) {
return new CupertinoPageScaffold(
navigationBar: navBar ?? const CupertinoNavigationBar(),
child: const Placeholder(),
);
} else if (navBar is CupertinoSliverNavigationBar) {
return new CupertinoPageScaffold(
child: new CustomScrollView(
slivers: <Widget>[
navBar,
// Add filler so it's scrollable.
const SliverToBoxAdapter(
child: Placeholder(fallbackHeight: 1000.0),
),
],
),
);
}
assert(false, 'Unexpected nav bar type ${navBar.runtimeType}');
return null;
}
Finder flying(WidgetTester tester, Finder finder) {
final RenderObjectWithChildMixin<RenderStack> theater =
tester.renderObject(find.byType(Overlay));
final RenderStack theaterStack = theater.child;
final Finder lastOverlayFinder = find.byElementPredicate((Element element) {
return element is RenderObjectElement &&
element.renderObject == theaterStack.lastChild;
});
assert(
find
.descendant(
of: lastOverlayFinder,
matching: find.byWidgetPredicate(
(Widget widget) =>
widget.runtimeType.toString() ==
'_NavigationBarTransition',
),
)
.evaluate()
.length ==
1,
'The last overlay in the navigator was not a flying hero',);
return find.descendant(
of: lastOverlayFinder,
matching: finder,
);
}
void checkBackgroundBoxHeight(WidgetTester tester, double height) {
final Widget transitionBackgroundBox =
tester.widget<Stack>(flying(tester, find.byType(Stack))).children[0];
expect(
tester
.widget<SizedBox>(
find.descendant(
of: find.byWidget(transitionBackgroundBox),
matching: find.byType(SizedBox),
),
)
.height,
height,
);
}
void checkOpacity(WidgetTester tester, Finder finder, double opacity) {
expect(
tester
.renderObject<RenderAnimatedOpacity>(find.ancestor(
of: finder,
matching: find.byType(FadeTransition),
))
.opacity
.value,
opacity,
);
}
void main() {
testWidgets('Bottom middle moves between middle and back label',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
// Be mid-transition.
await tester.pump(const Duration(milliseconds: 50));
// There's 2 of them. One from the top route's back label and one from the
// bottom route's middle widget.
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
// Since they have the same text, they should be more or less at the same
// place.
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).first),
const Offset(331.0724935531616, 13.5),
);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).last),
const Offset(331.0724935531616, 13.5),
);
});
testWidgets('Bottom middle and top back label transitions their font',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
// Be mid-transition.
await tester.pump(const Duration(milliseconds: 50));
// The transition's stack is ordered. The bottom middle is inserted first.
final RenderParagraph bottomMiddle =
tester.renderObject(flying(tester, find.text('Page 1')).first);
expect(bottomMiddle.text.style.color, const Color(0xFF00070F));
expect(bottomMiddle.text.style.fontWeight, FontWeight.w600);
expect(bottomMiddle.text.style.fontFamily, '.SF UI Text');
expect(bottomMiddle.text.style.letterSpacing, -0.08952957153320312);
checkOpacity(
tester, flying(tester, find.text('Page 1')).first, 0.8609542846679688);
// The top back label is styled exactly the same way. But the opacity tweens
// are flipped.
final RenderParagraph topBackLabel =
tester.renderObject(flying(tester, find.text('Page 1')).last);
expect(topBackLabel.text.style.color, const Color(0xFF00070F));
expect(topBackLabel.text.style.fontWeight, FontWeight.w600);
expect(topBackLabel.text.style.fontFamily, '.SF UI Text');
expect(topBackLabel.text.style.letterSpacing, -0.08952957153320312);
checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0);
// Move animation further a bit.
await tester.pump(const Duration(milliseconds: 200));
expect(bottomMiddle.text.style.color, const Color(0xFF0073F0));
expect(bottomMiddle.text.style.fontWeight, FontWeight.w400);
expect(bottomMiddle.text.style.fontFamily, '.SF UI Text');
expect(bottomMiddle.text.style.letterSpacing, -0.231169798374176);
checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0);
expect(topBackLabel.text.style.color, const Color(0xFF0073F0));
expect(topBackLabel.text.style.fontWeight, FontWeight.w400);
expect(topBackLabel.text.style.fontFamily, '.SF UI Text');
expect(topBackLabel.text.style.letterSpacing, -0.231169798374176);
checkOpacity(
tester, flying(tester, find.text('Page 1')).last, 0.8733493089675903);
});
testWidgets('Fullscreen dialogs do not create heroes',
(WidgetTester tester) async {
await tester.pumpWidget(
new CupertinoApp(
home: const Placeholder(),
),
);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: 'Page 1',
builder: (BuildContext context) => scaffoldForNavBar(null),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: 'Page 2',
fullscreenDialog: true,
builder: (BuildContext context) => scaffoldForNavBar(null),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Only the first (non-fullscreen-dialog) page has a Hero.
expect(find.byType(Hero), findsOneWidget);
// No Hero transition happened.
expect(() => flying(tester, find.text('Page 2')), throwsAssertionError);
});
testWidgets('Turning off transition works', (WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoNavigationBar(
transitionBetweenRoutes: false,
middle: Text('Page 1'),
),
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
// Only the second page that doesn't have the transitionBetweenRoutes
// override off has a Hero.
expect(find.byType(Hero), findsOneWidget);
expect(
find.descendant(of: find.byType(Hero), matching: find.text('Page 2')),
findsOneWidget,
);
// No Hero transition happened.
expect(() => flying(tester, find.text('Page 2')), throwsAssertionError);
});
testWidgets('Popping mid-transition is symmetrical',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
// Be mid-transition.
await tester.pump(const Duration(milliseconds: 50));
void checkColorAndPositionAt50ms() {
// The transition's stack is ordered. The bottom middle is inserted first.
final RenderParagraph bottomMiddle =
tester.renderObject(flying(tester, find.text('Page 1')).first);
expect(bottomMiddle.text.style.color, const Color(0xFF00070F));
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).first),
const Offset(331.0724935531616, 13.5),
);
// The top back label is styled exactly the same way. But the opacity tweens
// are flipped.
final RenderParagraph topBackLabel =
tester.renderObject(flying(tester, find.text('Page 1')).last);
expect(topBackLabel.text.style.color, const Color(0xFF00070F));
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).last),
const Offset(331.0724935531616, 13.5),
);
}
checkColorAndPositionAt50ms();
// Advance more.
await tester.pump(const Duration(milliseconds: 100));
// Pop and reverse the same amount of time.
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Check that everything's the same as on the way in.
checkColorAndPositionAt50ms();
});
testWidgets('There should be no global keys in the hero flight',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
// Be mid-transition.
await tester.pump(const Duration(milliseconds: 50));
expect(
flying(
tester,
find.byWidgetPredicate((Widget widget) => widget.key != null),
),
findsNothing,
);
});
testWidgets('Transition box grows to large title size',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
fromTitle: 'Page 1',
to: const CupertinoSliverNavigationBar(),
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 47.097110748291016);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 61.0267448425293);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 78.68475294113159);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 88.32722091674805);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 93.13018447160721);
});
testWidgets('Large transition box shrinks to standard nav bar size',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 92.90288925170898);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 78.9732551574707);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 61.31524705886841);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 51.67277908325195);
await tester.pump(const Duration(milliseconds: 50));
checkBackgroundBoxHeight(tester, 46.86981552839279);
});
testWidgets('Hero flight removed at the end of page transition',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
await tester.pump(const Duration(milliseconds: 50));
// There's 2 of them. One from the top route's back label and one from the
// bottom route's middle widget.
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
// End the transition.
await tester.pump(const Duration(milliseconds: 500));
expect(() => flying(tester, find.text('Page 1')), throwsAssertionError);
});
testWidgets('Exact widget is reused to build inside the transition',
(WidgetTester tester) async {
const Widget userMiddle = Placeholder();
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(
middle: userMiddle,
),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.byWidget(userMiddle)), findsOneWidget);
});
testWidgets('First appearance of back chevron fades in from the right',
(WidgetTester tester) async {
await tester.pumpWidget(
new CupertinoApp(
home: scaffoldForNavBar(null),
),
);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: 'Page 1',
builder: (BuildContext context) => scaffoldForNavBar(null),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final Finder backChevron = flying(tester,
find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)));
expect(
backChevron,
// Only one exists from the top page. The bottom page has no back chevron.
findsOneWidget,
);
// Come in from the right and fade in.
checkOpacity(tester, backChevron, 0.0);
expect(
tester.getTopLeft(backChevron), const Offset(71.94993209838867, 5.0));
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, backChevron, 0.32467134296894073);
expect(
tester.getTopLeft(backChevron), const Offset(18.033634185791016, 5.0));
});
testWidgets('Back chevron fades out and in when both pages have it',
(WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
await tester.pump(const Duration(milliseconds: 50));
final Finder backChevrons = flying(tester,
find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)));
expect(
backChevrons,
findsNWidgets(2),
);
checkOpacity(tester, backChevrons.first, 0.8393326997756958);
checkOpacity(tester, backChevrons.last, 0.0);
// Both overlap at the same place.
expect(tester.getTopLeft(backChevrons.first), const Offset(8.0, 5.0));
expect(tester.getTopLeft(backChevrons.last), const Offset(8.0, 5.0));
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, backChevrons.first, 0.0);
checkOpacity(tester, backChevrons.last, 0.6276369094848633);
// Still in the same place.
expect(tester.getTopLeft(backChevrons.first), const Offset(8.0, 5.0));
expect(tester.getTopLeft(backChevrons.last), const Offset(8.0, 5.0));
});
testWidgets('Bottom middle just fades if top page has a custom leading',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
fromTitle: 'Page 1',
to: const CupertinoSliverNavigationBar(
leading: Text('custom'),
),
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
// There's just 1 in flight because there's no back label on the top page.
expect(flying(tester, find.text('Page 1')), findsOneWidget);
checkOpacity(
tester, flying(tester, find.text('Page 1')), 0.8609542846679688);
// The middle widget doesn't move.
expect(
tester.getCenter(flying(tester, find.text('Page 1'))),
const Offset(400.0, 22.0),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0);
expect(
tester.getCenter(flying(tester, find.text('Page 1'))),
const Offset(400.0, 22.0),
);
});
testWidgets('Bottom leading fades in place', (WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(leading: Text('custom')),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('custom')), findsOneWidget);
checkOpacity(
tester, flying(tester, find.text('custom')), 0.7655444294214249);
expect(
tester.getTopLeft(flying(tester, find.text('custom'))),
const Offset(16.0, 0.0),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('custom')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('custom'))),
const Offset(16.0, 0.0),
);
});
testWidgets('Bottom trailing fades in place', (WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(trailing: Text('custom')),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('custom')), findsOneWidget);
checkOpacity(
tester, flying(tester, find.text('custom')), 0.8393326997756958);
expect(
tester.getTopLeft(flying(tester, find.text('custom'))),
const Offset(683.0, 13.5),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('custom')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('custom'))),
const Offset(683.0, 13.5),
);
});
testWidgets('Bottom back label fades and slides to the left',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 500));
tester
.state<NavigatorState>(find.byType(Navigator))
.push(new CupertinoPageRoute<void>(
title: 'Page 3',
builder: (BuildContext context) => scaffoldForNavBar(null),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// 'Page 1' appears once on Page 2 as the back label.
expect(flying(tester, find.text('Page 1')), findsOneWidget);
// Back label fades out faster.
checkOpacity(
tester, flying(tester, find.text('Page 1')), 0.5584745407104492);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1'))),
const Offset(24.176071166992188, 13.5),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1'))),
const Offset(-292.97862243652344, 13.5),
);
});
testWidgets('Bottom large title moves to top back label',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
// There's 2, one from the bottom large title fading out and one from the
// bottom back label fading in.
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
checkOpacity(
tester, flying(tester, find.text('Page 1')).first, 0.8393326997756958);
checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).first),
const Offset(17.905914306640625, 51.58156871795654),
);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).last),
const Offset(17.905914306640625, 51.58156871795654),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0);
checkOpacity(
tester, flying(tester, find.text('Page 1')).last, 0.6276369094848633);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).first),
const Offset(43.278289794921875, 19.23011875152588),
);
expect(
tester.getTopLeft(flying(tester, find.text('Page 1')).last),
const Offset(43.278289794921875, 19.23011875152588),
);
});
testWidgets('Long title turns into the word back mid transition',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
fromTitle: 'A title too long to fit',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(
flying(tester, find.text('A title too long to fit')), findsOneWidget);
// Automatically changed to the word 'Back' in the back label.
expect(flying(tester, find.text('Back')), findsOneWidget);
checkOpacity(tester, flying(tester, find.text('A title too long to fit')),
0.8393326997756958);
checkOpacity(tester, flying(tester, find.text('Back')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('A title too long to fit'))),
const Offset(17.905914306640625, 51.58156871795654),
);
expect(
tester.getTopLeft(flying(tester, find.text('Back'))),
const Offset(17.905914306640625, 51.58156871795654),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(
tester, flying(tester, find.text('A title too long to fit')), 0.0);
checkOpacity(tester, flying(tester, find.text('Back')), 0.6276369094848633);
expect(
tester.getTopLeft(flying(tester, find.text('A title too long to fit'))),
const Offset(43.278289794921875, 19.23011875152588),
);
expect(
tester.getTopLeft(flying(tester, find.text('Back'))),
const Offset(43.278289794921875, 19.23011875152588),
);
});
testWidgets('Bottom large title and top back label transitions their font',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
fromTitle: 'Page 1',
);
// Be mid-transition.
await tester.pump(const Duration(milliseconds: 50));
// The transition's stack is ordered. The bottom large title is inserted first.
final RenderParagraph bottomLargeTitle =
tester.renderObject(flying(tester, find.text('Page 1')).first);
expect(bottomLargeTitle.text.style.color, const Color(0xFF00070F));
expect(bottomLargeTitle.text.style.fontWeight, FontWeight.w700);
expect(bottomLargeTitle.text.style.fontFamily, '.SF Pro Display');
expect(bottomLargeTitle.text.style.letterSpacing, 0.21141128540039061);
// The top back label is styled exactly the same way.
final RenderParagraph topBackLabel =
tester.renderObject(flying(tester, find.text('Page 1')).last);
expect(topBackLabel.text.style.color, const Color(0xFF00070F));
expect(topBackLabel.text.style.fontWeight, FontWeight.w700);
expect(topBackLabel.text.style.fontFamily, '.SF Pro Display');
expect(topBackLabel.text.style.letterSpacing, 0.21141128540039061);
// Move animation further a bit.
await tester.pump(const Duration(milliseconds: 200));
expect(bottomLargeTitle.text.style.color, const Color(0xFF0073F0));
expect(bottomLargeTitle.text.style.fontWeight, FontWeight.w400);
expect(bottomLargeTitle.text.style.fontFamily, '.SF UI Text');
expect(bottomLargeTitle.text.style.letterSpacing, -0.2135093951225281);
expect(topBackLabel.text.style.color, const Color(0xFF0073F0));
expect(topBackLabel.text.style.fontWeight, FontWeight.w400);
expect(topBackLabel.text.style.fontFamily, '.SF UI Text');
expect(topBackLabel.text.style.letterSpacing, -0.2135093951225281);
});
testWidgets('Top middle fades in and slides in from the right',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('Page 2')), findsOneWidget);
checkOpacity(
tester, flying(tester, find.text('Page 2')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(725.1760711669922, 13.5),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(
tester, flying(tester, find.text('Page 2')), 0.6972532719373703);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(408.02137756347656, 13.5),
);
});
testWidgets('Top large title fades in and slides in from the right',
(WidgetTester tester) async {
await startTransitionBetween(
tester,
to: const CupertinoSliverNavigationBar(),
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('Page 2')), findsOneWidget);
checkOpacity(
tester, flying(tester, find.text('Page 2')), 0.0);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(768.3521423339844, 54.0),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(
tester, flying(tester, find.text('Page 2')), 0.6753286570310593);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(134.04275512695312, 54.0),
);
});
testWidgets('Components are not unnecessarily rebuilt during transitions',
(WidgetTester tester) async {
int bottomBuildTimes = 0;
int topBuildTimes = 0;
await startTransitionBetween(
tester,
from: new CupertinoNavigationBar(
middle: new Builder(builder: (BuildContext context) {
bottomBuildTimes++;
return const Text('Page 1');
}),
),
to: new CupertinoSliverNavigationBar(
largeTitle: new Builder(builder: (BuildContext context) {
topBuildTimes++;
return const Text('Page 2');
}),
),
);
expect(bottomBuildTimes, 1);
// RenderSliverPersistentHeader.layoutChild causes 2 builds.
expect(topBuildTimes, 2);
await tester.pump();
// The shuttle builder builds the component widgets one more time.
expect(bottomBuildTimes, 2);
expect(topBuildTimes, 3);
// Subsequent animation needs to use reprojection of children.
await tester.pump();
expect(bottomBuildTimes, 2);
expect(topBuildTimes, 3);
await tester.pump(const Duration(milliseconds: 100));
expect(bottomBuildTimes, 2);
expect(topBuildTimes, 3);
// Finish animations.
await tester.pump(const Duration(milliseconds: 400));
expect(bottomBuildTimes, 2);
expect(topBuildTimes, 3);
});
}

View File

@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@ -36,6 +37,71 @@ void main() {
expect(tester.getCenter(find.text('An iPod')).dx, 400.0);
});
testWidgets('Large title auto-populates with title', (WidgetTester tester) async {
await tester.pumpWidget(
new CupertinoApp(
home: const Placeholder(),
),
);
tester.state<NavigatorState>(find.byType(Navigator)).push(
new CupertinoPageRoute<void>(
title: 'An iPod',
builder: (BuildContext context) {
return new CupertinoPageScaffold(
child: new CustomScrollView(
slivers: const <Widget>[
CupertinoSliverNavigationBar(),
],
),
);
}
)
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// There should be 2 Text widget with the title in the nav bar. One in the
// large title position and one in the middle position (though the middle
// position Text is initially invisible while the sliver is expanded).
expect(
find.widgetWithText(CupertinoSliverNavigationBar, 'An iPod'),
findsNWidgets(2),
);
final List<Element> titles = tester.elementList(find.text('An iPod'))
.toList()
..sort((Element a, Element b) {
final RenderParagraph aParagraph = a.renderObject;
final RenderParagraph bParagraph = b.renderObject;
return aParagraph.text.style.fontSize.compareTo(
bParagraph.text.style.fontSize
);
});
final Iterable<double> opacities = titles.map((Element element) {
final RenderAnimatedOpacity renderOpacity =
element.ancestorRenderObjectOfType(const TypeMatcher<RenderAnimatedOpacity>());
return renderOpacity.opacity.value;
});
expect(opacities, <double> [
0.0, // Initially the smaller font title is invisible.
1.0, // The larger font title is visible.
]);
// Check that the large font title is at the right spot.
expect(
tester.getTopLeft(find.byWidget(titles[1].widget)),
const Offset(16.0, 54.0),
);
// The smaller, initially invisible title, should still be positioned in the
// center.
expect(tester.getCenter(find.byWidget(titles[0].widget)).dx, 400.0);
});
testWidgets('Leading auto-populates with back button with previous title', (WidgetTester tester) async {
await tester.pumpWidget(
new CupertinoApp(

View File

@ -239,7 +239,7 @@ void main() {
// Navigate in tab 2.
await tester.tap(find.text('Next'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Page 2 of tab 2'), isOnstage);
expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage);
@ -254,7 +254,7 @@ void main() {
// Navigate in tab 1.
await tester.tap(find.text('Next'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Page 2 of tab 1'), isOnstage);
expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage);
@ -268,7 +268,7 @@ void main() {
// Pop in tab 2
await tester.tap(find.text('Back'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Page 1 of tab 2'), isOnstage);
expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage);

View File

@ -1244,4 +1244,95 @@ void main() {
await tester.pump(duration * 0.1);
expect(tester.getTopLeft(find.byKey(firstKey)).dx, x0);
});
testWidgets('Can override flight shuttle', (WidgetTester tester) async {
await tester.pumpWidget(new MaterialApp(
home: new Material(
child: new ListView(
children: <Widget>[
const Hero(tag: 'a', child: Text('foo')),
new Builder(builder: (BuildContext context) {
return new FlatButton(
child: const Text('two'),
onPressed: () => Navigator.push<void>(context, new MaterialPageRoute<void>(
builder: (BuildContext context) {
return new Material(
child: new Hero(
tag: 'a',
child: const Text('bar'),
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return const Text('baz');
},
),
);
},
)),
);
}),
],
),
),
));
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('foo'), findsNothing);
expect(find.text('bar'), findsNothing);
expect(find.text('baz'), findsOneWidget);
});
testWidgets('Can override flight launch pads', (WidgetTester tester) async {
await tester.pumpWidget(new MaterialApp(
home: new Material(
child: new ListView(
children: <Widget>[
new Hero(
tag: 'a',
child: const Text('Batman'),
placeholderBuilder: (BuildContext context, Widget child) {
return const Text('Venom');
},
),
new Builder(builder: (BuildContext context) {
return new FlatButton(
child: const Text('two'),
onPressed: () => Navigator.push<void>(context, new MaterialPageRoute<void>(
builder: (BuildContext context) {
return new Material(
child: new Hero(
tag: 'a',
child: const Text('Wolverine'),
placeholderBuilder: (BuildContext context, Widget child) {
return const Text('Joker');
},
),
);
},
)),
);
}),
],
),
),
));
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('Batman'), findsNothing);
// This shows up once but in the Hero because by default, the destination
// Hero child is the widget in flight.
expect(find.text('Wolverine'), findsOneWidget);
expect(find.text('Venom'), findsOneWidget);
expect(find.text('Joker'), findsOneWidget);
});
}