Unverified Commit f23c9ae5 authored by xster's avatar xster Committed by GitHub

Cupertino nav bar transitions between routes (#20322)

parent 05b4bd74
...@@ -55,7 +55,11 @@ class _CupertinoRefreshControlDemoState extends State<CupertinoRefreshControlDem ...@@ -55,7 +55,11 @@ class _CupertinoRefreshControlDemoState extends State<CupertinoRefreshControlDem
new CupertinoSliverRefreshControl( new CupertinoSliverRefreshControl(
onRefresh: () { onRefresh: () {
return new Future<void>.delayed(const Duration(seconds: 2)) return new Future<void>.delayed(const Duration(seconds: 2))
..then((_) => setState(() => repopulateList())); ..then((_) {
if (mounted) {
setState(() => repopulateList());
}
});
}, },
), ),
new SliverSafeArea( new SliverSafeArea(
......
...@@ -320,13 +320,10 @@ class _AlwaysCupertinoScrollBehavior extends ScrollBehavior { ...@@ -320,13 +320,10 @@ class _AlwaysCupertinoScrollBehavior extends ScrollBehavior {
} }
class _CupertinoAppState extends State<CupertinoApp> { class _CupertinoAppState extends State<CupertinoApp> {
HeroController _heroController;
List<NavigatorObserver> _navigatorObservers;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_heroController = new HeroController(); // Linear tweening.
_updateNavigator(); _updateNavigator();
} }
...@@ -342,9 +339,6 @@ class _CupertinoAppState extends State<CupertinoApp> { ...@@ -342,9 +339,6 @@ class _CupertinoAppState extends State<CupertinoApp> {
widget.routes.isNotEmpty || widget.routes.isNotEmpty ||
widget.onGenerateRoute != null || widget.onGenerateRoute != null ||
widget.onUnknownRoute != null; widget.onUnknownRoute != null;
_navigatorObservers =
new List<NavigatorObserver>.from(widget.navigatorObservers)
..add(_heroController);
} }
Widget defaultBuilder(BuildContext context, Widget child) { Widget defaultBuilder(BuildContext context, Widget child) {
...@@ -361,7 +355,7 @@ class _CupertinoAppState extends State<CupertinoApp> { ...@@ -361,7 +355,7 @@ class _CupertinoAppState extends State<CupertinoApp> {
routes: widget.routes, routes: widget.routes,
onGenerateRoute: widget.onGenerateRoute, onGenerateRoute: widget.onGenerateRoute,
onUnknownRoute: widget.onUnknownRoute, onUnknownRoute: widget.onUnknownRoute,
navigatorObservers: _navigatorObservers, navigatorObservers: widget.navigatorObservers,
); );
if (widget.builder != null) { if (widget.builder != null) {
return widget.builder(context, navigator); return widget.builder(context, navigator);
......
...@@ -2,9 +2,11 @@ ...@@ -2,9 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui' show ImageFilter; import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -15,6 +17,8 @@ import 'page_scaffold.dart'; ...@@ -15,6 +17,8 @@ import 'page_scaffold.dart';
import 'route.dart'; import 'route.dart';
/// Standard iOS navigation bar height without the status bar. /// Standard iOS navigation bar height without the status bar.
///
/// This height is constant and independent of accessibility as it is in iOS.
const double _kNavBarPersistentHeight = 44.0; const double _kNavBarPersistentHeight = 44.0;
/// Size increase from expanding the navigation bar into an iOS-11-style large title /// Size increase from expanding the navigation bar into an iOS-11-style large title
...@@ -43,6 +47,14 @@ const Border _kDefaultNavBarBorder = Border( ...@@ -43,6 +47,14 @@ const Border _kDefaultNavBarBorder = Border(
), ),
); );
const TextStyle _kMiddleTitleTextStyle = TextStyle(
fontFamily: '.SF UI Text',
fontSize: 17.0,
fontWeight: FontWeight.w600,
letterSpacing: -0.08,
color: CupertinoColors.black,
);
const TextStyle _kLargeTitleTextStyle = TextStyle( const TextStyle _kLargeTitleTextStyle = TextStyle(
fontFamily: '.SF Pro Display', fontFamily: '.SF Pro Display',
fontSize: 34.0, fontSize: 34.0,
...@@ -51,6 +63,78 @@ const TextStyle _kLargeTitleTextStyle = TextStyle( ...@@ -51,6 +63,78 @@ const TextStyle _kLargeTitleTextStyle = TextStyle(
color: CupertinoColors.black, color: CupertinoColors.black,
); );
// There's a single tag for all instances of navigation bars because they can
// all transition between each other (per Navigator) via Hero transitions.
const _HeroTag _defaultHeroTag = _HeroTag();
class _HeroTag {
const _HeroTag();
// Let the Hero tag be described in tree dumps.
@override
String toString() => 'Default Hero tag for Cupertino navigation bars';
}
TextStyle _navBarItemStyle(Color color) {
return new TextStyle(
fontFamily: '.SF UI Text',
fontSize: 17.0,
letterSpacing: -0.24,
color: color,
);
}
/// Returns `child` wrapped with background and a bottom border if background color
/// is opaque. Otherwise, also blur with [BackdropFilter].
///
/// When `updateSystemUiOverlay` is true, the nav bar will update the OS
/// status bar's color theme based on the background color of the nav bar.
Widget _wrapWithBackground({
Border border,
Color backgroundColor,
Widget child,
bool updateSystemUiOverlay = true,
}) {
Widget result = child;
if (updateSystemUiOverlay) {
final bool darkBackground = backgroundColor.computeLuminance() < 0.179;
final SystemUiOverlayStyle overlayStyle = darkBackground
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark;
result = new AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
sized: true,
child: result,
);
}
final DecoratedBox childWithBackground = new DecoratedBox(
decoration: new BoxDecoration(
border: border,
color: backgroundColor,
),
child: result,
);
if (backgroundColor.alpha == 0xFF)
return childWithBackground;
return new ClipRect(
child: new BackdropFilter(
filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: childWithBackground,
),
);
}
// Whether the current route supports nav bar hero transitions from or to.
bool _isTransitionable(BuildContext context) {
final ModalRoute<dynamic> route = ModalRoute.of(context);
// Fullscreen dialogs never transitions their nav bar with other push-style
// pages' nav bars or with other fullscreen dialog pages on the way in or on
// the way out.
return route is PageRoute && !route.fullscreenDialog;
}
/// An iOS-styled navigation bar. /// An iOS-styled navigation bar.
/// ///
/// The navigation bar is a toolbar that minimally consists of a widget, normally /// The navigation bar is a toolbar that minimally consists of a widget, normally
...@@ -73,13 +157,20 @@ const TextStyle _kLargeTitleTextStyle = TextStyle( ...@@ -73,13 +157,20 @@ const TextStyle _kLargeTitleTextStyle = TextStyle(
/// If the given [backgroundColor]'s opacity is not 1.0 (which is the case by /// If the given [backgroundColor]'s opacity is not 1.0 (which is the case by
/// default), it will produce a blurring effect to the content behind it. /// default), it will produce a blurring effect to the content behind it.
/// ///
/// When [transitionBetweenRoutes] is true, this navigation bar will transition
/// on top of the routes instead of inside it if the route being transitioned
/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar]
/// with [transitionBetweenRoutes] set to true. If [transitionBetweenRoutes] is
/// true, none of the [Widget] parameters can contain a key in its subtree since
/// that widget will exist in multiple places in the tree simultaneously.
///
/// See also: /// See also:
/// ///
/// * [CupertinoPageScaffold], a page layout helper typically hosting the /// * [CupertinoPageScaffold], a page layout helper typically hosting the
/// [CupertinoNavigationBar]. /// [CupertinoNavigationBar].
/// * [CupertinoSliverNavigationBar] for a navigation bar to be placed in a /// * [CupertinoSliverNavigationBar] for a navigation bar to be placed in a
/// scrolling list and that supports iOS-11-style large titles. /// scrolling list and that supports iOS-11-style large titles.
class CupertinoNavigationBar extends StatelessWidget implements ObstructingPreferredSizeWidget { class CupertinoNavigationBar extends StatefulWidget implements ObstructingPreferredSizeWidget {
/// Creates a navigation bar in the iOS style. /// Creates a navigation bar in the iOS style.
const CupertinoNavigationBar({ const CupertinoNavigationBar({
Key key, Key key,
...@@ -93,8 +184,21 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe ...@@ -93,8 +184,21 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe
this.backgroundColor = _kDefaultNavBarBackgroundColor, this.backgroundColor = _kDefaultNavBarBackgroundColor,
this.padding, this.padding,
this.actionsForegroundColor = CupertinoColors.activeBlue, this.actionsForegroundColor = CupertinoColors.activeBlue,
this.transitionBetweenRoutes = true,
this.heroTag = _defaultHeroTag,
}) : assert(automaticallyImplyLeading != null), }) : assert(automaticallyImplyLeading != null),
assert(automaticallyImplyMiddle != null), assert(automaticallyImplyMiddle != null),
assert(transitionBetweenRoutes != null),
assert(
heroTag != null,
'heroTag cannot be null. Use transitionBetweenRoutes = false to '
'disable Hero transition on this navigation bar.'
),
assert(
!transitionBetweenRoutes || identical(heroTag, _defaultHeroTag),
'Cannot specify a heroTag override if this navigation bar does not '
'transition due to transitionBetweenRoutes = false.'
),
super(key: key); super(key: key);
/// {@template flutter.cupertino.navBar.leading} /// {@template flutter.cupertino.navBar.leading}
...@@ -199,6 +303,34 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe ...@@ -199,6 +303,34 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe
/// iOS standard design. /// iOS standard design.
final Color actionsForegroundColor; final Color actionsForegroundColor;
/// {@template flutter.cupertino.navBar.transitionBetweenRoutes}
/// Whether to transition between navigation bars.
///
/// When [transitionBetweenRoutes] is true, this navigation bar will transition
/// on top of the routes instead of inside it if the route being transitioned
/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar]
/// with [transitionBetweenRoutes] set to true.
///
/// When set to true, only one navigation bar can be present per route unless
/// [heroTag] is also set.
///
/// This value defaults to true and cannot be null.
/// {@endtemplate}
final bool transitionBetweenRoutes;
/// {@template flutter.cupertino.navBar.heroTag}
/// Tag for the navigation bar's Hero widget if [transitionBetweenRoutes] is true.
///
/// Defaults to a common tag between all [CupertinoNavigationBar] and
/// [CupertinoSliverNavigationBar] instances so they can all transition
/// between each other as long as there's only one per route. Use this tag
/// override with different tags to have multiple navigation bars per route.
///
/// Cannot be null. To disable Hero transitions for this navigation bar,
/// set [transitionBetweenRoutes] to false.
/// {@endtemplate}
final Object heroTag;
/// True if the navigation bar's background color has no transparency. /// True if the navigation bar's background color has no transparency.
@override @override
bool get fullObstruction => backgroundColor.alpha == 0xFF; bool get fullObstruction => backgroundColor.alpha == 0xFF;
...@@ -208,25 +340,67 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe ...@@ -208,25 +340,67 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe
return const Size.fromHeight(_kNavBarPersistentHeight); return const Size.fromHeight(_kNavBarPersistentHeight);
} }
@override
_CupertinoNavigationBarState createState() {
return new _CupertinoNavigationBarState();
}
}
// A state class exists for the nav bar so that the keys of its sub-components
// don't change when rebuilding the nav bar, causing the sub-components to
// lose their own states.
class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
_NavigationBarStaticComponentsKeys keys;
@override
void initState() {
super.initState();
keys = new _NavigationBarStaticComponentsKeys();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget effectiveMiddle = _effectiveTitle( final _NavigationBarStaticComponents components = new _NavigationBarStaticComponents(
title: middle, keys: keys,
automaticallyImplyTitle: automaticallyImplyMiddle, route: ModalRoute.of(context),
currentRoute: ModalRoute.of(context), userLeading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
automaticallyImplyTitle: widget.automaticallyImplyMiddle,
previousPageTitle: widget.previousPageTitle,
userMiddle: widget.middle,
userTrailing: widget.trailing,
padding: widget.padding,
actionsForegroundColor: widget.actionsForegroundColor,
userLargeTitle: null,
large: false,
); );
return _wrapWithBackground( final Widget navBar = _wrapWithBackground(
border: border, border: widget.border,
backgroundColor: backgroundColor, backgroundColor: widget.backgroundColor,
child: new _CupertinoPersistentNavigationBar( child: new _PersistentNavigationBar(
leading: leading, components: components,
automaticallyImplyLeading: automaticallyImplyLeading, padding: widget.padding,
previousPageTitle: previousPageTitle, ),
middle: effectiveMiddle, );
trailing: trailing,
padding: padding, if (!widget.transitionBetweenRoutes || !_isTransitionable(context)) {
actionsForegroundColor: actionsForegroundColor, return navBar;
}
return new Hero(
tag: widget.heroTag,
createRectTween: _linearTranslateWithLargestRectSizeTween,
placeholderBuilder: _navBarHeroLaunchPadBuilder,
flightShuttleBuilder: _navBarHeroFlightShuttleBuilder,
child: new _TransitionableNavigationBar(
componentsKeys: keys,
backgroundColor: widget.backgroundColor,
actionsForegroundColor: widget.actionsForegroundColor,
border: widget.border,
hasUserMiddle: widget.middle != null,
largeExpanded: false,
child: navBar,
), ),
); );
} }
...@@ -261,11 +435,19 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe ...@@ -261,11 +435,19 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe
/// route if none is provided and [automaticallyImplyTitle] is true (true by /// route if none is provided and [automaticallyImplyTitle] is true (true by
/// default). /// default).
/// ///
/// When [transitionBetweenRoutes] is true, this navigation bar will transition
/// on top of the routes instead of inside it if the route being transitioned
/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar]
/// with [transitionBetweenRoutes] set to true. If [transitionBetweenRoutes] is
/// true, none of the [Widget] parameters can contain any [GlobalKey]s in their
/// subtrees since those widgets will exist in multiple places in the tree
/// simultaneously.
///
/// See also: /// See also:
/// ///
/// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling /// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling
/// pages. /// pages.
class CupertinoSliverNavigationBar extends StatelessWidget { class CupertinoSliverNavigationBar extends StatefulWidget {
/// Creates a navigation bar for scrolling lists. /// Creates a navigation bar for scrolling lists.
/// ///
/// The [largeTitle] argument is required and must not be null. /// The [largeTitle] argument is required and must not be null.
...@@ -282,8 +464,16 @@ class CupertinoSliverNavigationBar extends StatelessWidget { ...@@ -282,8 +464,16 @@ class CupertinoSliverNavigationBar extends StatelessWidget {
this.backgroundColor = _kDefaultNavBarBackgroundColor, this.backgroundColor = _kDefaultNavBarBackgroundColor,
this.padding, this.padding,
this.actionsForegroundColor = CupertinoColors.activeBlue, this.actionsForegroundColor = CupertinoColors.activeBlue,
this.transitionBetweenRoutes = true,
this.heroTag = _defaultHeroTag,
}) : assert(automaticallyImplyLeading != null), }) : assert(automaticallyImplyLeading != null),
assert(automaticallyImplyTitle != null), assert(automaticallyImplyTitle != null),
assert(
automaticallyImplyTitle == true || largeTitle != null,
'No largeTitle has been provided but automaticallyImplyTitle is also '
'false. Either provide a largeTitle or set automaticallyImplyTitle to '
'true.'
),
super(key: key); super(key: key);
/// The navigation bar's title. /// The navigation bar's title.
...@@ -304,6 +494,9 @@ class CupertinoSliverNavigationBar extends StatelessWidget { ...@@ -304,6 +494,9 @@ class CupertinoSliverNavigationBar extends StatelessWidget {
/// If null and [automaticallyImplyTitle] is true, an appropriate [Text] /// If null and [automaticallyImplyTitle] is true, an appropriate [Text]
/// title will be created if the current route is a [CupertinoPageRoute] and /// title will be created if the current route is a [CupertinoPageRoute] and
/// has a `title`. /// has a `title`.
///
/// This parameter must either be non-null or the route must have a title
/// ([CupertinoPageRoute.title]) and [automaticallyImplyTitle] must be true.
final Widget largeTitle; final Widget largeTitle;
/// {@macro flutter.cupertino.navBar.leading} /// {@macro flutter.cupertino.navBar.leading}
...@@ -355,73 +548,96 @@ class CupertinoSliverNavigationBar extends StatelessWidget { ...@@ -355,73 +548,96 @@ class CupertinoSliverNavigationBar extends StatelessWidget {
/// iOS standard design. /// iOS standard design.
final Color actionsForegroundColor; final Color actionsForegroundColor;
/// {@macro flutter.cupertino.navBar.transitionBetweenRoutes}
final bool transitionBetweenRoutes;
/// {@macro flutter.cupertino.navBar.heroTag}
final Object heroTag;
/// True if the navigation bar's background color has no transparency. /// True if the navigation bar's background color has no transparency.
bool get opaque => backgroundColor.alpha == 0xFF; bool get opaque => backgroundColor.alpha == 0xFF;
@override
_CupertinoSliverNavigationBarState createState() => new _CupertinoSliverNavigationBarState();
}
// A state class exists for the nav bar so that the keys of its sub-components
// don't change when rebuilding the nav bar, causing the sub-components to
// lose their own states.
class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar> {
_NavigationBarStaticComponentsKeys keys;
@override
void initState() {
super.initState();
keys = new _NavigationBarStaticComponentsKeys();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget effectiveTitle = _effectiveTitle( final _NavigationBarStaticComponents components = new _NavigationBarStaticComponents(
title: largeTitle, keys: keys,
automaticallyImplyTitle: automaticallyImplyTitle, route: ModalRoute.of(context),
currentRoute: ModalRoute.of(context), userLeading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
automaticallyImplyTitle: widget.automaticallyImplyTitle,
previousPageTitle: widget.previousPageTitle,
userMiddle: widget.middle,
userTrailing: widget.trailing,
userLargeTitle: widget.largeTitle,
padding: widget.padding,
actionsForegroundColor: widget.actionsForegroundColor,
large: true,
); );
return new SliverPersistentHeader( return new SliverPersistentHeader(
pinned: true, // iOS navigation bars are always pinned. pinned: true, // iOS navigation bars are always pinned.
delegate: new _CupertinoLargeTitleNavigationBarSliverDelegate( delegate: new _LargeTitleNavigationBarSliverDelegate(
keys: keys,
components: components,
userMiddle: widget.middle,
backgroundColor: widget.backgroundColor,
border: widget.border,
padding: widget.padding,
actionsForegroundColor: widget.actionsForegroundColor,
transitionBetweenRoutes: widget.transitionBetweenRoutes,
heroTag: widget.heroTag,
persistentHeight: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top, persistentHeight: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top,
largeTitle: effectiveTitle, alwaysShowMiddle: widget.middle != null,
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
previousPageTitle: previousPageTitle,
middle: middle,
trailing: trailing,
padding: padding,
border: border,
backgroundColor: backgroundColor,
actionsForegroundColor: actionsForegroundColor,
), ),
); );
} }
} }
class _CupertinoLargeTitleNavigationBarSliverDelegate class _LargeTitleNavigationBarSliverDelegate
extends SliverPersistentHeaderDelegate with DiagnosticableTreeMixin { extends SliverPersistentHeaderDelegate with DiagnosticableTreeMixin {
_CupertinoLargeTitleNavigationBarSliverDelegate({ _LargeTitleNavigationBarSliverDelegate({
@required this.keys,
@required this.components,
@required this.userMiddle,
@required this.backgroundColor,
@required this.border,
@required this.padding,
@required this.actionsForegroundColor,
@required this.transitionBetweenRoutes,
@required this.heroTag,
@required this.persistentHeight, @required this.persistentHeight,
@required this.largeTitle, @required this.alwaysShowMiddle,
this.leading, }) : assert(persistentHeight != null),
this.automaticallyImplyLeading, assert(alwaysShowMiddle != null),
this.previousPageTitle, assert(transitionBetweenRoutes != null);
this.middle,
this.trailing, final _NavigationBarStaticComponentsKeys keys;
this.padding, final _NavigationBarStaticComponents components;
this.border, final Widget userMiddle;
this.backgroundColor,
this.actionsForegroundColor,
}) : assert(persistentHeight != null);
final double persistentHeight;
final Widget largeTitle;
final Widget leading;
final bool automaticallyImplyLeading;
final String previousPageTitle;
final Widget middle;
final Widget trailing;
final EdgeInsetsDirectional padding;
final Color backgroundColor; final Color backgroundColor;
final Border border; final Border border;
final EdgeInsetsDirectional padding;
final Color actionsForegroundColor; final Color actionsForegroundColor;
final bool transitionBetweenRoutes;
final Object heroTag;
final double persistentHeight;
final bool alwaysShowMiddle;
@override @override
double get minExtent => persistentHeight; double get minExtent => persistentHeight;
...@@ -433,21 +649,16 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate ...@@ -433,21 +649,16 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final bool showLargeTitle = shrinkOffset < maxExtent - minExtent - _kNavBarShowLargeTitleThreshold; final bool showLargeTitle = shrinkOffset < maxExtent - minExtent - _kNavBarShowLargeTitleThreshold;
final _CupertinoPersistentNavigationBar persistentNavigationBar = final _PersistentNavigationBar persistentNavigationBar =
new _CupertinoPersistentNavigationBar( new _PersistentNavigationBar(
leading: leading, components: components,
automaticallyImplyLeading: automaticallyImplyLeading,
previousPageTitle: previousPageTitle,
middle: middle ?? largeTitle,
trailing: trailing,
// If middle widget exists, always show it. Otherwise, show title
// when collapsed.
middleVisible: middle != null ? null : !showLargeTitle,
padding: padding, padding: padding,
actionsForegroundColor: actionsForegroundColor, // If a user specified middle exists, always show it. Otherwise, show
// title when sliver is collapsed.
middleVisible: alwaysShowMiddle ? null : !showLargeTitle,
); );
return _wrapWithBackground( final Widget navBar = _wrapWithBackground(
border: border, border: border,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
child: new Stack( child: new Stack(
...@@ -471,19 +682,19 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate ...@@ -471,19 +682,19 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate
start: _kNavBarEdgePadding, start: _kNavBarEdgePadding,
bottom: 8.0, // Bottom has a different padding. bottom: 8.0, // Bottom has a different padding.
), ),
child: new DefaultTextStyle(
style: _kLargeTitleTextStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: new AnimatedOpacity(
opacity: showLargeTitle ? 1.0 : 0.0,
duration: _kNavBarTitleFadeDuration,
child: new SafeArea( child: new SafeArea(
top: false, top: false,
bottom: false, bottom: false,
child: new AnimatedOpacity(
opacity: showLargeTitle ? 1.0 : 0.0,
duration: _kNavBarTitleFadeDuration,
child: new Semantics( child: new Semantics(
header: true, header: true,
child: largeTitle, child: new DefaultTextStyle(
style: _kLargeTitleTextStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: components.largeTitle,
), ),
), ),
), ),
...@@ -501,70 +712,44 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate ...@@ -501,70 +712,44 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate
], ],
), ),
); );
}
@override if (!transitionBetweenRoutes || !_isTransitionable(context)) {
bool shouldRebuild(_CupertinoLargeTitleNavigationBarSliverDelegate oldDelegate) { return navBar;
return persistentHeight != oldDelegate.persistentHeight
|| largeTitle != oldDelegate.largeTitle
|| leading != oldDelegate.leading
|| middle != oldDelegate.middle
|| trailing != oldDelegate.trailing
|| border != oldDelegate.border
|| backgroundColor != oldDelegate.backgroundColor
|| actionsForegroundColor != oldDelegate.actionsForegroundColor;
} }
}
/// Returns `child` wrapped with background and a bottom border if background color
/// is opaque. Otherwise, also blur with [BackdropFilter].
Widget _wrapWithBackground({
Border border,
Color backgroundColor,
Widget child,
}) {
final bool darkBackground = backgroundColor.computeLuminance() < 0.179; return new Hero(
final SystemUiOverlayStyle overlayStyle = darkBackground tag: heroTag,
? SystemUiOverlayStyle.light createRectTween: _linearTranslateWithLargestRectSizeTween,
: SystemUiOverlayStyle.dark; flightShuttleBuilder: _navBarHeroFlightShuttleBuilder,
final DecoratedBox childWithBackground = new DecoratedBox( placeholderBuilder: _navBarHeroLaunchPadBuilder,
decoration: new BoxDecoration( // This is all the way down here instead of being at the top level of
// CupertinoSliverNavigationBar like CupertinoNavigationBar because it
// needs to wrap the top level RenderBox rather than a RenderSliver.
child: new _TransitionableNavigationBar(
componentsKeys: keys,
backgroundColor: backgroundColor,
actionsForegroundColor: actionsForegroundColor,
border: border, border: border,
color: backgroundColor, hasUserMiddle: userMiddle != null,
), largeExpanded: showLargeTitle,
child: new AnnotatedRegion<SystemUiOverlayStyle>( child: navBar,
value: overlayStyle,
sized: true,
child: child,
),
);
if (backgroundColor.alpha == 0xFF)
return childWithBackground;
return new ClipRect(
child: new BackdropFilter(
filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: childWithBackground,
), ),
); );
}
Widget _effectiveTitle({
Widget title,
bool automaticallyImplyTitle,
ModalRoute<dynamic> currentRoute,
}) {
// Auto use the CupertinoPageRoute's title if middle not provided.
if (title == null &&
automaticallyImplyTitle &&
currentRoute is CupertinoPageRoute &&
currentRoute.title != null) {
return new Text(currentRoute.title);
} }
return title; @override
bool shouldRebuild(_LargeTitleNavigationBarSliverDelegate oldDelegate) {
return components != oldDelegate.components
|| userMiddle != oldDelegate.userMiddle
|| backgroundColor != oldDelegate.backgroundColor
|| border != oldDelegate.border
|| padding != oldDelegate.padding
|| actionsForegroundColor != oldDelegate.actionsForegroundColor
|| transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes
|| persistentHeight != oldDelegate.persistentHeight
|| alwaysShowMiddle != oldDelegate.alwaysShowMiddle
|| heroTag != oldDelegate.heroTag;
}
} }
/// The top part of the navigation bar that's never scrolled away. /// The top part of the navigation bar that's never scrolled away.
...@@ -572,123 +757,57 @@ Widget _effectiveTitle({ ...@@ -572,123 +757,57 @@ Widget _effectiveTitle({
/// Consists of the entire navigation bar without background and border when used /// Consists of the entire navigation bar without background and border when used
/// without large titles. With large titles, it's the top static half that /// without large titles. With large titles, it's the top static half that
/// doesn't scroll. /// doesn't scroll.
class _CupertinoPersistentNavigationBar extends StatelessWidget implements PreferredSizeWidget { class _PersistentNavigationBar extends StatelessWidget {
const _CupertinoPersistentNavigationBar({ const _PersistentNavigationBar({
Key key, Key key,
this.leading, this.components,
this.automaticallyImplyLeading,
this.previousPageTitle,
this.middle,
this.trailing,
this.padding, this.padding,
this.actionsForegroundColor,
this.middleVisible, this.middleVisible,
}) : super(key: key); }) : super(key: key);
final Widget leading; final _NavigationBarStaticComponents components;
final bool automaticallyImplyLeading;
final String previousPageTitle;
final Widget middle;
final Widget trailing;
final EdgeInsetsDirectional padding; final EdgeInsetsDirectional padding;
final Color actionsForegroundColor;
/// Whether the middle widget has a visible animated opacity. A null value /// Whether the middle widget has a visible animated opacity. A null value
/// means the middle opacity will not be animated. /// means the middle opacity will not be animated.
final bool middleVisible; final bool middleVisible;
@override
Size get preferredSize => const Size.fromHeight(_kNavBarPersistentHeight);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TextStyle actionsStyle = new TextStyle( Widget middle = components.middle;
fontFamily: '.SF UI Text',
fontSize: 17.0,
letterSpacing: -0.24,
color: actionsForegroundColor,
);
final Widget styledLeading = leading == null
? null
: new Padding(
padding: new EdgeInsetsDirectional.only(
start: padding?.start ?? _kNavBarEdgePadding,
),
child: new DefaultTextStyle(
style: actionsStyle,
child: leading,
),
);
final Widget styledTrailing = trailing == null
? null
: Padding(
padding: new EdgeInsetsDirectional.only(
end: padding?.end ?? _kNavBarEdgePadding,
),
child: new DefaultTextStyle(
style: actionsStyle,
child: trailing,
),
);
// Let the middle be black rather than `actionsForegroundColor` in case if (middle != null) {
// it's a plain text title. middle = new DefaultTextStyle(
final Widget styledMiddle = middle == null style: _kMiddleTitleTextStyle,
? null child: new Semantics(header: true, child: middle),
: new DefaultTextStyle(
style: actionsStyle.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.08,
color: CupertinoColors.black,
),
child: new Semantics(child: middle, header: true),
); );
// When the middle's visibility can change on the fly like with large title
final Widget animatedStyledMiddle = middleVisible == null // slivers, wrap with animated opacity.
? styledMiddle middle = middleVisible == null
? middle
: new AnimatedOpacity( : new AnimatedOpacity(
opacity: middleVisible ? 1.0 : 0.0, opacity: middleVisible ? 1.0 : 0.0,
duration: _kNavBarTitleFadeDuration, duration: _kNavBarTitleFadeDuration,
child: styledMiddle, child: middle,
); );
}
// Auto add back button if leading not provided. Widget leading = components.leading;
Widget backOrCloseButton; final Widget backChevron = components.backChevron;
if (styledLeading == null && automaticallyImplyLeading) { final Widget backLabel = components.backLabel;
final ModalRoute<dynamic> currentRoute = ModalRoute.of(context);
if (currentRoute?.canPop == true) { if (leading == null && backChevron != null && backLabel != null) {
if (currentRoute is PageRoute && currentRoute?.fullscreenDialog == true) { leading = new CupertinoNavigationBarBackButton._assemble(
backOrCloseButton = new CupertinoButton( backChevron,
child: const Padding( backLabel,
padding: EdgeInsetsDirectional.only( components.actionsForegroundColor,
start: _kNavBarEdgePadding,
),
child: Text('Close'),
),
padding: EdgeInsets.zero,
onPressed: () { Navigator.maybePop(context); },
);
} else {
backOrCloseButton = new CupertinoNavigationBarBackButton(
color: actionsForegroundColor,
previousPageTitle: previousPageTitle,
); );
} }
}
}
Widget paddedToolbar = new NavigationToolbar( Widget paddedToolbar = new NavigationToolbar(
leading: styledLeading ?? backOrCloseButton, leading: leading,
middle: animatedStyledMiddle, middle: middle,
trailing: styledTrailing, trailing: components.trailing,
centerMiddle: true, centerMiddle: true,
middleSpacing: 6.0, middleSpacing: 6.0,
); );
...@@ -713,6 +832,303 @@ class _CupertinoPersistentNavigationBar extends StatelessWidget implements Prefe ...@@ -713,6 +832,303 @@ class _CupertinoPersistentNavigationBar extends StatelessWidget implements Prefe
} }
} }
// A collection of keys always used when building static routes' nav bars's
// components with _NavigationBarStaticComponents and read in
// _NavigationBarTransition in Hero flights in order to reference the components'
// RenderBoxes for their positions.
//
// These keys should never re-appear inside the Hero flights.
@immutable
class _NavigationBarStaticComponentsKeys {
_NavigationBarStaticComponentsKeys()
: navBarBoxKey = new GlobalKey(debugLabel: 'Navigation bar render box'),
leadingKey = new GlobalKey(debugLabel: 'Leading'),
backChevronKey = new GlobalKey(debugLabel: 'Back chevron'),
backLabelKey = new GlobalKey(debugLabel: 'Back label'),
middleKey = new GlobalKey(debugLabel: 'Middle'),
trailingKey = new GlobalKey(debugLabel: 'Trailing'),
largeTitleKey = new GlobalKey(debugLabel: 'Large title');
final GlobalKey navBarBoxKey;
final GlobalKey leadingKey;
final GlobalKey backChevronKey;
final GlobalKey backLabelKey;
final GlobalKey middleKey;
final GlobalKey trailingKey;
final GlobalKey largeTitleKey;
}
// Based on various user Widgets and other parameters, construct KeyedSubtree
// components that are used in common by the CupertinoNavigationBar and
// CupertinoSliverNavigationBar. The KeyedSubtrees are inserted into static
// routes and the KeyedSubtrees' child are reused in the Hero flights.
@immutable
class _NavigationBarStaticComponents {
_NavigationBarStaticComponents({
@required _NavigationBarStaticComponentsKeys keys,
@required ModalRoute<dynamic> route,
@required Widget userLeading,
@required bool automaticallyImplyLeading,
@required bool automaticallyImplyTitle,
@required String previousPageTitle,
@required Widget userMiddle,
@required Widget userTrailing,
@required Widget userLargeTitle,
@required EdgeInsetsDirectional padding,
@required this.actionsForegroundColor,
@required bool large,
}) : leading = createLeading(
leadingKey: keys.leadingKey,
userLeading: userLeading,
route: route,
automaticallyImplyLeading: automaticallyImplyLeading,
padding: padding,
actionsForegroundColor: actionsForegroundColor,
),
backChevron = createBackChevron(
backChevronKey: keys.backChevronKey,
userLeading: userLeading,
route: route,
automaticallyImplyLeading: automaticallyImplyLeading,
),
backLabel = createBackLabel(
backLabelKey: keys.backLabelKey,
userLeading: userLeading,
route: route,
previousPageTitle: previousPageTitle,
automaticallyImplyLeading: automaticallyImplyLeading,
),
middle = createMiddle(
middleKey: keys.middleKey,
userMiddle: userMiddle,
userLargeTitle: userLargeTitle,
route: route,
automaticallyImplyTitle: automaticallyImplyTitle,
large: large,
),
trailing = createTrailing(
trailingKey: keys.trailingKey,
userTrailing: userTrailing,
padding: padding,
actionsForegroundColor: actionsForegroundColor,
),
largeTitle = createLargeTitle(
largeTitleKey: keys.largeTitleKey,
userLargeTitle: userLargeTitle,
route: route,
automaticImplyTitle: automaticallyImplyTitle,
large: large,
);
static Widget _derivedTitle({
bool automaticallyImplyTitle,
ModalRoute<dynamic> currentRoute,
}) {
// Auto use the CupertinoPageRoute's title if middle not provided.
if (automaticallyImplyTitle &&
currentRoute is CupertinoPageRoute &&
currentRoute.title != null) {
return new Text(currentRoute.title);
}
return null;
}
final Color actionsForegroundColor;
final KeyedSubtree leading;
static KeyedSubtree createLeading({
@required GlobalKey leadingKey,
@required Widget userLeading,
@required ModalRoute<dynamic> route,
@required bool automaticallyImplyLeading,
@required EdgeInsetsDirectional padding,
@required Color actionsForegroundColor
}) {
Widget leadingContent;
if (userLeading != null) {
leadingContent = userLeading;
} else if (
automaticallyImplyLeading &&
route is PageRoute &&
route.canPop &&
route.fullscreenDialog
) {
leadingContent = new CupertinoButton(
child: const Text('Close'),
padding: EdgeInsets.zero,
onPressed: () { route.navigator.maybePop(); },
);
}
if (leadingContent == null) {
return null;
}
return new KeyedSubtree(
key: leadingKey,
child: new Padding(
padding: new EdgeInsetsDirectional.only(
start: padding?.start ?? _kNavBarEdgePadding,
),
child: new DefaultTextStyle(
style: _navBarItemStyle(actionsForegroundColor),
child: IconTheme.merge(
data: new IconThemeData(
color: actionsForegroundColor,
size: 32.0,
),
child: leadingContent,
),
),
),
);
}
final KeyedSubtree backChevron;
static KeyedSubtree createBackChevron({
@required GlobalKey backChevronKey,
@required Widget userLeading,
@required ModalRoute<dynamic> route,
@required bool automaticallyImplyLeading,
}) {
if (
userLeading != null ||
!automaticallyImplyLeading ||
route == null ||
!route.canPop ||
(route is PageRoute && route.fullscreenDialog)
) {
return null;
}
return new KeyedSubtree(key: backChevronKey, child: const _BackChevron());
}
/// This widget is not decorated with a font since the font style could
/// animate during transitions.
final KeyedSubtree backLabel;
static KeyedSubtree createBackLabel({
@required GlobalKey backLabelKey,
@required Widget userLeading,
@required ModalRoute<dynamic> route,
@required bool automaticallyImplyLeading,
@required String previousPageTitle,
}) {
if (
userLeading != null ||
!automaticallyImplyLeading ||
route == null ||
!route.canPop ||
(route is PageRoute && route.fullscreenDialog)
) {
return null;
}
return new KeyedSubtree(
key: backLabelKey,
child: new _BackLabel(
specifiedPreviousTitle: previousPageTitle,
route: route,
),
);
}
/// This widget is not decorated with a font since the font style could
/// animate during transitions.
final KeyedSubtree middle;
static KeyedSubtree createMiddle({
@required GlobalKey middleKey,
@required Widget userMiddle,
@required Widget userLargeTitle,
@required bool large,
@required bool automaticallyImplyTitle,
@required ModalRoute<dynamic> route,
}) {
Widget middleContent = userMiddle;
if (large) {
middleContent ??= userLargeTitle;
}
middleContent ??= _derivedTitle(
automaticallyImplyTitle: automaticallyImplyTitle,
currentRoute: route,
);
if (middleContent == null) {
return null;
}
return new KeyedSubtree(
key: middleKey,
child: middleContent,
);
}
final KeyedSubtree trailing;
static KeyedSubtree createTrailing({
@required GlobalKey trailingKey,
@required Widget userTrailing,
@required EdgeInsetsDirectional padding,
@required Color actionsForegroundColor,
}) {
if (userTrailing == null) {
return null;
}
return new KeyedSubtree(
key: trailingKey,
child: new Padding(
padding: new EdgeInsetsDirectional.only(
end: padding?.end ?? _kNavBarEdgePadding,
),
child: new DefaultTextStyle(
style: _navBarItemStyle(actionsForegroundColor),
child: IconTheme.merge(
data: new IconThemeData(
color: actionsForegroundColor,
size: 32.0,
),
child: userTrailing,
),
),
),
);
}
/// This widget is not decorated with a font since the font style could
/// animate during transitions.
final KeyedSubtree largeTitle;
static KeyedSubtree createLargeTitle({
@required GlobalKey largeTitleKey,
@required Widget userLargeTitle,
@required bool large,
@required bool automaticImplyTitle,
@required ModalRoute<dynamic> route,
}) {
if (!large) {
return null;
}
final Widget largeTitleContent = userLargeTitle ?? _derivedTitle(
automaticallyImplyTitle: automaticImplyTitle,
currentRoute: route,
);
assert(
largeTitleContent != null,
'largeTitle was not provided and there was no title from the route.',
);
return new KeyedSubtree(
key: largeTitleKey,
child: largeTitleContent,
);
}
}
/// A nav bar back button typically used in [CupertinoNavigationBar]. /// A nav bar back button typically used in [CupertinoNavigationBar].
/// ///
/// This is automatically inserted into [CupertinoNavigationBar] and /// This is automatically inserted into [CupertinoNavigationBar] and
...@@ -730,9 +1146,20 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { ...@@ -730,9 +1146,20 @@ class CupertinoNavigationBarBackButton extends StatelessWidget {
const CupertinoNavigationBarBackButton({ const CupertinoNavigationBarBackButton({
@required this.color, @required this.color,
this.previousPageTitle, this.previousPageTitle,
}) : assert(color != null); }) : _backChevron = null,
_backLabel = null,
/// The [Color] of the back chevron. assert(color != null);
// Allow the back chevron and label to be separately created (and keyed)
// because they animate separately during page transitions.
const CupertinoNavigationBarBackButton._assemble(
this._backChevron,
this._backLabel,
this.color,
) : previousPageTitle = null,
assert(color != null);
/// The [Color] of the back button.
/// ///
/// Must not be null. /// Must not be null.
final Color color; final Color color;
...@@ -742,6 +1169,10 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { ...@@ -742,6 +1169,10 @@ class CupertinoNavigationBarBackButton extends StatelessWidget {
/// previous routes are both [CupertinoPageRoute]s. /// previous routes are both [CupertinoPageRoute]s.
final String previousPageTitle; final String previousPageTitle;
final Widget _backChevron;
final Widget _backLabel;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ModalRoute<dynamic> currentRoute = ModalRoute.of(context); final ModalRoute<dynamic> currentRoute = ModalRoute.of(context);
...@@ -758,15 +1189,17 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { ...@@ -758,15 +1189,17 @@ class CupertinoNavigationBarBackButton extends StatelessWidget {
button: true, button: true,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: _kNavBarBackButtonTapWidth), constraints: const BoxConstraints(minWidth: _kNavBarBackButtonTapWidth),
child: new DefaultTextStyle(
style: _navBarItemStyle(color),
child: new Row( child: new Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[ children: <Widget>[
const Padding(padding: EdgeInsetsDirectional.only(start: 8.0)), const Padding(padding: EdgeInsetsDirectional.only(start: 8.0)),
new _BackChevron(color: color), _backChevron ?? const _BackChevron(),
const Padding(padding: EdgeInsetsDirectional.only(start: 6.0)), const Padding(padding: EdgeInsetsDirectional.only(start: 6.0)),
new Flexible( new Flexible(
child: new _BackLabel( child: _backLabel ?? new _BackLabel(
specifiedPreviousTitle: previousPageTitle, specifiedPreviousTitle: previousPageTitle,
route: currentRoute, route: currentRoute,
), ),
...@@ -775,22 +1208,21 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { ...@@ -775,22 +1208,21 @@ class CupertinoNavigationBarBackButton extends StatelessWidget {
), ),
), ),
), ),
),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () { Navigator.maybePop(context); }, onPressed: () { Navigator.maybePop(context); },
); );
} }
} }
class _BackChevron extends StatelessWidget {
const _BackChevron({
@required this.color,
}) : assert(color != null);
final Color color; class _BackChevron extends StatelessWidget {
const _BackChevron({ Key key }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TextDirection textDirection = Directionality.of(context); final TextDirection textDirection = Directionality.of(context);
final TextStyle textStyle = DefaultTextStyle.of(context).style;
// Replicate the Icon logic here to get a tightly sized icon and add // Replicate the Icon logic here to get a tightly sized icon and add
// custom non-square padding. // custom non-square padding.
...@@ -799,7 +1231,7 @@ class _BackChevron extends StatelessWidget { ...@@ -799,7 +1231,7 @@ class _BackChevron extends StatelessWidget {
text: new String.fromCharCode(CupertinoIcons.back.codePoint), text: new String.fromCharCode(CupertinoIcons.back.codePoint),
style: new TextStyle( style: new TextStyle(
inherit: false, inherit: false,
color: color, color: textStyle.color,
fontSize: 34.0, fontSize: 34.0,
fontFamily: CupertinoIcons.back.fontFamily, fontFamily: CupertinoIcons.back.fontFamily,
package: CupertinoIcons.back.fontPackage, package: CupertinoIcons.back.fontPackage,
...@@ -827,9 +1259,11 @@ class _BackChevron extends StatelessWidget { ...@@ -827,9 +1259,11 @@ class _BackChevron extends StatelessWidget {
/// is true. /// is true.
class _BackLabel extends StatelessWidget { class _BackLabel extends StatelessWidget {
const _BackLabel({ const _BackLabel({
Key key,
@required this.specifiedPreviousTitle, @required this.specifiedPreviousTitle,
@required this.route, @required this.route,
}) : assert(route != null); }) : assert(route != null),
super(key: key);
final String specifiedPreviousTitle; final String specifiedPreviousTitle;
final ModalRoute<dynamic> route; final ModalRoute<dynamic> route;
...@@ -841,11 +1275,21 @@ class _BackLabel extends StatelessWidget { ...@@ -841,11 +1275,21 @@ class _BackLabel extends StatelessWidget {
return const SizedBox(height: 0.0, width: 0.0); return const SizedBox(height: 0.0, width: 0.0);
} }
if (previousTitle.length > 10) { Text textWidget = new Text(
return const Text('Back'); previousTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
if (previousTitle.length > 12) {
textWidget = const Text('Back');
} }
return new Text(previousTitle, maxLines: 1); return new Align(
alignment: AlignmentDirectional.centerStart,
widthFactor: 1.0,
child: textWidget,
);
} }
@override @override
...@@ -866,3 +1310,802 @@ class _BackLabel extends StatelessWidget { ...@@ -866,3 +1310,802 @@ class _BackLabel extends StatelessWidget {
} }
} }
} }
/// This should always be the first child of Hero widgets.
///
/// This class helps each Hero transition obtain the start or end navigation
/// bar's box size and the inner components of the navigation bar that will
/// move around.
///
/// It should be wrapped around the biggest [RenderBox] of the static
/// navigation bar in each route.
class _TransitionableNavigationBar extends StatelessWidget {
_TransitionableNavigationBar({
@required this.componentsKeys,
@required this.backgroundColor,
@required this.actionsForegroundColor,
@required this.border,
@required this.hasUserMiddle,
@required this.largeExpanded,
@required this.child,
}) : assert(componentsKeys != null),
assert(largeExpanded != null),
super(key: componentsKeys.navBarBoxKey);
final _NavigationBarStaticComponentsKeys componentsKeys;
final Color backgroundColor;
final Color actionsForegroundColor;
final Border border;
final bool hasUserMiddle;
final bool largeExpanded;
final Widget child;
RenderBox get renderBox {
final RenderBox box = componentsKeys.navBarBoxKey.currentContext.findRenderObject();
assert(
box.attached,
'_TransitionableNavigationBar.renderBox should be called when building '
'hero flight shuttles when the from and the to nav bar boxes are already '
'laid out and painted.',
);
return box;
}
@override
Widget build(BuildContext context) {
assert(() {
bool inHero;
context.visitAncestorElements((Element ancestor) {
if (ancestor is ComponentElement) {
assert(
ancestor.widget.runtimeType != _NavigationBarTransition,
'_TransitionableNavigationBar should never re-appear inside '
'_NavigationBarTransition. Keyed _TransitionableNavigationBar should '
'only serve as anchor points in routes rather than appearing inside '
'Hero flights themselves.',
);
if (ancestor.widget.runtimeType == Hero) {
inHero = true;
}
}
inHero ??= false;
return true;
});
assert(
inHero == true,
'_TransitionableNavigationBar should only be added as the immediate '
'child of Hero widgets.',
);
return true;
}());
return child;
}
}
/// This class represents the widget that will be in the Hero flight instead of
/// the 2 static navigation bars by taking inner components from both.
///
/// The `topNavBar` parameter is the nav bar that was on top regardless of
/// push/pop direction.
///
/// Similarly, the `bottomNavBar` parameter is the nav bar that was at the
/// bottom regardless of the push/pop direction.
///
/// If [MediaQuery.padding] is still present in this widget's [BuildContext],
/// that padding will become part of the transitional navigation bar as well.
///
/// [MediaQuery.padding] should be consistent between the from/to routes and
/// the Hero overlay. Inconsistent [MediaQuery.padding] will produce undetermined
/// results.
class _NavigationBarTransition extends StatelessWidget {
_NavigationBarTransition({
@required this.animation,
@required _TransitionableNavigationBar topNavBar,
@required _TransitionableNavigationBar bottomNavBar,
}) : heightTween = new Tween<double>(
begin: bottomNavBar.renderBox.size.height,
end: topNavBar.renderBox.size.height,
),
backgroundTween = new ColorTween(
begin: bottomNavBar.backgroundColor,
end: topNavBar.backgroundColor,
),
borderTween = new BorderTween(
begin: bottomNavBar.border,
end: topNavBar.border,
),
componentsTransition = new _NavigationBarComponentsTransition(
animation: animation,
bottomNavBar: bottomNavBar,
topNavBar: topNavBar,
);
final Animation<double> animation;
final _NavigationBarComponentsTransition componentsTransition;
final Tween<double> heightTween;
final ColorTween backgroundTween;
final BorderTween borderTween;
@override
Widget build(BuildContext context) {
final List<Widget> children = <Widget>[
// Draw an empty navigation bar box with changing shape behind all the
// moving components without any components inside it itself.
AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget child) {
return _wrapWithBackground(
// Don't update the system status bar color mid-flight.
updateSystemUiOverlay: false,
backgroundColor: backgroundTween.evaluate(animation),
border: borderTween.evaluate(animation),
child: new SizedBox(
height: heightTween.evaluate(animation),
width: double.infinity,
),
);
},
),
// Draw all the components on top of the empty bar box.
componentsTransition.bottomBackChevron,
componentsTransition.bottomBackLabel,
componentsTransition.bottomLeading,
componentsTransition.bottomMiddle,
componentsTransition.bottomLargeTitle,
componentsTransition.bottomTrailing,
// Draw top components on top of the bottom components.
componentsTransition.topLeading,
componentsTransition.topBackChevron,
componentsTransition.topBackLabel,
componentsTransition.topMiddle,
componentsTransition.topLargeTitle,
componentsTransition.topTrailing,
];
children.removeWhere((Widget child) => child == null);
// The actual outer box is big enough to contain both the bottom and top
// navigation bars. It's not a direct Rect lerp because some components
// can actually be outside the linearly lerp'ed Rect in the middle of
// the animation, such as the topLargeTitle.
return new SizedBox(
height: math.max(heightTween.begin, heightTween.end) + MediaQuery.of(context).padding.top,
width: double.infinity,
child: new Stack(
children: children,
),
);
}
}
/// This class helps create widgets that are in transition based on static
/// components from the bottom and top navigation bars.
///
/// It animates these transitional components both in terms of position and
/// their appearance.
///
/// Instead of running the transitional components through their normal static
/// navigation bar layout logic, this creates transitional widgets that are based
/// on these widgets' existing render objects' layout and position.
///
/// This is possible because this widget is only used during Hero transitions
/// where both the from and to routes are already built and laid out.
///
/// The components' existing layout constraints and positions are then
/// replicated using [Positioned] or [PositionedTransition] wrappers.
///
/// This class should never return [KeyedSubtree]s created by
/// _NavigationBarStaticComponents directly. Since widgets from
/// _NavigationBarStaticComponents are still present in the widget tree during the
/// hero transitions, it would cause global key duplications. Instead, return
/// only the [KeyedSubtree]s' child.
@immutable
class _NavigationBarComponentsTransition {
_NavigationBarComponentsTransition({
@required this.animation,
@required _TransitionableNavigationBar bottomNavBar,
@required _TransitionableNavigationBar topNavBar,
}) : bottomComponents = bottomNavBar.componentsKeys,
topComponents = topNavBar.componentsKeys,
bottomNavBarBox = bottomNavBar.renderBox,
topNavBarBox = topNavBar.renderBox,
bottomActionsStyle = _navBarItemStyle(bottomNavBar.actionsForegroundColor),
topActionsStyle = _navBarItemStyle(topNavBar.actionsForegroundColor),
bottomHasUserMiddle = bottomNavBar.hasUserMiddle,
topHasUserMiddle = topNavBar.hasUserMiddle,
bottomLargeExpanded = bottomNavBar.largeExpanded,
topLargeExpanded = topNavBar.largeExpanded,
transitionBox =
// paintBounds are based on offset zero so it's ok to expand the Rects.
bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds);
static final Tween<double> fadeOut = new Tween<double>(
begin: 1.0,
end: 0.0,
);
static final Tween<double> fadeIn = new Tween<double>(
begin: 0.0,
end: 1.0,
);
final Animation<double> animation;
final _NavigationBarStaticComponentsKeys bottomComponents;
final _NavigationBarStaticComponentsKeys topComponents;
// These render boxes that are the ancestors of all the bottom and top
// components are used to determine the components' relative positions inside
// their respective navigation bars.
final RenderBox bottomNavBarBox;
final RenderBox topNavBarBox;
final TextStyle bottomActionsStyle;
final TextStyle topActionsStyle;
final bool bottomHasUserMiddle;
final bool topHasUserMiddle;
final bool bottomLargeExpanded;
final bool topLargeExpanded;
// This is the outer box in which all the components will be fitted. The
// sizing component of RelativeRects will be based on this rect's size.
final Rect transitionBox;
// Take a widget it its original ancestor navigation bar render box and
// translate it into a RelativeBox in the transition navigation bar box.
RelativeRect positionInTransitionBox(
GlobalKey key, {
@required RenderBox from,
}) {
final RenderBox componentBox = key.currentContext.findRenderObject();
assert(componentBox.attached);
return new RelativeRect.fromRect(
componentBox.localToGlobal(Offset.zero, ancestor: from) & componentBox.size,
transitionBox,
);
}
// Create a Tween that moves a widget between its original position in its
// ancestor navigation bar to another widget's position in that widget's
// navigation bar.
//
// Anchor their positions based on the center of their respective render
// boxes' leading edge.
//
// Also produce RelativeRects with sizes that would preserve the constant
// BoxConstraints of the 'from' widget so that animating font sizes etc don't
// produce rounding error artifacts with a linearly resizing rect.
RelativeRectTween slideFromLeadingEdge({
@required GlobalKey fromKey,
@required RenderBox fromNavBarBox,
@required GlobalKey toKey,
@required RenderBox toNavBarBox,
}) {
final RelativeRect fromRect = positionInTransitionBox(fromKey, from: fromNavBarBox);
final RenderBox fromBox = fromKey.currentContext.findRenderObject();
final RenderBox toBox = toKey.currentContext.findRenderObject();
final Rect toRect =
toBox.localToGlobal(
Offset.zero,
ancestor: toNavBarBox,
).translate(
0.0,
- fromBox.size.height / 2 + toBox.size.height / 2
) & fromBox.size; // Keep the from render object's size.
return new RelativeRectTween(
begin: fromRect,
end: new RelativeRect.fromRect(toRect, transitionBox),
);
}
Animation<double> fadeInFrom(double t, { Curve curve = Curves.easeIn }) {
return fadeIn.animate(
new CurvedAnimation(curve: new Interval(t, 1.0, curve: curve), parent: animation),
);
}
Animation<double> fadeOutBy(double t, { Curve curve = Curves.easeOut }) {
return fadeOut.animate(
new CurvedAnimation(curve: new Interval(0.0, t, curve: curve), parent: animation),
);
}
Widget get bottomLeading {
final KeyedSubtree bottomLeading = bottomComponents.leadingKey.currentWidget;
if (bottomLeading == null) {
return null;
}
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(bottomComponents.leadingKey, from: bottomNavBarBox),
child: new FadeTransition(
opacity: fadeOutBy(0.4),
child: bottomLeading.child,
),
);
}
Widget get bottomBackChevron {
final KeyedSubtree bottomBackChevron = bottomComponents.backChevronKey.currentWidget;
if (bottomBackChevron == null) {
return null;
}
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(bottomComponents.backChevronKey, from: bottomNavBarBox),
child: new FadeTransition(
opacity: fadeOutBy(0.6),
child: new DefaultTextStyle(
style: bottomActionsStyle,
child: bottomBackChevron.child,
),
),
);
}
Widget get bottomBackLabel {
final KeyedSubtree bottomBackLabel = bottomComponents.backLabelKey.currentWidget;
if (bottomBackLabel == null) {
return null;
}
final RelativeRect from = positionInTransitionBox(bottomComponents.backLabelKey, from: bottomNavBarBox);
// Transition away by sliding horizontally to the left off of the screen.
final RelativeRectTween positionTween = new RelativeRectTween(
begin: from,
end: from.shift(new Offset(-bottomNavBarBox.size.width / 2.0, 0.0)),
);
return new PositionedTransition(
rect: positionTween.animate(animation),
child: new FadeTransition(
opacity: fadeOutBy(0.2),
child: new DefaultTextStyle(
style: bottomActionsStyle,
child: bottomBackLabel.child,
),
),
);
}
Widget get bottomMiddle {
final KeyedSubtree bottomMiddle = bottomComponents.middleKey.currentWidget;
final KeyedSubtree topBackLabel = topComponents.backLabelKey.currentWidget;
final KeyedSubtree topLeading = topComponents.leadingKey.currentWidget;
// The middle component is non-null when the nav bar is a large title
// nav bar but would be invisible when expanded, therefore don't show it here.
if (!bottomHasUserMiddle && bottomLargeExpanded) {
return null;
}
if (bottomMiddle != null && topBackLabel != null) {
return new PositionedTransition(
rect: slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
).animate(animation),
child: new FadeTransition(
// A custom middle widget like a segmented control fades away faster.
opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7),
child: new Align(
// As the text shrinks, make sure it's still anchored to the leading
// edge of a constantly sized outer box.
alignment: AlignmentDirectional.centerStart,
child: new DefaultTextStyleTransition(
style: TextStyleTween(
begin: _kMiddleTitleTextStyle,
end: topActionsStyle,
).animate(animation),
child: bottomMiddle.child,
),
),
),
);
}
// When the top page has a leading widget override, don't move the bottom
// middle widget.
if (bottomMiddle != null && topLeading != null) {
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(bottomComponents.middleKey, from: bottomNavBarBox),
child: new FadeTransition(
opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7),
// Keep the font when transitioning into a non-back label leading.
child: new DefaultTextStyle(
style: _kMiddleTitleTextStyle,
child: bottomMiddle.child,
),
),
);
}
return null;
}
Widget get bottomLargeTitle {
final KeyedSubtree bottomLargeTitle = bottomComponents.largeTitleKey.currentWidget;
final KeyedSubtree topBackLabel = topComponents.backLabelKey.currentWidget;
final KeyedSubtree topLeading = topComponents.leadingKey.currentWidget;
if (bottomLargeTitle == null || !bottomLargeExpanded) {
return null;
}
if (bottomLargeTitle != null && topBackLabel != null) {
return new PositionedTransition(
rect: slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
).animate(animation),
child: new FadeTransition(
opacity: fadeOutBy(0.6),
child: new Align(
// As the text shrinks, make sure it's still anchored to the leading
// edge of a constantly sized outer box.
alignment: AlignmentDirectional.centerStart,
child: new DefaultTextStyleTransition(
style: TextStyleTween(
begin: _kLargeTitleTextStyle,
end: topActionsStyle,
).animate(animation),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: bottomLargeTitle.child,
),
),
),
);
}
if (bottomLargeTitle != null && topLeading != null) {
final RelativeRect from = positionInTransitionBox(bottomComponents.largeTitleKey, from: bottomNavBarBox);
final RelativeRectTween positionTween = new RelativeRectTween(
begin: from,
end: from.shift(new Offset(bottomNavBarBox.size.width / 4.0, 0.0)),
);
// Just shift slightly towards the right instead of moving to the back
// label position.
return new PositionedTransition(
rect: positionTween.animate(animation),
child: new FadeTransition(
opacity: fadeOutBy(0.4),
// Keep the font when transitioning into a non-back-label leading.
child: new DefaultTextStyle(
style: _kLargeTitleTextStyle,
child: bottomLargeTitle.child,
),
),
);
}
return null;
}
Widget get bottomTrailing {
final KeyedSubtree bottomTrailing = bottomComponents.trailingKey.currentWidget;
if (bottomTrailing == null) {
return null;
}
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(bottomComponents.trailingKey, from: bottomNavBarBox),
child: new FadeTransition(
opacity: fadeOutBy(0.6),
child: bottomTrailing.child,
),
);
}
Widget get topLeading {
final KeyedSubtree topLeading = topComponents.leadingKey.currentWidget;
if (topLeading == null) {
return null;
}
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(topComponents.leadingKey, from: topNavBarBox),
child: new FadeTransition(
opacity: fadeInFrom(0.6),
child: topLeading.child,
),
);
}
Widget get topBackChevron {
final KeyedSubtree topBackChevron = topComponents.backChevronKey.currentWidget;
final KeyedSubtree bottomBackChevron = bottomComponents.backChevronKey.currentWidget;
if (topBackChevron == null) {
return null;
}
final RelativeRect to = positionInTransitionBox(topComponents.backChevronKey, from: topNavBarBox);
RelativeRect from = to;
// If it's the first page with a back chevron, shift in slightly from the
// right.
if (bottomBackChevron == null) {
final RenderBox topBackChevronBox = topComponents.backChevronKey.currentContext.findRenderObject();
from = to.shift(new Offset(topBackChevronBox.size.width * 2.0, 0.0));
}
final RelativeRectTween positionTween = new RelativeRectTween(
begin: from,
end: to,
);
return new PositionedTransition(
rect: positionTween.animate(animation),
child: new FadeTransition(
opacity: fadeInFrom(bottomBackChevron == null ? 0.7 : 0.4),
child: new DefaultTextStyle(
style: topActionsStyle,
child: topBackChevron.child,
),
),
);
}
Widget get topBackLabel {
final KeyedSubtree bottomMiddle = bottomComponents.middleKey.currentWidget;
final KeyedSubtree bottomLargeTitle = bottomComponents.largeTitleKey.currentWidget;
final KeyedSubtree topBackLabel = topComponents.backLabelKey.currentWidget;
if (topBackLabel == null) {
return null;
}
final RenderAnimatedOpacity topBackLabelOpacity =
topComponents.backLabelKey.currentContext?.ancestorRenderObjectOfType(
const TypeMatcher<RenderAnimatedOpacity>()
);
Animation<double> midClickOpacity;
if (topBackLabelOpacity != null && topBackLabelOpacity.opacity.value < 1.0) {
midClickOpacity = new Tween<double>(
begin: 0.0,
end: topBackLabelOpacity.opacity.value,
).animate(animation);
}
// Pick up from an incoming transition from the large title. This is
// duplicated here from the bottomLargeTitle transition widget because the
// content text might be different. For instance, if the bottomLargeTitle
// text is too long, the topBackLabel will say 'Back' instead of the original
// text.
if (bottomLargeTitle != null &&
topBackLabel != null &&
bottomLargeExpanded
) {
return new PositionedTransition(
rect: slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
).animate(animation),
child: new FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.4),
child: new DefaultTextStyleTransition(
style: TextStyleTween(
begin: _kLargeTitleTextStyle,
end: topActionsStyle,
).animate(animation),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: topBackLabel.child,
),
),
);
}
// The topBackLabel always comes from the large title first if available
// and expanded instead of middle.
if (bottomMiddle != null && topBackLabel != null) {
return new PositionedTransition(
rect: slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
).animate(animation),
child: new FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.3),
child: new DefaultTextStyleTransition(
style: TextStyleTween(
begin: _kMiddleTitleTextStyle,
end: topActionsStyle,
).animate(animation),
child: topBackLabel.child,
),
),
);
}
return null;
}
Widget get topMiddle {
final KeyedSubtree topMiddle = topComponents.middleKey.currentWidget;
if (topMiddle == null) {
return null;
}
// The middle component is non-null when the nav bar is a large title
// nav bar but would be invisible when expanded, therefore don't show it here.
if (!topHasUserMiddle && topLargeExpanded) {
return null;
}
final RelativeRect to = positionInTransitionBox(topComponents.middleKey, from: topNavBarBox);
// Shift in from the trailing edge of the screen.
final RelativeRectTween positionTween = new RelativeRectTween(
begin: to.shift(new Offset(topNavBarBox.size.width / 2.0, 0.0)),
end: to,
);
return new PositionedTransition(
rect: positionTween.animate(animation),
child: new FadeTransition(
opacity: fadeInFrom(0.25),
child: new DefaultTextStyle(
style: _kMiddleTitleTextStyle,
child: topMiddle.child,
),
),
);
}
Widget get topTrailing {
final KeyedSubtree topTrailing = topComponents.trailingKey.currentWidget;
if (topTrailing == null) {
return null;
}
return new Positioned.fromRelativeRect(
rect: positionInTransitionBox(topComponents.trailingKey, from: topNavBarBox),
child: new FadeTransition(
opacity: fadeInFrom(0.4),
child: topTrailing.child,
),
);
}
Widget get topLargeTitle {
final KeyedSubtree topLargeTitle = topComponents.largeTitleKey.currentWidget;
if (topLargeTitle == null || !topLargeExpanded) {
return null;
}
final RelativeRect to = positionInTransitionBox(topComponents.largeTitleKey, from: topNavBarBox);
// Shift in from the trailing edge of the screen.
final RelativeRectTween positionTween = new RelativeRectTween(
begin: to.shift(new Offset(topNavBarBox.size.width, 0.0)),
end: to,
);
return new PositionedTransition(
rect: positionTween.animate(animation),
child: new FadeTransition(
opacity: fadeInFrom(0.3),
child: new DefaultTextStyle(
style: _kLargeTitleTextStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: topLargeTitle.child,
),
),
);
}
}
/// Navigation bars' hero rect tween that will move between the static bars
/// but keep a constant size that's the bigger of both navigation bars.
CreateRectTween _linearTranslateWithLargestRectSizeTween = (Rect begin, Rect end) {
final Size largestSize = new Size(
math.max(begin.size.width, end.size.width),
math.max(begin.size.height, end.size.height),
);
return new RectTween(
begin: begin.topLeft & largestSize,
end: end.topLeft & largestSize,
);
};
final TransitionBuilder _navBarHeroLaunchPadBuilder = (
BuildContext context,
Widget child,
) {
assert(child is _TransitionableNavigationBar);
// Tree reshaping is fine here because the Heroes' child is always a
// _TransitionableNavigationBar which has a GlobalKey.
// Keeping the Hero subtree here is needed (instead of just swapping out the
// anchor nav bars for fixed size boxes during flights) because the nav bar
// and their specific component children may serve as anchor points again if
// another mid-transition flight diversion is triggered.
// This is ok performance-wise because static nav bars are generally cheap to
// build and layout but expensive to GPU render (due to clips and blurs) which
// we're skipping here.
return new Visibility(
maintainSize: true,
maintainAnimation: true,
maintainState: true,
visible: false,
child: child,
);
};
/// Navigation bars' hero flight shuttle builder.
final HeroFlightShuttleBuilder _navBarHeroFlightShuttleBuilder = (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
assert(animation != null);
assert(flightDirection != null);
assert(fromHeroContext != null);
assert(toHeroContext != null);
assert(fromHeroContext.widget is Hero);
assert(toHeroContext.widget is Hero);
final Hero fromHeroWidget = fromHeroContext.widget;
final Hero toHeroWidget = toHeroContext.widget;
assert(fromHeroWidget.child is _TransitionableNavigationBar);
assert(toHeroWidget.child is _TransitionableNavigationBar);
final _TransitionableNavigationBar fromNavBar = fromHeroWidget.child;
final _TransitionableNavigationBar toNavBar = toHeroWidget.child;
assert(fromNavBar.componentsKeys != null);
assert(toNavBar.componentsKeys != null);
assert(
fromNavBar.componentsKeys.navBarBoxKey.currentContext.owner != null,
'The from nav bar to Hero must have been mounted in the previous frame',
);
assert(
toNavBar.componentsKeys.navBarBoxKey.currentContext.owner != null,
'The to nav bar to Hero must have been mounted in the previous frame',
);
switch (flightDirection) {
case HeroFlightDirection.push:
return new _NavigationBarTransition(
animation: animation,
bottomNavBar: fromNavBar,
topNavBar: toNavBar,
);
break;
case HeroFlightDirection.pop:
return new _NavigationBarTransition(
animation: animation,
bottomNavBar: toNavBar,
topNavBar: fromNavBar,
);
}
};
...@@ -37,7 +37,7 @@ import 'route.dart'; ...@@ -37,7 +37,7 @@ import 'route.dart';
/// * [CupertinoTabScaffold], a typical host that supports switching between tabs. /// * [CupertinoTabScaffold], a typical host that supports switching between tabs.
/// * [CupertinoPageRoute], a typical modal page route pushed onto the /// * [CupertinoPageRoute], a typical modal page route pushed onto the
/// [CupertinoTabView]'s [Navigator]. /// [CupertinoTabView]'s [Navigator].
class CupertinoTabView extends StatelessWidget { class CupertinoTabView extends StatefulWidget {
/// Creates the content area for a tab in a [CupertinoTabScaffold]. /// Creates the content area for a tab in a [CupertinoTabScaffold].
const CupertinoTabView({ const CupertinoTabView({
Key key, Key key,
...@@ -101,12 +101,41 @@ class CupertinoTabView extends StatelessWidget { ...@@ -101,12 +101,41 @@ class CupertinoTabView extends StatelessWidget {
/// This list of observers is not shared with ancestor or descendant [Navigator]s. /// This list of observers is not shared with ancestor or descendant [Navigator]s.
final List<NavigatorObserver> navigatorObservers; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new Navigator( return new Navigator(
onGenerateRoute: _onGenerateRoute, onGenerateRoute: _onGenerateRoute,
onUnknownRoute: _onUnknownRoute, onUnknownRoute: _onUnknownRoute,
observers: navigatorObservers, observers: _navigatorObservers,
); );
} }
...@@ -114,12 +143,12 @@ class CupertinoTabView extends StatelessWidget { ...@@ -114,12 +143,12 @@ class CupertinoTabView extends StatelessWidget {
final String name = settings.name; final String name = settings.name;
WidgetBuilder routeBuilder; WidgetBuilder routeBuilder;
String title; String title;
if (name == Navigator.defaultRouteName && builder != null) { if (name == Navigator.defaultRouteName && widget.builder != null) {
routeBuilder = builder; routeBuilder = widget.builder;
title = defaultTitle; title = widget.defaultTitle;
} }
else if (routes != null) else if (widget.routes != null)
routeBuilder = routes[name]; routeBuilder = widget.routes[name];
if (routeBuilder != null) { if (routeBuilder != null) {
return new CupertinoPageRoute<dynamic>( return new CupertinoPageRoute<dynamic>(
builder: routeBuilder, builder: routeBuilder,
...@@ -127,14 +156,14 @@ class CupertinoTabView extends StatelessWidget { ...@@ -127,14 +156,14 @@ class CupertinoTabView extends StatelessWidget {
settings: settings, settings: settings,
); );
} }
if (onGenerateRoute != null) if (widget.onGenerateRoute != null)
return onGenerateRoute(settings); return widget.onGenerateRoute(settings);
return null; return null;
} }
Route<dynamic> _onUnknownRoute(RouteSettings settings) { Route<dynamic> _onUnknownRoute(RouteSettings settings) {
assert(() { assert(() {
if (onUnknownRoute == null) { if (widget.onUnknownRoute == null) {
throw new FlutterError( throw new FlutterError(
'Could not find a generator for route $settings in the $runtimeType.\n' 'Could not find a generator for route $settings in the $runtimeType.\n'
'Generators for routes are searched for in the following order:\n' 'Generators for routes are searched for in the following order:\n'
...@@ -149,7 +178,7 @@ class CupertinoTabView extends StatelessWidget { ...@@ -149,7 +178,7 @@ class CupertinoTabView extends StatelessWidget {
} }
return true; return true;
}()); }());
final Route<dynamic> result = onUnknownRoute(settings); final Route<dynamic> result = widget.onUnknownRoute(settings);
assert(() { assert(() {
if (result == null) { if (result == null) {
throw new FlutterError( throw new FlutterError(
......
...@@ -20,11 +20,36 @@ import 'transitions.dart'; ...@@ -20,11 +20,36 @@ import 'transitions.dart';
/// [MaterialRectArcTween]. /// [MaterialRectArcTween].
typedef Tween<Rect> CreateRectTween(Rect begin, Rect end); 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); typedef void _OnFlightEnded(_HeroFlight flight);
enum _HeroFlightType { /// Direction of the hero's flight based on the navigation operation.
push, // Fly the "to" hero and animate with the "to" route. enum HeroFlightDirection {
pop, // Fly the "to" hero and animate with the "from" route. /// 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. // The bounding box for context in global coordinates.
...@@ -42,8 +67,8 @@ Rect _globalBoundingBoxFor(BuildContext context) { ...@@ -42,8 +67,8 @@ Rect _globalBoundingBoxFor(BuildContext context) {
/// be helpful for orienting the user for the feature to physically move from /// 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 /// 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 /// 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 /// overlay during the transition and while they're in-flight they're, by
/// not shown in their original locations in the old and new routes. /// 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 /// 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 /// navigation happens, the [Hero] widgets on each route are identified
...@@ -52,6 +77,9 @@ Rect _globalBoundingBoxFor(BuildContext context) { ...@@ -52,6 +77,9 @@ Rect _globalBoundingBoxFor(BuildContext context) {
/// ///
/// If a [Hero] is already in flight when navigation occurs, its /// If a [Hero] is already in flight when navigation occurs, its
/// flight animation will be redirected to its new destination. /// 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]. /// Routes must not contain more than one [Hero] for each [tag].
/// ///
...@@ -67,12 +95,24 @@ Rect _globalBoundingBoxFor(BuildContext context) { ...@@ -67,12 +95,24 @@ Rect _globalBoundingBoxFor(BuildContext context) {
/// ///
/// To make the animations look good, it's critical that the widget tree for the /// 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* /// 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 /// is, by default, used to do the transition: when going from route A to route
/// hero's widget is placed over route A's hero's widget, and route A's hero is /// B, route B's hero's widget is placed over route A's hero's widget. If a
/// hidden. Then the widget is animated to route B's hero's position, and then /// [flightShuttleBuilder] is supplied, its output widget is shown during the
/// the widget is inserted into route B. When going back from B to A, route A's /// flight transition instead.
/// hero's widget is placed over where route B's hero's widget was, and then the ///
/// animation goes the other way. /// 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 { class Hero extends StatefulWidget {
/// Create a hero. /// Create a hero.
/// ///
...@@ -81,6 +121,8 @@ class Hero extends StatefulWidget { ...@@ -81,6 +121,8 @@ class Hero extends StatefulWidget {
Key key, Key key,
@required this.tag, @required this.tag,
this.createRectTween, this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
@required this.child, @required this.child,
}) : assert(tag != null), }) : assert(tag != null),
assert(child != null), assert(child != null),
...@@ -115,6 +157,25 @@ class Hero extends StatefulWidget { ...@@ -115,6 +157,25 @@ class Hero extends StatefulWidget {
/// {@macro flutter.widgets.child} /// {@macro flutter.widgets.child}
final Widget 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. // Returns a map of all of the heroes in context, indexed by hero tag.
static Map<Object, _HeroState> _allHeroesFor(BuildContext context) { static Map<Object, _HeroState> _allHeroesFor(BuildContext context) {
assert(context != null); assert(context != null);
...@@ -141,6 +202,10 @@ class Hero extends StatefulWidget { ...@@ -141,6 +202,10 @@ class Hero extends StatefulWidget {
final _HeroState heroState = hero.state; final _HeroState heroState = hero.state;
result[tag] = heroState; result[tag] = heroState;
} }
// Don't perform transitions across different Navigators.
if (element.widget is Navigator) {
return;
}
element.visitChildren(visitor); element.visitChildren(visitor);
} }
context.visitChildElements(visitor); context.visitChildElements(visitor);
...@@ -181,10 +246,14 @@ class _HeroState extends State<Hero> { ...@@ -181,10 +246,14 @@ class _HeroState extends State<Hero> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_placeholderSize != null) { if (_placeholderSize != null) {
if (widget.placeholderBuilder == null) {
return new SizedBox( return new SizedBox(
width: _placeholderSize.width, width: _placeholderSize.width,
height: _placeholderSize.height height: _placeholderSize.height
); );
} else {
return widget.placeholderBuilder(context, widget.child);
}
} }
return new KeyedSubtree( return new KeyedSubtree(
key: _key, key: _key,
...@@ -204,9 +273,10 @@ class _HeroFlightManifest { ...@@ -204,9 +273,10 @@ class _HeroFlightManifest {
@required this.fromHero, @required this.fromHero,
@required this.toHero, @required this.toHero,
@required this.createRectTween, @required this.createRectTween,
@required this.shuttleBuilder,
}) : assert(fromHero.widget.tag == toHero.widget.tag); }) : assert(fromHero.widget.tag == toHero.widget.tag);
final _HeroFlightType type; final HeroFlightDirection type;
final OverlayState overlay; final OverlayState overlay;
final Rect navigatorRect; final Rect navigatorRect;
final PageRoute<dynamic> fromRoute; final PageRoute<dynamic> fromRoute;
...@@ -214,19 +284,21 @@ class _HeroFlightManifest { ...@@ -214,19 +284,21 @@ class _HeroFlightManifest {
final _HeroState fromHero; final _HeroState fromHero;
final _HeroState toHero; final _HeroState toHero;
final CreateRectTween createRectTween; final CreateRectTween createRectTween;
final HeroFlightShuttleBuilder shuttleBuilder;
Object get tag => fromHero.widget.tag; Object get tag => fromHero.widget.tag;
Animation<double> get animation { Animation<double> get animation {
return new CurvedAnimation( return new CurvedAnimation(
parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation, parent: (type == HeroFlightDirection.push) ? toRoute.animation : fromRoute.animation,
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
} }
@override @override
String toString() { 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 { ...@@ -238,7 +310,9 @@ class _HeroFlight {
final _OnFlightEnded onFlightEnded; final _OnFlightEnded onFlightEnded;
Tween<Rect> heroRect; Tween<Rect> heroRectTween;
Widget shuttle;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation; Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation; ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest; _HeroFlightManifest manifest;
...@@ -255,9 +329,18 @@ class _HeroFlight { ...@@ -255,9 +329,18 @@ class _HeroFlight {
// The OverlayEntry WidgetBuilder callback for the hero's overlay. // The OverlayEntry WidgetBuilder callback for the hero's overlay.
Widget _buildOverlay(BuildContext context) { Widget _buildOverlay(BuildContext context) {
assert(manifest != null); assert(manifest != null);
shuttle ??= manifest.shuttleBuilder(
context,
manifest.animation,
manifest.type,
manifest.fromHero.context,
manifest.toHero.context,
);
assert(shuttle != null);
return new AnimatedBuilder( return new AnimatedBuilder(
animation: _proxyAnimation, animation: _proxyAnimation,
child: manifest.toHero.widget, child: shuttle,
builder: (BuildContext context, Widget child) { builder: (BuildContext context, Widget child) {
final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject(); final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject();
if (_aborted || toHeroBox == null || !toHeroBox.attached) { if (_aborted || toHeroBox == null || !toHeroBox.attached) {
...@@ -273,13 +356,13 @@ class _HeroFlight { ...@@ -273,13 +356,13 @@ class _HeroFlight {
// supposed to end up then recreate the heroRect tween. // supposed to end up then recreate the heroRect tween.
final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject(); final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox); final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (toHeroOrigin != heroRect.end.topLeft) { if (toHeroOrigin != heroRectTween.end.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRect.end.size; final Rect heroRectEnd = toHeroOrigin & heroRectTween.end.size;
heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd); heroRectTween = _doCreateRectTween(heroRectTween.begin, heroRectEnd);
} }
} }
final Rect rect = heroRect.evaluate(_proxyAnimation); final Rect rect = heroRectTween.evaluate(_proxyAnimation);
final Size size = manifest.navigatorRect.size; final Size size = manifest.navigatorRect.size;
final RelativeRect offsets = new RelativeRect.fromSize(rect, size); final RelativeRect offsets = new RelativeRect.fromSize(rect, size);
...@@ -291,7 +374,6 @@ class _HeroFlight { ...@@ -291,7 +374,6 @@ class _HeroFlight {
child: new IgnorePointer( child: new IgnorePointer(
child: new RepaintBoundary( child: new RepaintBoundary(
child: new Opacity( child: new Opacity(
key: manifest.toHero._key,
opacity: _heroOpacity.value, opacity: _heroOpacity.value,
child: child, child: child,
), ),
...@@ -322,12 +404,12 @@ class _HeroFlight { ...@@ -322,12 +404,12 @@ class _HeroFlight {
assert(() { assert(() {
final Animation<double> initial = initialManifest.animation; final Animation<double> initial = initialManifest.animation;
assert(initial != null); assert(initial != null);
final _HeroFlightType type = initialManifest.type; final HeroFlightDirection type = initialManifest.type;
assert(type != null); assert(type != null);
switch (type) { switch (type) {
case _HeroFlightType.pop: case HeroFlightDirection.pop:
return initial.value == 1.0 && initial.status == AnimationStatus.reverse; 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 initial.value == 0.0 && initial.status == AnimationStatus.forward;
} }
return null; return null;
...@@ -335,7 +417,7 @@ class _HeroFlight { ...@@ -335,7 +417,7 @@ class _HeroFlight {
manifest = initialManifest; manifest = initialManifest;
if (manifest.type == _HeroFlightType.pop) if (manifest.type == HeroFlightDirection.pop)
_proxyAnimation.parent = new ReverseAnimation(manifest.animation); _proxyAnimation.parent = new ReverseAnimation(manifest.animation);
else else
_proxyAnimation.parent = manifest.animation; _proxyAnimation.parent = manifest.animation;
...@@ -343,7 +425,7 @@ class _HeroFlight { ...@@ -343,7 +425,7 @@ class _HeroFlight {
manifest.fromHero.startFlight(); manifest.fromHero.startFlight();
manifest.toHero.startFlight(); manifest.toHero.startFlight();
heroRect = _doCreateRectTween( heroRectTween = _doCreateRectTween(
_globalBoundingBoxFor(manifest.fromHero.context), _globalBoundingBoxFor(manifest.fromHero.context),
_globalBoundingBoxFor(manifest.toHero.context), _globalBoundingBoxFor(manifest.toHero.context),
); );
...@@ -357,7 +439,7 @@ class _HeroFlight { ...@@ -357,7 +439,7 @@ class _HeroFlight {
void divert(_HeroFlightManifest newManifest) { void divert(_HeroFlightManifest newManifest) {
assert(manifest.tag == newManifest.tag); 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. // A push flight was interrupted by a pop.
assert(newManifest.animation.status == AnimationStatus.reverse); assert(newManifest.animation.status == AnimationStatus.reverse);
assert(manifest.fromHero == newManifest.toHero); assert(manifest.fromHero == newManifest.toHero);
...@@ -371,8 +453,8 @@ class _HeroFlight { ...@@ -371,8 +453,8 @@ class _HeroFlight {
// path for swapped begin and end parameters. We want the pop flight // path for swapped begin and end parameters. We want the pop flight
// path to be the same (in reverse) as the push flight path. // path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation); _proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
heroRect = new ReverseTween<Rect>(heroRect); heroRectTween = new ReverseTween<Rect>(heroRectTween);
} else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) { } else if (manifest.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) {
// A pop flight was interrupted by a push. // A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward); assert(newManifest.animation.status == AnimationStatus.forward);
assert(manifest.toHero == newManifest.fromHero); assert(manifest.toHero == newManifest.fromHero);
...@@ -386,10 +468,10 @@ class _HeroFlight { ...@@ -386,10 +468,10 @@ class _HeroFlight {
if (manifest.fromHero != newManifest.toHero) { if (manifest.fromHero != newManifest.toHero) {
manifest.fromHero.endFlight(); manifest.fromHero.endFlight();
newManifest.toHero.startFlight(); newManifest.toHero.startFlight();
heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context)); heroRectTween = _doCreateRectTween(heroRectTween.end, _globalBoundingBoxFor(newManifest.toHero.context));
} else { } else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203. // 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 { } else {
// A push or a pop flight is heading to a new route, i.e. // A push or a pop flight is heading to a new route, i.e.
...@@ -398,17 +480,24 @@ class _HeroFlight { ...@@ -398,17 +480,24 @@ class _HeroFlight {
assert(manifest.fromHero != newManifest.fromHero); assert(manifest.fromHero != newManifest.fromHero);
assert(manifest.toHero != newManifest.toHero); 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); _proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
else else
_proxyAnimation.parent = newManifest.animation; _proxyAnimation.parent = newManifest.animation;
manifest.fromHero.endFlight(); manifest.fromHero.endFlight();
manifest.toHero.endFlight(); manifest.toHero.endFlight();
// Let the heroes in each of the routes rebuild with their placeholders.
newManifest.fromHero.startFlight(); newManifest.fromHero.startFlight();
newManifest.toHero.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; _aborted = false;
...@@ -455,14 +544,14 @@ class HeroController extends NavigatorObserver { ...@@ -455,14 +544,14 @@ class HeroController extends NavigatorObserver {
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(route != null);
_maybeStartHeroTransition(previousRoute, route, _HeroFlightType.push); _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push);
} }
@override @override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) { void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(route != null);
_maybeStartHeroTransition(route, previousRoute, _HeroFlightType.pop); _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop);
} }
@override @override
...@@ -477,14 +566,14 @@ class HeroController extends NavigatorObserver { ...@@ -477,14 +566,14 @@ class HeroController extends NavigatorObserver {
// If we're transitioning between different page routes, start a hero transition // 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. // 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>) { if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
final PageRoute<dynamic> from = fromRoute; final PageRoute<dynamic> from = fromRoute;
final PageRoute<dynamic> to = toRoute; 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. // 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; return;
// Putting a route offstage changes its animation value to 1.0. Once this // Putting a route offstage changes its animation value to 1.0. Once this
...@@ -493,14 +582,19 @@ class HeroController extends NavigatorObserver { ...@@ -493,14 +582,19 @@ class HeroController extends NavigatorObserver {
to.offstage = to.animation.value == 0.0; to.offstage = to.animation.value == 0.0;
WidgetsBinding.instance.addPostFrameCallback((Duration value) { 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 // Find the matching pairs of heros in from and to and either start or a new
// hero flight, or divert an existing one. // 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 // 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. // end-of-frame callback was called, then don't actually start a transition.
if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) { if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
...@@ -520,6 +614,9 @@ class HeroController extends NavigatorObserver { ...@@ -520,6 +614,9 @@ class HeroController extends NavigatorObserver {
for (Object tag in fromHeroes.keys) { for (Object tag in fromHeroes.keys) {
if (toHeroes[tag] != null) { if (toHeroes[tag] != null) {
final HeroFlightShuttleBuilder fromShuttleBuilder = fromHeroes[tag].widget.flightShuttleBuilder;
final HeroFlightShuttleBuilder toShuttleBuilder = toHeroes[tag].widget.flightShuttleBuilder;
final _HeroFlightManifest manifest = new _HeroFlightManifest( final _HeroFlightManifest manifest = new _HeroFlightManifest(
type: flightType, type: flightType,
overlay: navigator.overlay, overlay: navigator.overlay,
...@@ -529,7 +626,10 @@ class HeroController extends NavigatorObserver { ...@@ -529,7 +626,10 @@ class HeroController extends NavigatorObserver {
fromHero: fromHeroes[tag], fromHero: fromHeroes[tag],
toHero: toHeroes[tag], toHero: toHeroes[tag],
createRectTween: createRectTween, createRectTween: createRectTween,
shuttleBuilder:
toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder,
); );
if (_flights[tag] != null) if (_flights[tag] != null)
_flights[tag].divert(manifest); _flights[tag].divert(manifest);
else else
...@@ -543,4 +643,15 @@ class HeroController extends NavigatorObserver { ...@@ -543,4 +643,15 @@ class HeroController extends NavigatorObserver {
void _handleFlightEnded(_HeroFlight flight) { void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest.tag); _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;
};
} }
...@@ -127,6 +127,24 @@ class BorderRadiusTween extends Tween<BorderRadius> { ...@@ -127,6 +127,24 @@ class BorderRadiusTween extends Tween<BorderRadius> {
BorderRadius lerp(double t) => BorderRadius.lerp(begin, end, t); 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. /// An interpolation between two [Matrix4]s.
/// ///
/// This class specializes the interpolation of [Tween<Matrix4>] to be /// This class specializes the interpolation of [Tween<Matrix4>] to be
......
...@@ -188,6 +188,7 @@ class _SliverPersistentHeaderElement extends RenderObjectElement { ...@@ -188,6 +188,7 @@ class _SliverPersistentHeaderElement extends RenderObjectElement {
@override @override
void performRebuild() { void performRebuild() {
super.performRebuild();
renderObject.triggerRebuild(); renderObject.triggerRebuild();
} }
......
...@@ -10,6 +10,7 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4; ...@@ -10,6 +10,7 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4;
import 'basic.dart'; import 'basic.dart';
import 'container.dart'; import 'container.dart';
import 'framework.dart'; import 'framework.dart';
import 'text.dart';
export 'package:flutter/rendering.dart' show RelativeRect; export 'package:flutter/rendering.dart' show RelativeRect;
...@@ -671,6 +672,64 @@ class AlignTransition extends AnimatedWidget { ...@@ -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. /// A general-purpose widget for building animations.
/// ///
/// AnimatedBuilder is useful for more complex widgets that wish to include /// AnimatedBuilder is useful for more complex widgets that wish to include
......
...@@ -306,6 +306,65 @@ void main() { ...@@ -306,6 +306,65 @@ void main() {
expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 0.0); 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 { testWidgets('Small title can be overridden', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController(); final ScrollController scrollController = new ScrollController();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -390,7 +449,7 @@ void main() { ...@@ -390,7 +449,7 @@ void main() {
)); ));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoButton), findsOneWidget); expect(find.byType(CupertinoButton), findsOneWidget);
expect(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget); expect(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget);
...@@ -405,23 +464,22 @@ void main() { ...@@ -405,23 +464,22 @@ void main() {
)); ));
await tester.pump(); 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.widgetWithText(CupertinoButton, 'Close'), findsOneWidget);
expect(find.text('Close'), findsOneWidget);
// Test popping goes back correctly. // Test popping goes back correctly.
await tester.tap(find.text('Close')); await tester.tap(find.text('Close'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Page 2'), findsOneWidget); expect(find.text('Page 2'), findsOneWidget);
await tester.tap(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint))); await tester.tap(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Home page'), findsOneWidget); expect(find.text('Home page'), findsOneWidget);
}); });
...@@ -438,7 +496,7 @@ void main() { ...@@ -438,7 +496,7 @@ void main() {
builder: (BuildContext context) { builder: (BuildContext context) {
return const CupertinoPageScaffold( return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
previousPageTitle: '0123456789', previousPageTitle: '012345678901',
), ),
child: Placeholder(), child: Placeholder(),
); );
...@@ -449,14 +507,14 @@ void main() { ...@@ -449,14 +507,14 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); 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( tester.state<NavigatorState>(find.byType(Navigator)).push(
new CupertinoPageRoute<void>( new CupertinoPageRoute<void>(
builder: (BuildContext context) { builder: (BuildContext context) {
return const CupertinoPageScaffold( return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
previousPageTitle: '01234567890', previousPageTitle: '0123456789012',
), ),
child: Placeholder(), child: Placeholder(),
); );
......
// 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);
});
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
...@@ -36,6 +37,71 @@ void main() { ...@@ -36,6 +37,71 @@ void main() {
expect(tester.getCenter(find.text('An iPod')).dx, 400.0); 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 { testWidgets('Leading auto-populates with back button with previous title', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
new CupertinoApp( new CupertinoApp(
......
...@@ -239,7 +239,7 @@ void main() { ...@@ -239,7 +239,7 @@ void main() {
// Navigate in tab 2. // Navigate in tab 2.
await tester.tap(find.text('Next')); await tester.tap(find.text('Next'));
await tester.pump(); 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 2 of tab 2'), isOnstage);
expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage); expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage);
...@@ -254,7 +254,7 @@ void main() { ...@@ -254,7 +254,7 @@ void main() {
// Navigate in tab 1. // Navigate in tab 1.
await tester.tap(find.text('Next')); await tester.tap(find.text('Next'));
await tester.pump(); 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 1'), isOnstage);
expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage);
...@@ -268,7 +268,7 @@ void main() { ...@@ -268,7 +268,7 @@ void main() {
// Pop in tab 2 // Pop in tab 2
await tester.tap(find.text('Back')); await tester.tap(find.text('Back'));
await tester.pump(); 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 1 of tab 2'), isOnstage);
expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage); expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage);
......
...@@ -1244,4 +1244,95 @@ void main() { ...@@ -1244,4 +1244,95 @@ void main() {
await tester.pump(duration * 0.1); await tester.pump(duration * 0.1);
expect(tester.getTopLeft(find.byKey(firstKey)).dx, x0); 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);
});
} }
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