diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index cfc59c5819..46adc04d16 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -9,12 +9,32 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; -// Standard iOS 10 nav bar height without the status bar. -const double _kNavBarHeight = 44.0; +/// Standard iOS nav bar height without the status bar. +const double _kNavBarPersistentHeight = 44.0; + +/// Size increase from expanding the nav bar into an iOS 11 style large title +/// form in a [CustomScrollView]. +const double _kNavBarLargeTitleHeightExtension = 56.0; + +/// Number of logical pixels scrolled down before the title text is transferred +/// from the normal nav bar to a big title below the nav bar. +const double _kNavBarShowLargeTitleThreshold = 10.0; + +const double _kNavBarEdgePadding = 16.0; + +/// Title text transfer fade. +const Duration _kNavBarTitleFadeDuration = const Duration(milliseconds: 150); const Color _kDefaultNavBarBackgroundColor = const Color(0xCCF8F8F8); const Color _kDefaultNavBarBorderColor = const Color(0x4C000000); +const TextStyle _kLargeTitleTextStyle = const TextStyle( + fontSize: 34.0, + fontWeight: FontWeight.bold, + letterSpacing: 0.41, + color: CupertinoColors.black, +); + /// An iOS-styled navigation bar. /// /// The navigation bar is a toolbar that minimally consists of a widget, normally @@ -28,6 +48,10 @@ const Color _kDefaultNavBarBorderColor = const Color(0x4C000000); /// /// 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. +/// +/// Enabling [largeTitle] will create a scrollable second row showing the title +/// in a larger font introduced in iOS 11. The [middle] widget must be a text +/// and the [CupertinoNavigationBar] must be placed in a sliver group in this case. // // TODO(xster): document automatic addition of a CupertinoBackButton. // TODO(xster): add sample code using icons. @@ -41,8 +65,9 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid this.trailing, this.backgroundColor: _kDefaultNavBarBackgroundColor, this.actionsForegroundColor: CupertinoColors.activeBlue, + this.largeTitle: false, }) : assert(middle != null, 'There must be a middle widget, usually a title.'), - super(key: key); + super(key: key); /// Widget to place at the start of the nav bar. Normally a back button /// for a normal page or a cancel button for full page dialogs. @@ -73,8 +98,110 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid /// True if the nav bar's background color has no transparency. bool get opaque => backgroundColor.alpha == 0xFF; + /// Use iOS 11 style large title navigation bars. + /// + /// When true, the navigation bar will split into 2 sections. The static + /// top 44px section will be wrapped in a SliverPersistentHeader and a + /// second scrollable section behind it will show and replace the `middle` + /// text in a larger font when scrolled down. + /// + /// Navigation bars with large titles must be used in a sliver group such + /// as [CustomScrollView]. + final bool largeTitle; + @override - Size get preferredSize => const Size.fromHeight(_kNavBarHeight); + Size get preferredSize => const Size.fromHeight(_kNavBarPersistentHeight); + + @override + Widget build(BuildContext context) { + assert( + !largeTitle || middle is Text, + "largeTitle mode is only possible when 'middle' is a Text widget", + ); + + if (!largeTitle) { + return _wrapWithBackground( + backgroundColor: backgroundColor, + child: new _CupertinoPersistentNavigationBar( + leading: leading, + middle: middle, + trailing: trailing, + actionsForegroundColor: actionsForegroundColor, + ), + ); + } else { + return new SliverPersistentHeader( + pinned: true, // iOS navigation bars are always pinned. + delegate: new _CupertinoLargeTitleNavigationBarSliverDelegate( + persistentHeight: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top, + leading: leading, + middle: middle, + trailing: trailing, + backgroundColor: backgroundColor, + actionsForegroundColor: actionsForegroundColor, + ), + ); + } + } +} + +/// Returns `child` wrapped with background and a bottom border if background color +/// is opaque. Otherwise, also blur with [BackdropFilter]. +Widget _wrapWithBackground({Color backgroundColor, Widget child}) { + final DecoratedBox childWithBackground = new DecoratedBox( + decoration: new BoxDecoration( + border: const Border( + bottom: const BorderSide( + color: _kDefaultNavBarBorderColor, + width: 0.0, // One physical pixel. + style: BorderStyle.solid, + ), + ), + color: backgroundColor, + ), + 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, + ), + ); +} + +/// The top part of the nav bar that's never scrolled away. +/// +/// Consists of the entire nav 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({ + Key key, + this.leading, + @required this.middle, + this.trailing, + this.actionsForegroundColor, + this.middleVisible, + }) : super(key: key); + + final Widget leading; + + final Widget middle; + + final Widget trailing; + + 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) { @@ -101,55 +228,139 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid child: middle, ); + final Widget animatedStyledMiddle = middleVisible == null + ? styledMiddle + : new AnimatedOpacity( + opacity: middleVisible ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: styledMiddle, + ); + // TODO(xster): automatically build a CupertinoBackButton. - Widget result = new DecoratedBox( - decoration: new BoxDecoration( - border: const Border( - bottom: const BorderSide( - color: _kDefaultNavBarBorderColor, - width: 0.0, // One physical pixel. - style: BorderStyle.solid, - ), + return new SizedBox( + height: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top, + child: IconTheme.merge( + data: new IconThemeData( + color: actionsForegroundColor, + size: 22.0, ), - color: backgroundColor, - ), - child: new SizedBox( - height: _kNavBarHeight + MediaQuery.of(context).padding.top, - child: IconTheme.merge( - data: new IconThemeData( - color: actionsForegroundColor, - size: 22.0, + child: new Padding( + padding: new EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + // TODO(xster): dynamically reduce padding when an automatic + // CupertinoBackButton is present. + left: _kNavBarEdgePadding, + right: _kNavBarEdgePadding, ), - child: new Padding( - padding: new EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - // TODO(xster): dynamically reduce padding when an automatic - // CupertinoBackButton is present. - left: 16.0, - right: 16.0, - ), - child: new NavigationToolbar( - leading: styledLeading, - middle: styledMiddle, - trailing: styledTrailing, - centerMiddle: true, - ), + child: new NavigationToolbar( + leading: styledLeading, + middle: animatedStyledMiddle, + trailing: styledTrailing, + centerMiddle: true, ), ), ), ); - - if (!opaque) { - // For non-opaque backgrounds, apply a blur effect. - result = new ClipRect( - child: new BackdropFilter( - filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: result, - ), - ); - } - - return result; + } +} + +class _CupertinoLargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDelegate { + const _CupertinoLargeTitleNavigationBarSliverDelegate({ + @required this.persistentHeight, + this.leading, + @required this.middle, + this.trailing, + this.backgroundColor, + this.actionsForegroundColor, + }) : assert(persistentHeight != null); + + final double persistentHeight; + + final Widget leading; + + final Text middle; + + final Widget trailing; + + final Color backgroundColor; + + final Color actionsForegroundColor; + + @override + double get minExtent => persistentHeight; + + @override + double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final bool showLargeTitle = shrinkOffset < maxExtent - minExtent - _kNavBarShowLargeTitleThreshold; + + final _CupertinoPersistentNavigationBar persistentNavigationBar = + new _CupertinoPersistentNavigationBar( + leading: leading, + middle: middle, + trailing: trailing, + middleVisible: !showLargeTitle, + actionsForegroundColor: actionsForegroundColor, + ); + + return _wrapWithBackground( + backgroundColor: backgroundColor, + child: new Stack( + fit: StackFit.expand, + children: [ + new Positioned( + top: persistentHeight, + left: 0.0, + right: 0.0, + bottom: 0.0, + child: new ClipRect( + // The large title starts at the persistent bar. + // It's aligned with the bottom of the sliver and expands clipped + // and behind the persistent bar. + child: new OverflowBox( + minHeight: 0.0, + maxHeight: double.INFINITY, + alignment: FractionalOffsetDirectional.bottomStart, + child: new Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: 8.0, // Bottom has a different padding. + ), + child: new DefaultTextStyle( + style: _kLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: new AnimatedOpacity( + opacity: showLargeTitle ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: middle, + ) + ), + ), + ), + ), + ), + new Positioned( + left: 0.0, + right: 0.0, + top: 0.0, + child: persistentNavigationBar, + ), + ], + ), + ); + } + + @override + bool shouldRebuild(_CupertinoLargeTitleNavigationBarSliverDelegate oldDelegate) { + return persistentHeight != oldDelegate.persistentHeight || + leading != oldDelegate.leading || + middle != oldDelegate.middle || + trailing != oldDelegate.trailing || + backgroundColor != oldDelegate.backgroundColor || + actionsForegroundColor != oldDelegate.actionsForegroundColor; } } diff --git a/packages/flutter/lib/src/cupertino/scaffold.dart b/packages/flutter/lib/src/cupertino/scaffold.dart index 621ee019d2..ae16827fe2 100644 --- a/packages/flutter/lib/src/cupertino/scaffold.dart +++ b/packages/flutter/lib/src/cupertino/scaffold.dart @@ -113,9 +113,10 @@ class _CupertinoScaffoldState extends State { /// Pad the given middle widget with or without top and bottom offsets depending /// on whether the middle widget should slide behind translucent bars. Widget _padMiddle(Widget middle) { - double topPadding = MediaQuery.of(context).padding.top; + double topPadding = 0.0; if (widget.navigationBar is CupertinoNavigationBar) { final CupertinoNavigationBar top = widget.navigationBar; + topPadding += MediaQuery.of(context).padding.top; if (top.opaque) topPadding += top.preferredSize.height; } diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index 89d385a720..cc7e1e9de8 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test/flutter_test.dart' hide TypeMatcher; int count = 0; @@ -14,9 +14,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return const CupertinoNavigationBar( leading: const CupertinoButton(child: const Text('Something'), onPressed: null,), middle: const Text('Title'), @@ -36,9 +36,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return const CupertinoNavigationBar( middle: const Text('Title'), backgroundColor: const Color(0xFFE5E5E5), @@ -56,9 +56,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return const CupertinoNavigationBar( middle: const Text('Title'), ); @@ -76,9 +76,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return const CupertinoNavigationBar( leading: const _ExpectStyles(color: const Color(0xFF001122), index: 0x000001), middle: const _ExpectStyles(color: const Color(0xFF000000), index: 0x000100), @@ -92,6 +92,118 @@ void main() { ); expect(count, 0x010101); }); + + testWidgets('No slivers with no large titles', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return const CupertinoScaffold( + navigationBar: const CupertinoNavigationBar( + middle: const Text('Title'), + ), + child: const Center(), + ); + }, + ); + }, + ), + ); + + expect(find.byType(SliverPersistentHeader), findsNothing); + }); + + testWidgets('Large title nav bar scrolls', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoScaffold( + child: new CustomScrollView( + controller: scrollController, + slivers: [ + const CupertinoNavigationBar( + middle: const Text('Title'), + largeTitle: true, + ), + 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'), findsNWidgets(2)); // Though only one is visible. + + List titles = tester.elementList(find.text('Title')) + .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); + }); + + Iterable opacities = titles.map((Element element) { + final RenderOpacity renderOpacity = element.ancestorRenderObjectOfType(const TypeMatcher()); + return renderOpacity.opacity; + }); + + expect(opacities, [ + 0.0, // Initially the smaller font title is invisible. + 1.0, // The larger font title is visible. + ]); + + expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0); + expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 56.0); + + scrollController.jumpTo(600.0); + await tester.pump(); // Once to trigger the opacity animation. + await tester.pump(const Duration(milliseconds: 300)); + + titles = tester.elementList(find.text('Title')) + .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); + }); + + opacities = titles.map((Element element) { + final RenderOpacity renderOpacity = element.ancestorRenderObjectOfType(const TypeMatcher()); + return renderOpacity.opacity; + }); + + expect(opacities, [ + 1.0, // Smaller font title now visiblee + 0.0, // Larger font title invisible. + ]); + + // The persistent toolbar doesn't move or change size. + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0); + // The OverflowBox is squished with the text in it. + expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 0.0); + }); } class _ExpectStyles extends StatelessWidget { diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index 34482ff4b3..0608a0464e 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -20,10 +20,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - // TODO(xster): change to a CupertinoPageRoute. - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return const CupertinoScaffold( // Default nav bar is translucent. navigationBar: const CupertinoNavigationBar( @@ -47,10 +46,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - // TODO(xster): change to a CupertinoPageRoute. - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return new CupertinoScaffold.tabbed( navigationBar: const CupertinoNavigationBar( backgroundColor: CupertinoColors.white, @@ -77,10 +75,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - // TODO(xster): change to a CupertinoPageRoute. - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return new CupertinoScaffold.tabbed( navigationBar: const CupertinoNavigationBar( backgroundColor: CupertinoColors.white, @@ -142,10 +139,9 @@ void main() { new WidgetsApp( color: const Color(0xFFFFFFFF), onGenerateRoute: (RouteSettings settings) { - // TODO(xster): change to a CupertinoPageRoute. - return new PageRouteBuilder( + return new CupertinoPageRoute( settings: settings, - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + builder: (BuildContext context) { return new CupertinoScaffold.tabbed( navigationBar: const CupertinoNavigationBar( backgroundColor: CupertinoColors.white,