From f23c9ae5f8eefbcd1c635e7ec98218524602b8d8 Mon Sep 17 00:00:00 2001 From: xster Date: Tue, 28 Aug 2018 16:44:34 -0700 Subject: [PATCH] Cupertino nav bar transitions between routes (#20322) --- .../cupertino/cupertino_refresh_demo.dart | 6 +- packages/flutter/lib/src/cupertino/app.dart | 8 +- .../flutter/lib/src/cupertino/nav_bar.dart | 1777 ++++++++++++++--- .../flutter/lib/src/cupertino/tab_view.dart | 51 +- packages/flutter/lib/src/widgets/heroes.dart | 199 +- .../lib/src/widgets/implicit_animations.dart | 18 + .../src/widgets/sliver_persistent_header.dart | 1 + .../flutter/lib/src/widgets/transitions.dart | 59 + .../flutter/test/cupertino/nav_bar_test.dart | 76 +- .../cupertino/nav_bar_transition_test.dart | 811 ++++++++ .../flutter/test/cupertino/route_test.dart | 66 + .../flutter/test/cupertino/scaffold_test.dart | 6 +- .../flutter/test/widgets/heroes_test.dart | 91 + 13 files changed, 2827 insertions(+), 342 deletions(-) create mode 100644 packages/flutter/test/cupertino/nav_bar_transition_test.dart diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart index f249dd4947..d1fdc5e018 100644 --- a/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart +++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino_refresh_demo.dart @@ -55,7 +55,11 @@ class _CupertinoRefreshControlDemoState extends State.delayed(const Duration(seconds: 2)) - ..then((_) => setState(() => repopulateList())); + ..then((_) { + if (mounted) { + setState(() => repopulateList()); + } + }); }, ), new SliverSafeArea( diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index db36aa90a0..4718f8e0bd 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -320,13 +320,10 @@ class _AlwaysCupertinoScrollBehavior extends ScrollBehavior { } class _CupertinoAppState extends State { - HeroController _heroController; - List _navigatorObservers; @override void initState() { super.initState(); - _heroController = new HeroController(); // Linear tweening. _updateNavigator(); } @@ -342,9 +339,6 @@ class _CupertinoAppState extends State { widget.routes.isNotEmpty || widget.onGenerateRoute != null || widget.onUnknownRoute != null; - _navigatorObservers = - new List.from(widget.navigatorObservers) - ..add(_heroController); } Widget defaultBuilder(BuildContext context, Widget child) { @@ -361,7 +355,7 @@ class _CupertinoAppState extends State { routes: widget.routes, onGenerateRoute: widget.onGenerateRoute, onUnknownRoute: widget.onUnknownRoute, - navigatorObservers: _navigatorObservers, + navigatorObservers: widget.navigatorObservers, ); if (widget.builder != null) { return widget.builder(context, navigator); diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 7c18074f7c..f51c518e45 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -2,9 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; import 'dart:ui' show ImageFilter; import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -15,6 +17,8 @@ import 'page_scaffold.dart'; import 'route.dart'; /// 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; /// Size increase from expanding the navigation bar into an iOS-11-style large title @@ -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( fontFamily: '.SF Pro Display', fontSize: 34.0, @@ -51,6 +63,78 @@ const TextStyle _kLargeTitleTextStyle = TextStyle( 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( + 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 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. /// /// The navigation bar is a toolbar that minimally consists of a widget, normally @@ -73,13 +157,20 @@ const TextStyle _kLargeTitleTextStyle = TextStyle( /// 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. /// +/// 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: /// /// * [CupertinoPageScaffold], a page layout helper typically hosting the /// [CupertinoNavigationBar]. /// * [CupertinoSliverNavigationBar] for a navigation bar to be placed in a /// 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. const CupertinoNavigationBar({ Key key, @@ -93,8 +184,21 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe this.backgroundColor = _kDefaultNavBarBackgroundColor, this.padding, this.actionsForegroundColor = CupertinoColors.activeBlue, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, }) : assert(automaticallyImplyLeading != 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); /// {@template flutter.cupertino.navBar.leading} @@ -199,6 +303,34 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe /// iOS standard design. 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. @override bool get fullObstruction => backgroundColor.alpha == 0xFF; @@ -208,25 +340,67 @@ class CupertinoNavigationBar extends StatelessWidget implements ObstructingPrefe 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 { + _NavigationBarStaticComponentsKeys keys; + + @override + void initState() { + super.initState(); + keys = new _NavigationBarStaticComponentsKeys(); + } + @override Widget build(BuildContext context) { - final Widget effectiveMiddle = _effectiveTitle( - title: middle, - automaticallyImplyTitle: automaticallyImplyMiddle, - currentRoute: ModalRoute.of(context), + final _NavigationBarStaticComponents components = new _NavigationBarStaticComponents( + keys: keys, + route: 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( - border: border, - backgroundColor: backgroundColor, - child: new _CupertinoPersistentNavigationBar( - leading: leading, - automaticallyImplyLeading: automaticallyImplyLeading, - previousPageTitle: previousPageTitle, - middle: effectiveMiddle, - trailing: trailing, - padding: padding, - actionsForegroundColor: actionsForegroundColor, + final Widget navBar = _wrapWithBackground( + border: widget.border, + backgroundColor: widget.backgroundColor, + child: new _PersistentNavigationBar( + components: components, + padding: widget.padding, + ), + ); + + if (!widget.transitionBetweenRoutes || !_isTransitionable(context)) { + 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 /// route if none is provided and [automaticallyImplyTitle] is true (true by /// 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: /// /// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling /// pages. -class CupertinoSliverNavigationBar extends StatelessWidget { +class CupertinoSliverNavigationBar extends StatefulWidget { /// Creates a navigation bar for scrolling lists. /// /// The [largeTitle] argument is required and must not be null. @@ -282,8 +464,16 @@ class CupertinoSliverNavigationBar extends StatelessWidget { this.backgroundColor = _kDefaultNavBarBackgroundColor, this.padding, this.actionsForegroundColor = CupertinoColors.activeBlue, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, }) : assert(automaticallyImplyLeading != 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); /// The navigation bar's title. @@ -304,6 +494,9 @@ class CupertinoSliverNavigationBar extends StatelessWidget { /// If null and [automaticallyImplyTitle] is true, an appropriate [Text] /// title will be created if the current route is a [CupertinoPageRoute] and /// 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; /// {@macro flutter.cupertino.navBar.leading} @@ -355,73 +548,96 @@ class CupertinoSliverNavigationBar extends StatelessWidget { /// iOS standard design. 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. 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 { + _NavigationBarStaticComponentsKeys keys; + + @override + void initState() { + super.initState(); + keys = new _NavigationBarStaticComponentsKeys(); + } + @override Widget build(BuildContext context) { - final Widget effectiveTitle = _effectiveTitle( - title: largeTitle, - automaticallyImplyTitle: automaticallyImplyTitle, - currentRoute: ModalRoute.of(context), + final _NavigationBarStaticComponents components = new _NavigationBarStaticComponents( + keys: keys, + route: 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( 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, - largeTitle: effectiveTitle, - leading: leading, - automaticallyImplyLeading: automaticallyImplyLeading, - previousPageTitle: previousPageTitle, - middle: middle, - trailing: trailing, - padding: padding, - border: border, - backgroundColor: backgroundColor, - actionsForegroundColor: actionsForegroundColor, + alwaysShowMiddle: widget.middle != null, ), ); } } -class _CupertinoLargeTitleNavigationBarSliverDelegate +class _LargeTitleNavigationBarSliverDelegate 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.largeTitle, - this.leading, - this.automaticallyImplyLeading, - this.previousPageTitle, - this.middle, - this.trailing, - this.padding, - this.border, - 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; + @required this.alwaysShowMiddle, + }) : assert(persistentHeight != null), + assert(alwaysShowMiddle != null), + assert(transitionBetweenRoutes != null); + final _NavigationBarStaticComponentsKeys keys; + final _NavigationBarStaticComponents components; + final Widget userMiddle; final Color backgroundColor; - final Border border; - + final EdgeInsetsDirectional padding; final Color actionsForegroundColor; + final bool transitionBetweenRoutes; + final Object heroTag; + final double persistentHeight; + final bool alwaysShowMiddle; @override double get minExtent => persistentHeight; @@ -433,21 +649,16 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { final bool showLargeTitle = shrinkOffset < maxExtent - minExtent - _kNavBarShowLargeTitleThreshold; - final _CupertinoPersistentNavigationBar persistentNavigationBar = - new _CupertinoPersistentNavigationBar( - leading: leading, - 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, + final _PersistentNavigationBar persistentNavigationBar = + new _PersistentNavigationBar( + components: components, 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, backgroundColor: backgroundColor, child: new Stack( @@ -471,19 +682,19 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate start: _kNavBarEdgePadding, bottom: 8.0, // Bottom has a different padding. ), - child: new DefaultTextStyle( - style: _kLargeTitleTextStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: new SafeArea( + top: false, + bottom: false, child: new AnimatedOpacity( opacity: showLargeTitle ? 1.0 : 0.0, duration: _kNavBarTitleFadeDuration, - child: new SafeArea( - top: false, - bottom: false, - child: new Semantics( - header: true, - child: largeTitle, + child: new Semantics( + header: true, + child: new DefaultTextStyle( + style: _kLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: components.largeTitle, ), ), ), @@ -501,194 +712,102 @@ class _CupertinoLargeTitleNavigationBarSliverDelegate ], ), ); + + if (!transitionBetweenRoutes || !_isTransitionable(context)) { + return navBar; + } + + return new Hero( + tag: heroTag, + createRectTween: _linearTranslateWithLargestRectSizeTween, + flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, + placeholderBuilder: _navBarHeroLaunchPadBuilder, + // 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, + hasUserMiddle: userMiddle != null, + largeExpanded: showLargeTitle, + child: navBar, + ), + ); } @override - bool shouldRebuild(_CupertinoLargeTitleNavigationBarSliverDelegate oldDelegate) { - return persistentHeight != oldDelegate.persistentHeight - || largeTitle != oldDelegate.largeTitle - || leading != oldDelegate.leading - || middle != oldDelegate.middle - || trailing != oldDelegate.trailing - || border != oldDelegate.border + bool shouldRebuild(_LargeTitleNavigationBarSliverDelegate oldDelegate) { + return components != oldDelegate.components + || userMiddle != oldDelegate.userMiddle || backgroundColor != oldDelegate.backgroundColor - || actionsForegroundColor != oldDelegate.actionsForegroundColor; + || border != oldDelegate.border + || padding != oldDelegate.padding + || actionsForegroundColor != oldDelegate.actionsForegroundColor + || transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes + || persistentHeight != oldDelegate.persistentHeight + || alwaysShowMiddle != oldDelegate.alwaysShowMiddle + || heroTag != oldDelegate.heroTag; } } -/// 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; - final SystemUiOverlayStyle overlayStyle = darkBackground - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark; - final DecoratedBox childWithBackground = new DecoratedBox( - decoration: new BoxDecoration( - border: border, - color: backgroundColor, - ), - child: new AnnotatedRegion( - 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 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; -} - /// The top part of the navigation bar that's never scrolled away. /// /// 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 /// doesn't scroll. -class _CupertinoPersistentNavigationBar extends StatelessWidget implements PreferredSizeWidget { - const _CupertinoPersistentNavigationBar({ +class _PersistentNavigationBar extends StatelessWidget { + const _PersistentNavigationBar({ Key key, - this.leading, - this.automaticallyImplyLeading, - this.previousPageTitle, - this.middle, - this.trailing, + this.components, this.padding, - this.actionsForegroundColor, this.middleVisible, }) : super(key: key); - final Widget leading; - - final bool automaticallyImplyLeading; - - final String previousPageTitle; - - final Widget middle; - - final Widget trailing; + final _NavigationBarStaticComponents components; final EdgeInsetsDirectional padding; - - final Color actionsForegroundColor; - /// Whether the middle widget has a visible animated opacity. A null value /// means the middle opacity will not be animated. final bool middleVisible; - @override - Size get preferredSize => const Size.fromHeight(_kNavBarPersistentHeight); - @override Widget build(BuildContext context) { - final TextStyle actionsStyle = new TextStyle( - fontFamily: '.SF UI Text', - fontSize: 17.0, - letterSpacing: -0.24, - color: actionsForegroundColor, - ); + Widget middle = components.middle; - 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 - // it's a plain text title. - final Widget styledMiddle = middle == null - ? null - : new DefaultTextStyle( - style: actionsStyle.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.08, - color: CupertinoColors.black, - ), - child: new Semantics(child: middle, header: true), - ); - - final Widget animatedStyledMiddle = middleVisible == null - ? styledMiddle - : new AnimatedOpacity( - opacity: middleVisible ? 1.0 : 0.0, - duration: _kNavBarTitleFadeDuration, - child: styledMiddle, + if (middle != null) { + middle = new DefaultTextStyle( + style: _kMiddleTitleTextStyle, + child: new Semantics(header: true, child: middle), ); + // When the middle's visibility can change on the fly like with large title + // slivers, wrap with animated opacity. + middle = middleVisible == null + ? middle + : new AnimatedOpacity( + opacity: middleVisible ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: middle, + ); + } - // Auto add back button if leading not provided. - Widget backOrCloseButton; - if (styledLeading == null && automaticallyImplyLeading) { - final ModalRoute currentRoute = ModalRoute.of(context); - if (currentRoute?.canPop == true) { - if (currentRoute is PageRoute && currentRoute?.fullscreenDialog == true) { - backOrCloseButton = new CupertinoButton( - child: const Padding( - padding: EdgeInsetsDirectional.only( - start: _kNavBarEdgePadding, - ), - child: Text('Close'), - ), - padding: EdgeInsets.zero, - onPressed: () { Navigator.maybePop(context); }, - ); - } else { - backOrCloseButton = new CupertinoNavigationBarBackButton( - color: actionsForegroundColor, - previousPageTitle: previousPageTitle, - ); - } - } + Widget leading = components.leading; + final Widget backChevron = components.backChevron; + final Widget backLabel = components.backLabel; + + if (leading == null && backChevron != null && backLabel != null) { + leading = new CupertinoNavigationBarBackButton._assemble( + backChevron, + backLabel, + components.actionsForegroundColor, + ); } Widget paddedToolbar = new NavigationToolbar( - leading: styledLeading ?? backOrCloseButton, - middle: animatedStyledMiddle, - trailing: styledTrailing, + leading: leading, + middle: middle, + trailing: components.trailing, centerMiddle: true, middleSpacing: 6.0, ); @@ -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 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 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 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 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 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 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 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]. /// /// This is automatically inserted into [CupertinoNavigationBar] and @@ -730,9 +1146,20 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { const CupertinoNavigationBarBackButton({ @required this.color, this.previousPageTitle, - }) : assert(color != null); + }) : _backChevron = null, + _backLabel = null, + assert(color != null); - /// The [Color] of the back chevron. + // 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. final Color color; @@ -742,6 +1169,10 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { /// previous routes are both [CupertinoPageRoute]s. final String previousPageTitle; + final Widget _backChevron; + + final Widget _backLabel; + @override Widget build(BuildContext context) { final ModalRoute currentRoute = ModalRoute.of(context); @@ -758,20 +1189,23 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { button: true, child: ConstrainedBox( constraints: const BoxConstraints(minWidth: _kNavBarBackButtonTapWidth), - child: new Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Padding(padding: EdgeInsetsDirectional.only(start: 8.0)), - new _BackChevron(color: color), - const Padding(padding: EdgeInsetsDirectional.only(start: 6.0)), - new Flexible( - child: new _BackLabel( - specifiedPreviousTitle: previousPageTitle, - route: currentRoute, + child: new DefaultTextStyle( + style: _navBarItemStyle(color), + child: new Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Padding(padding: EdgeInsetsDirectional.only(start: 8.0)), + _backChevron ?? const _BackChevron(), + const Padding(padding: EdgeInsetsDirectional.only(start: 6.0)), + new Flexible( + child: _backLabel ?? new _BackLabel( + specifiedPreviousTitle: previousPageTitle, + route: currentRoute, + ), ), - ), - ], + ], + ), ), ), ), @@ -781,16 +1215,14 @@ class CupertinoNavigationBarBackButton extends StatelessWidget { } } -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 Widget build(BuildContext 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 // custom non-square padding. @@ -799,7 +1231,7 @@ class _BackChevron extends StatelessWidget { text: new String.fromCharCode(CupertinoIcons.back.codePoint), style: new TextStyle( inherit: false, - color: color, + color: textStyle.color, fontSize: 34.0, fontFamily: CupertinoIcons.back.fontFamily, package: CupertinoIcons.back.fontPackage, @@ -827,9 +1259,11 @@ class _BackChevron extends StatelessWidget { /// is true. class _BackLabel extends StatelessWidget { const _BackLabel({ + Key key, @required this.specifiedPreviousTitle, @required this.route, - }) : assert(route != null); + }) : assert(route != null), + super(key: key); final String specifiedPreviousTitle; final ModalRoute route; @@ -841,11 +1275,21 @@ class _BackLabel extends StatelessWidget { return const SizedBox(height: 0.0, width: 0.0); } - if (previousTitle.length > 10) { - return const Text('Back'); + Text textWidget = new Text( + 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 @@ -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( + 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 animation; + final _NavigationBarComponentsTransition componentsTransition; + + final Tween heightTween; + final ColorTween backgroundTween; + final BorderTween borderTween; + + @override + Widget build(BuildContext context) { + final List children = [ + // 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 fadeOut = new Tween( + begin: 1.0, + end: 0.0, + ); + static final Tween fadeIn = new Tween( + begin: 0.0, + end: 1.0, + ); + + final Animation 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 fadeInFrom(double t, { Curve curve = Curves.easeIn }) { + return fadeIn.animate( + new CurvedAnimation(curve: new Interval(t, 1.0, curve: curve), parent: animation), + ); + } + + Animation 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() + ); + + Animation midClickOpacity; + if (topBackLabelOpacity != null && topBackLabelOpacity.opacity.value < 1.0) { + midClickOpacity = new Tween( + 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 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, + ); + } +}; diff --git a/packages/flutter/lib/src/cupertino/tab_view.dart b/packages/flutter/lib/src/cupertino/tab_view.dart index 16b42ed901..81a3d17e56 100644 --- a/packages/flutter/lib/src/cupertino/tab_view.dart +++ b/packages/flutter/lib/src/cupertino/tab_view.dart @@ -37,7 +37,7 @@ import 'route.dart'; /// * [CupertinoTabScaffold], a typical host that supports switching between tabs. /// * [CupertinoPageRoute], a typical modal page route pushed onto the /// [CupertinoTabView]'s [Navigator]. -class CupertinoTabView extends StatelessWidget { +class CupertinoTabView extends StatefulWidget { /// Creates the content area for a tab in a [CupertinoTabScaffold]. const CupertinoTabView({ Key key, @@ -101,12 +101,41 @@ class CupertinoTabView extends StatelessWidget { /// This list of observers is not shared with ancestor or descendant [Navigator]s. final List navigatorObservers; + @override + _CupertinoTabViewState createState() { + return new _CupertinoTabViewState(); + } +} + +class _CupertinoTabViewState extends State { + HeroController _heroController; + List _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.from(widget.navigatorObservers) + ..add(_heroController); + } + @override Widget build(BuildContext context) { return new Navigator( onGenerateRoute: _onGenerateRoute, onUnknownRoute: _onUnknownRoute, - observers: navigatorObservers, + observers: _navigatorObservers, ); } @@ -114,12 +143,12 @@ class CupertinoTabView extends StatelessWidget { final String name = settings.name; WidgetBuilder routeBuilder; String title; - if (name == Navigator.defaultRouteName && builder != null) { - routeBuilder = builder; - title = defaultTitle; + if (name == Navigator.defaultRouteName && widget.builder != null) { + routeBuilder = widget.builder; + title = widget.defaultTitle; } - else if (routes != null) - routeBuilder = routes[name]; + else if (widget.routes != null) + routeBuilder = widget.routes[name]; if (routeBuilder != null) { return new CupertinoPageRoute( builder: routeBuilder, @@ -127,14 +156,14 @@ class CupertinoTabView extends StatelessWidget { settings: settings, ); } - if (onGenerateRoute != null) - return onGenerateRoute(settings); + if (widget.onGenerateRoute != null) + return widget.onGenerateRoute(settings); return null; } Route _onUnknownRoute(RouteSettings settings) { assert(() { - if (onUnknownRoute == null) { + if (widget.onUnknownRoute == null) { throw new FlutterError( 'Could not find a generator for route $settings in the $runtimeType.\n' 'Generators for routes are searched for in the following order:\n' @@ -149,7 +178,7 @@ class CupertinoTabView extends StatelessWidget { } return true; }()); - final Route result = onUnknownRoute(settings); + final Route result = widget.onUnknownRoute(settings); assert(() { if (result == null) { throw new FlutterError( diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index 5e674db97f..ce2c080173 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -20,11 +20,36 @@ import 'transitions.dart'; /// [MaterialRectArcTween]. typedef Tween 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 animation, + HeroFlightDirection flightDirection, + BuildContext fromHeroContext, + BuildContext toHeroContext, +); + typedef void _OnFlightEnded(_HeroFlight flight); -enum _HeroFlightType { - push, // Fly the "to" hero and animate with the "to" route. - pop, // Fly the "to" hero and animate with the "from" route. +/// Direction of the hero's flight based on the navigation operation. +enum HeroFlightDirection { + /// A flight triggered by a route push. + /// + /// The animation goes from 0 to 1. + /// + /// If no custom [HeroFlightShuttleBuilder] is supplied, the top route's + /// [Hero] child is shown in flight. + push, + + /// A flight triggered by a route pop. + /// + /// The animation goes from 1 to 0. + /// + /// If no custom [HeroFlightShuttleBuilder] is supplied, the bottom route's + /// [Hero] child is shown in flight. + pop, } // The bounding box for context in global coordinates. @@ -42,8 +67,8 @@ Rect _globalBoundingBoxFor(BuildContext context) { /// be helpful for orienting the user for the feature to physically move from /// one page to the other during the routes' transition. Such an animation /// is called a *hero animation*. The hero widgets "fly" in the Navigator's -/// overlay during the transition and while they're in-flight they're -/// not shown in their original locations in the old and new routes. +/// overlay during the transition and while they're in-flight they're, by +/// default, not shown in their original locations in the old and new routes. /// /// To label a widget as such a feature, wrap it in a [Hero] widget. When /// navigation happens, the [Hero] widgets on each route are identified @@ -52,6 +77,9 @@ Rect _globalBoundingBoxFor(BuildContext context) { /// /// If a [Hero] is already in flight when navigation occurs, its /// flight animation will be redirected to its new destination. + +/// The widget shown in-flight during the transition is, by default, the +/// destination route's [Hero]'s child. /// /// Routes must not contain more than one [Hero] for each [tag]. /// @@ -67,12 +95,24 @@ Rect _globalBoundingBoxFor(BuildContext context) { /// /// To make the animations look good, it's critical that the widget tree for the /// hero in both locations be essentially identical. The widget of the *target* -/// is used to do the transition: when going from route A to route B, route B's -/// hero's widget is placed over route A's hero's widget, and route A's hero is -/// hidden. Then the widget is animated to route B's hero's position, and then -/// the widget is inserted into route B. When going back from B to A, route A's -/// hero's widget is placed over where route B's hero's widget was, and then the -/// animation goes the other way. +/// is, by default, used to do the transition: when going from route A to route +/// B, route B's hero's widget is placed over route A's hero's widget. If a +/// [flightShuttleBuilder] is supplied, its output widget is shown during the +/// flight transition instead. +/// +/// By default, both route A and route B's heroes are hidden while the +/// transitioning widget is animating in-flight above the 2 routes. +/// [placeholderBuilder] can be used to show a custom widget in their place +/// instead once the transition has taken flight. +/// +/// During the transition, the transition widget is animated to route B's hero's +/// position, and then the widget is inserted into route B. When going back from +/// B to A, route A's hero's widget is, by default, placed over where route B's +/// hero's widget was, and then the animation goes the other way. +/// +/// ## Parts of a Hero Transition +/// +/// ![Diagrams with parts of the Hero transition.](https://flutter.github.io/assets-for-api-docs/assets/interaction/heroes.png) class Hero extends StatefulWidget { /// Create a hero. /// @@ -81,6 +121,8 @@ class Hero extends StatefulWidget { Key key, @required this.tag, this.createRectTween, + this.flightShuttleBuilder, + this.placeholderBuilder, @required this.child, }) : assert(tag != null), assert(child != null), @@ -115,6 +157,25 @@ class Hero extends StatefulWidget { /// {@macro flutter.widgets.child} final Widget child; + /// Optional override to supply a widget that's shown during the hero's flight. + /// + /// This in-flight widget can depend on the route transition's animation as + /// well as the incoming and outgoing routes' [Hero] descendants' widgets and + /// layout. + /// + /// When both the source and destination [Hero]s provide a [flightShuttleBuilder], + /// the destination's [flightShuttleBuilder] takes precedence. + /// + /// If none is provided, the destination route's Hero child is shown in-flight + /// by default. + final HeroFlightShuttleBuilder flightShuttleBuilder; + + /// Placeholder widget left in place as the Hero's child once the flight takes off. + /// + /// By default, an empty SizedBox keeping the Hero child's original size is + /// left in place once the Hero shuttle has taken flight. + final TransitionBuilder placeholderBuilder; + // Returns a map of all of the heroes in context, indexed by hero tag. static Map _allHeroesFor(BuildContext context) { assert(context != null); @@ -141,6 +202,10 @@ class Hero extends StatefulWidget { final _HeroState heroState = hero.state; result[tag] = heroState; } + // Don't perform transitions across different Navigators. + if (element.widget is Navigator) { + return; + } element.visitChildren(visitor); } context.visitChildElements(visitor); @@ -181,10 +246,14 @@ class _HeroState extends State { @override Widget build(BuildContext context) { if (_placeholderSize != null) { - return new SizedBox( - width: _placeholderSize.width, - height: _placeholderSize.height - ); + if (widget.placeholderBuilder == null) { + return new SizedBox( + width: _placeholderSize.width, + height: _placeholderSize.height + ); + } else { + return widget.placeholderBuilder(context, widget.child); + } } return new KeyedSubtree( key: _key, @@ -204,9 +273,10 @@ class _HeroFlightManifest { @required this.fromHero, @required this.toHero, @required this.createRectTween, + @required this.shuttleBuilder, }) : assert(fromHero.widget.tag == toHero.widget.tag); - final _HeroFlightType type; + final HeroFlightDirection type; final OverlayState overlay; final Rect navigatorRect; final PageRoute fromRoute; @@ -214,19 +284,21 @@ class _HeroFlightManifest { final _HeroState fromHero; final _HeroState toHero; final CreateRectTween createRectTween; + final HeroFlightShuttleBuilder shuttleBuilder; Object get tag => fromHero.widget.tag; Animation get animation { return new CurvedAnimation( - parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation, + parent: (type == HeroFlightDirection.push) ? toRoute.animation : fromRoute.animation, curve: Curves.fastOutSlowIn, ); } @override String toString() { - return '_HeroFlightManifest($type hero: $tag from: ${fromRoute.settings} to: ${toRoute.settings})'; + return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} ' + 'to route: ${toRoute.settings} with hero: $fromHero to $toHero)'; } } @@ -238,7 +310,9 @@ class _HeroFlight { final _OnFlightEnded onFlightEnded; - Tween heroRect; + Tween heroRectTween; + Widget shuttle; + Animation _heroOpacity = kAlwaysCompleteAnimation; ProxyAnimation _proxyAnimation; _HeroFlightManifest manifest; @@ -255,9 +329,18 @@ class _HeroFlight { // The OverlayEntry WidgetBuilder callback for the hero's overlay. Widget _buildOverlay(BuildContext context) { assert(manifest != null); + shuttle ??= manifest.shuttleBuilder( + context, + manifest.animation, + manifest.type, + manifest.fromHero.context, + manifest.toHero.context, + ); + assert(shuttle != null); + return new AnimatedBuilder( animation: _proxyAnimation, - child: manifest.toHero.widget, + child: shuttle, builder: (BuildContext context, Widget child) { final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject(); if (_aborted || toHeroBox == null || !toHeroBox.attached) { @@ -273,13 +356,13 @@ class _HeroFlight { // supposed to end up then recreate the heroRect tween. final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject(); final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox); - if (toHeroOrigin != heroRect.end.topLeft) { - final Rect heroRectEnd = toHeroOrigin & heroRect.end.size; - heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd); + if (toHeroOrigin != heroRectTween.end.topLeft) { + final Rect heroRectEnd = toHeroOrigin & heroRectTween.end.size; + heroRectTween = _doCreateRectTween(heroRectTween.begin, heroRectEnd); } } - final Rect rect = heroRect.evaluate(_proxyAnimation); + final Rect rect = heroRectTween.evaluate(_proxyAnimation); final Size size = manifest.navigatorRect.size; final RelativeRect offsets = new RelativeRect.fromSize(rect, size); @@ -291,7 +374,6 @@ class _HeroFlight { child: new IgnorePointer( child: new RepaintBoundary( child: new Opacity( - key: manifest.toHero._key, opacity: _heroOpacity.value, child: child, ), @@ -322,12 +404,12 @@ class _HeroFlight { assert(() { final Animation initial = initialManifest.animation; assert(initial != null); - final _HeroFlightType type = initialManifest.type; + final HeroFlightDirection type = initialManifest.type; assert(type != null); switch (type) { - case _HeroFlightType.pop: + case HeroFlightDirection.pop: return initial.value == 1.0 && initial.status == AnimationStatus.reverse; - case _HeroFlightType.push: + case HeroFlightDirection.push: return initial.value == 0.0 && initial.status == AnimationStatus.forward; } return null; @@ -335,7 +417,7 @@ class _HeroFlight { manifest = initialManifest; - if (manifest.type == _HeroFlightType.pop) + if (manifest.type == HeroFlightDirection.pop) _proxyAnimation.parent = new ReverseAnimation(manifest.animation); else _proxyAnimation.parent = manifest.animation; @@ -343,7 +425,7 @@ class _HeroFlight { manifest.fromHero.startFlight(); manifest.toHero.startFlight(); - heroRect = _doCreateRectTween( + heroRectTween = _doCreateRectTween( _globalBoundingBoxFor(manifest.fromHero.context), _globalBoundingBoxFor(manifest.toHero.context), ); @@ -357,7 +439,7 @@ class _HeroFlight { void divert(_HeroFlightManifest newManifest) { assert(manifest.tag == newManifest.tag); - if (manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.pop) { + if (manifest.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) { // A push flight was interrupted by a pop. assert(newManifest.animation.status == AnimationStatus.reverse); assert(manifest.fromHero == newManifest.toHero); @@ -371,8 +453,8 @@ class _HeroFlight { // path for swapped begin and end parameters. We want the pop flight // path to be the same (in reverse) as the push flight path. _proxyAnimation.parent = new ReverseAnimation(newManifest.animation); - heroRect = new ReverseTween(heroRect); - } else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) { + heroRectTween = new ReverseTween(heroRectTween); + } else if (manifest.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) { // A pop flight was interrupted by a push. assert(newManifest.animation.status == AnimationStatus.forward); assert(manifest.toHero == newManifest.fromHero); @@ -386,10 +468,10 @@ class _HeroFlight { if (manifest.fromHero != newManifest.toHero) { manifest.fromHero.endFlight(); newManifest.toHero.startFlight(); - heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context)); + heroRectTween = _doCreateRectTween(heroRectTween.end, _globalBoundingBoxFor(newManifest.toHero.context)); } else { // TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203. - heroRect = _doCreateRectTween(heroRect.end, heroRect.begin); + heroRectTween = _doCreateRectTween(heroRectTween.end, heroRectTween.begin); } } else { // A push or a pop flight is heading to a new route, i.e. @@ -398,17 +480,24 @@ class _HeroFlight { assert(manifest.fromHero != newManifest.fromHero); assert(manifest.toHero != newManifest.toHero); - heroRect = _doCreateRectTween(heroRect.evaluate(_proxyAnimation), _globalBoundingBoxFor(newManifest.toHero.context)); + heroRectTween = _doCreateRectTween(heroRectTween.evaluate(_proxyAnimation), _globalBoundingBoxFor(newManifest.toHero.context)); + shuttle = null; - if (newManifest.type == _HeroFlightType.pop) + if (newManifest.type == HeroFlightDirection.pop) _proxyAnimation.parent = new ReverseAnimation(newManifest.animation); else _proxyAnimation.parent = newManifest.animation; manifest.fromHero.endFlight(); manifest.toHero.endFlight(); + + // Let the heroes in each of the routes rebuild with their placeholders. newManifest.fromHero.startFlight(); newManifest.toHero.startFlight(); + + // Let the transition overlay on top of the routes also rebuild since + // we cleared the old shuttle. + overlayEntry.markNeedsBuild(); } _aborted = false; @@ -455,14 +544,14 @@ class HeroController extends NavigatorObserver { void didPush(Route route, Route previousRoute) { assert(navigator != null); assert(route != null); - _maybeStartHeroTransition(previousRoute, route, _HeroFlightType.push); + _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push); } @override void didPop(Route route, Route previousRoute) { assert(navigator != null); assert(route != null); - _maybeStartHeroTransition(route, previousRoute, _HeroFlightType.pop); + _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop); } @override @@ -477,14 +566,14 @@ class HeroController extends NavigatorObserver { // If we're transitioning between different page routes, start a hero transition // after the toRoute has been laid out with its animation's value at 1.0. - void _maybeStartHeroTransition(Route fromRoute, Route toRoute, _HeroFlightType flightType) { + void _maybeStartHeroTransition(Route fromRoute, Route toRoute, HeroFlightDirection flightType) { if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute && fromRoute is PageRoute) { final PageRoute from = fromRoute; final PageRoute to = toRoute; - final Animation animation = (flightType == _HeroFlightType.push) ? to.animation : from.animation; + final Animation animation = (flightType == HeroFlightDirection.push) ? to.animation : from.animation; // A user gesture may have already completed the pop. - if (flightType == _HeroFlightType.pop && animation.status == AnimationStatus.dismissed) + if (flightType == HeroFlightDirection.pop && animation.status == AnimationStatus.dismissed) return; // Putting a route offstage changes its animation value to 1.0. Once this @@ -493,14 +582,19 @@ class HeroController extends NavigatorObserver { to.offstage = to.animation.value == 0.0; WidgetsBinding.instance.addPostFrameCallback((Duration value) { - _startHeroTransition(from, to, flightType); + _startHeroTransition(from, to, animation, flightType); }); } } // Find the matching pairs of heros in from and to and either start or a new // hero flight, or divert an existing one. - void _startHeroTransition(PageRoute from, PageRoute to, _HeroFlightType flightType) { + void _startHeroTransition( + PageRoute from, + PageRoute to, + Animation animation, + HeroFlightDirection flightType, + ) { // If the navigator or one of the routes subtrees was removed before this // end-of-frame callback was called, then don't actually start a transition. if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) { @@ -520,6 +614,9 @@ class HeroController extends NavigatorObserver { for (Object tag in fromHeroes.keys) { if (toHeroes[tag] != null) { + final HeroFlightShuttleBuilder fromShuttleBuilder = fromHeroes[tag].widget.flightShuttleBuilder; + final HeroFlightShuttleBuilder toShuttleBuilder = toHeroes[tag].widget.flightShuttleBuilder; + final _HeroFlightManifest manifest = new _HeroFlightManifest( type: flightType, overlay: navigator.overlay, @@ -529,7 +626,10 @@ class HeroController extends NavigatorObserver { fromHero: fromHeroes[tag], toHero: toHeroes[tag], createRectTween: createRectTween, + shuttleBuilder: + toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder, ); + if (_flights[tag] != null) _flights[tag].divert(manifest); else @@ -543,4 +643,15 @@ class HeroController extends NavigatorObserver { void _handleFlightEnded(_HeroFlight flight) { _flights.remove(flight.manifest.tag); } + + static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = ( + BuildContext flightContext, + Animation animation, + HeroFlightDirection flightDirection, + BuildContext fromHeroContext, + BuildContext toHeroContext, + ) { + final Hero toHero = toHeroContext.widget; + return toHero.child; + }; } diff --git a/packages/flutter/lib/src/widgets/implicit_animations.dart b/packages/flutter/lib/src/widgets/implicit_animations.dart index 67eb0fc831..22ba326baf 100644 --- a/packages/flutter/lib/src/widgets/implicit_animations.dart +++ b/packages/flutter/lib/src/widgets/implicit_animations.dart @@ -127,6 +127,24 @@ class BorderRadiusTween extends Tween { BorderRadius lerp(double t) => BorderRadius.lerp(begin, end, t); } +/// An interpolation between two [Border]s. +/// +/// This class specializes the interpolation of [Tween] to use +/// [Border.lerp]. +/// +/// See [Tween] for a discussion on how to use interpolation objects. +class BorderTween extends Tween { + /// Creates a [Border] tween. + /// + /// The [begin] and [end] properties may be null; the null value + /// is treated as having no border. + BorderTween({ Border begin, Border end }) : super(begin: begin, end: end); + + /// Returns the value this variable has at the given animation clock value. + @override + Border lerp(double t) => Border.lerp(begin, end, t); +} + /// An interpolation between two [Matrix4]s. /// /// This class specializes the interpolation of [Tween] to be diff --git a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart index 11fdf92590..009afbcf47 100644 --- a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart @@ -188,6 +188,7 @@ class _SliverPersistentHeaderElement extends RenderObjectElement { @override void performRebuild() { + super.performRebuild(); renderObject.triggerRebuild(); } diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index a4d10f8df7..66f5947040 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -10,6 +10,7 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'basic.dart'; import 'container.dart'; import 'framework.dart'; +import 'text.dart'; export 'package:flutter/rendering.dart' show RelativeRect; @@ -671,6 +672,64 @@ class AlignTransition extends AnimatedWidget { } } +/// Animated version of a [DefaultTextStyle] that animates the different properties +/// of its [TextStyle]. +/// +/// See also: +/// +/// * [DefaultTextStyle], which also defines a [TextStyle] for its descendants +/// but is not animated. +class DefaultTextStyleTransition extends AnimatedWidget { + /// Creates an animated [DefaultTextStyle] whose [TextStyle] animation updates + /// the widget. + const DefaultTextStyleTransition({ + Key key, + @required Animation 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 get style => listenable; + + /// How the text should be aligned horizontally. + final TextAlign textAlign; + + /// Whether the text should break at soft line breaks. + /// + /// See [DefaultTextStyle.softWrap] for more details. + final bool softWrap; + + /// How visual overflow should be handled. + /// + final TextOverflow overflow; + + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// + /// See [DefaultTextStyle.maxLines] for more details. + final int maxLines; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + @override + Widget build(BuildContext context) { + return new DefaultTextStyle( + style: style.value, + textAlign: textAlign, + softWrap: softWrap, + overflow: overflow, + maxLines: maxLines, + child: child, + ); + } +} + /// A general-purpose widget for building animations. /// /// AnimatedBuilder is useful for more complex widgets that wish to include diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index 1508f5cf05..366c57f06a 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -306,6 +306,65 @@ void main() { expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 0.0); }); + testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + final Key segmentedControlsKey = new UniqueKey(); + await tester.pumpWidget( + new CupertinoApp( + home: new CupertinoPageScaffold( + child: new CustomScrollView( + controller: scrollController, + slivers: [ + new CupertinoSliverNavigationBar( + middle: new ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200.0), + child: new CupertinoSegmentedControl( + key: segmentedControlsKey, + children: const { + 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( + find.widgetWithText(AnimatedOpacity, 'Title') + ).opacity.value, + 0.0, + ); + }); + testWidgets('Small title can be overridden', (WidgetTester tester) async { final ScrollController scrollController = new ScrollController(); await tester.pumpWidget( @@ -390,7 +449,7 @@ void main() { )); await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.byType(CupertinoButton), findsOneWidget); expect(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget); @@ -405,23 +464,22 @@ void main() { )); await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 500)); - expect(find.byType(CupertinoButton), findsNWidgets(2)); - expect(find.text('Close'), findsOneWidget); + expect(find.widgetWithText(CupertinoButton, 'Close'), findsOneWidget); // Test popping goes back correctly. await tester.tap(find.text('Close')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Page 2'), findsOneWidget); await tester.tap(find.text(new String.fromCharCode(CupertinoIcons.back.codePoint))); await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Home page'), findsOneWidget); }); @@ -438,7 +496,7 @@ void main() { builder: (BuildContext context) { return const CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( - previousPageTitle: '0123456789', + previousPageTitle: '012345678901', ), child: Placeholder(), ); @@ -449,14 +507,14 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - expect(find.widgetWithText(CupertinoButton, '0123456789'), findsOneWidget); + expect(find.widgetWithText(CupertinoButton, '012345678901'), findsOneWidget); tester.state(find.byType(Navigator)).push( new CupertinoPageRoute( builder: (BuildContext context) { return const CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( - previousPageTitle: '01234567890', + previousPageTitle: '0123456789012', ), child: Placeholder(), ); diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart new file mode 100644 index 0000000000..dd936093af --- /dev/null +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -0,0 +1,811 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future startTransitionBetween( + WidgetTester tester, { + Widget from, + Widget to, + String fromTitle, + String toTitle, +}) async { + await tester.pumpWidget( + new CupertinoApp( + home: const Placeholder(), + ), + ); + + tester + .state(find.byType(Navigator)) + .push(new CupertinoPageRoute( + title: fromTitle, + builder: (BuildContext context) => scaffoldForNavBar(from), + )); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state(find.byType(Navigator)) + .push(new CupertinoPageRoute( + 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: [ + 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 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(flying(tester, find.byType(Stack))).children[0]; + expect( + tester + .widget( + find.descendant( + of: find.byWidget(transitionBackgroundBox), + matching: find.byType(SizedBox), + ), + ) + .height, + height, + ); +} + +void checkOpacity(WidgetTester tester, Finder finder, double opacity) { + expect( + tester + .renderObject(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(find.byType(Navigator)) + .push(new CupertinoPageRoute( + title: 'Page 1', + builder: (BuildContext context) => scaffoldForNavBar(null), + )); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state(find.byType(Navigator)) + .push(new CupertinoPageRoute( + 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(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(find.byType(Navigator)) + .push(new CupertinoPageRoute( + 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(find.byType(Navigator)) + .push(new CupertinoPageRoute( + 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); + }); +} diff --git a/packages/flutter/test/cupertino/route_test.dart b/packages/flutter/test/cupertino/route_test.dart index 92197da4c8..982c2c5b6e 100644 --- a/packages/flutter/test/cupertino/route_test.dart +++ b/packages/flutter/test/cupertino/route_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -36,6 +37,71 @@ void main() { expect(tester.getCenter(find.text('An iPod')).dx, 400.0); }); + testWidgets('Large title auto-populates with title', (WidgetTester tester) async { + await tester.pumpWidget( + new CupertinoApp( + home: const Placeholder(), + ), + ); + + tester.state(find.byType(Navigator)).push( + new CupertinoPageRoute( + title: 'An iPod', + builder: (BuildContext context) { + return new CupertinoPageScaffold( + child: new CustomScrollView( + slivers: const [ + 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 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 opacities = titles.map((Element element) { + final RenderAnimatedOpacity renderOpacity = + element.ancestorRenderObjectOfType(const TypeMatcher()); + return renderOpacity.opacity.value; + }); + + expect(opacities, [ + 0.0, // Initially the smaller font title is invisible. + 1.0, // The larger font title is visible. + ]); + + // Check that the large font title is at the right spot. + expect( + tester.getTopLeft(find.byWidget(titles[1].widget)), + const Offset(16.0, 54.0), + ); + + // The smaller, initially invisible title, should still be positioned in the + // center. + expect(tester.getCenter(find.byWidget(titles[0].widget)).dx, 400.0); + }); + testWidgets('Leading auto-populates with back button with previous title', (WidgetTester tester) async { await tester.pumpWidget( new CupertinoApp( diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index b9be03278c..c2b6bcaf0f 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -239,7 +239,7 @@ void main() { // Navigate in tab 2. await tester.tap(find.text('Next')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Page 2 of tab 2'), isOnstage); expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage); @@ -254,7 +254,7 @@ void main() { // Navigate in tab 1. await tester.tap(find.text('Next')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Page 2 of tab 1'), isOnstage); expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); @@ -268,7 +268,7 @@ void main() { // Pop in tab 2 await tester.tap(find.text('Back')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Page 1 of tab 2'), isOnstage); expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage); diff --git a/packages/flutter/test/widgets/heroes_test.dart b/packages/flutter/test/widgets/heroes_test.dart index 56e67700b3..450c4ac865 100644 --- a/packages/flutter/test/widgets/heroes_test.dart +++ b/packages/flutter/test/widgets/heroes_test.dart @@ -1244,4 +1244,95 @@ void main() { await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(firstKey)).dx, x0); }); + + testWidgets('Can override flight shuttle', (WidgetTester tester) async { + await tester.pumpWidget(new MaterialApp( + home: new Material( + child: new ListView( + children: [ + const Hero(tag: 'a', child: Text('foo')), + new Builder(builder: (BuildContext context) { + return new FlatButton( + child: const Text('two'), + onPressed: () => Navigator.push(context, new MaterialPageRoute( + builder: (BuildContext context) { + return new Material( + child: new Hero( + tag: 'a', + child: const Text('bar'), + flightShuttleBuilder: ( + BuildContext flightContext, + Animation 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: [ + 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(context, new MaterialPageRoute( + 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); + }); }