diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 916b61b159..2fcff8c8c5 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -18,6 +18,7 @@ export 'src/cupertino/route.dart'; export 'src/cupertino/slider.dart'; export 'src/cupertino/switch.dart'; export 'src/cupertino/tab_scaffold.dart'; +export 'src/cupertino/tab_view.dart'; export 'src/cupertino/text_selection.dart'; export 'src/cupertino/thumb_painter.dart'; export 'widgets.dart'; diff --git a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart index e28125d9a1..384dcce452 100644 --- a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart +++ b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart @@ -29,8 +29,11 @@ const Color _kDefaultTabBarBorderColor = 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. -// -// TODO(xster): document using with a CupertinoScaffold. +/// +/// See also: +/// +/// * [CupertinoTabScaffold] which hosts the [CupertinoTabBar] at the bottom. +/// * [BottomNavigationBarItem] typical item in a [CupertinoTabBar]. class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { /// Creates a tab bar in the iOS style. CupertinoTabBar({ diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 9901bcf4d9..f0fa9d1f71 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -50,8 +50,13 @@ const TextStyle _kLargeTitleTextStyle = const 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. /// +/// 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. +/// /// See also: /// +/// * [CupertinoPageScaffold] page layout helper typically hosting the [CupertinoNavigationBar]. /// * [CupertinoSliverNavigationBar] for a nav bar to be placed in a sliver and /// that supports iOS 11 style large titles. // diff --git a/packages/flutter/lib/src/cupertino/page_scaffold.dart b/packages/flutter/lib/src/cupertino/page_scaffold.dart index 4c43b16f79..c699032ef1 100644 --- a/packages/flutter/lib/src/cupertino/page_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/page_scaffold.dart @@ -12,7 +12,11 @@ import 'nav_bar.dart'; /// /// The scaffold lays out the navigation bar on top and the content between or /// behind the navigation bar. -// TODO(xster): add an example. +/// +/// See also: +/// +/// * [CupertinoPageRoute] a modal page route that typically hosts a [CupertinoPageRoute] +/// with support for iOS style page transitions. class CupertinoPageScaffold extends StatelessWidget { const CupertinoPageScaffold({ Key key, @@ -37,20 +41,30 @@ class CupertinoPageScaffold extends StatelessWidget { @override Widget build(BuildContext context) { final List stacked = []; + Widget childWithMediaQuery = child; + double topPadding = 0.0; if (navigationBar != null) { topPadding += navigationBar.preferredSize.height; // If the navigation bar has a preferred size, pad it and the OS status // bar as well. Otherwise, let the content extend to the complete top // of the page. - if (topPadding > 0.0) - topPadding += MediaQuery.of(context).padding.top; + if (topPadding > 0.0) { + final EdgeInsets mediaQueryPadding = MediaQuery.of(context).padding; + topPadding += mediaQueryPadding.top; + childWithMediaQuery = new MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: mediaQueryPadding.copyWith(top: 0.0), + ), + child: child, + ); + } } // The main content being at the bottom is added to the stack first. stacked.add(new Padding( padding: new EdgeInsets.only(top: topPadding), - child: child, + child: childWithMediaQuery, )); if (navigationBar != null) { diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 6e87f5db91..3eb14eaf7f 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -69,6 +69,8 @@ final DecorationTween _kGradientShadowTween = new DecorationTween( /// /// * [MaterialPageRoute] for an adaptive [PageRoute] that uses a platform /// appropriate transition. +/// * [CupertinoPageScaffold] typical content of a [CupertinoPageRoute] implementing +/// iOS style layout with navigation bar on top. class CupertinoPageRoute extends PageRoute { /// Creates a page route for use in an iOS designed app. /// diff --git a/packages/flutter/lib/src/cupertino/tab_scaffold.dart b/packages/flutter/lib/src/cupertino/tab_scaffold.dart index 71c3b39f87..75775c3684 100644 --- a/packages/flutter/lib/src/cupertino/tab_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/tab_scaffold.dart @@ -19,8 +19,67 @@ import 'bottom_tab_bar.dart'; /// tab index. [tabBuilder] must be able to build the same number of /// pages as the [tabBar.items.length]. Inactive tabs will be moved [Offstage] /// and its animations disabled. -// TODO(xster): describe navigator handlings. -// TODO(xster): add an example. +/// +/// Use [CupertinoTabView] as the content of each tab to support tabs with parallel +/// navigation state and history. +/// +/// ## Sample code +/// +/// A sample code implementing a typical iOS information architecture with tabs. +/// +/// ```dart +/// new CupertinoTabScaffold( +/// tabBar: new CupertinoTabBar( +/// items: [ +/// // ... +/// ], +/// ), +/// tabBuilder: (BuildContext context, int index) { +/// return new CupertinoTabView( +/// builder: (BuildContext context) { +/// return new CupertinoPageScaffold( +/// navigationBar: new CupertinoNavigationBar( +/// middle: new Text('Page 1 of tab $index'), +/// ), +/// child: new Center( +/// child: new CupertinoButton( +/// child: const Text('Next page'), +/// onPressed: () { +/// Navigator.of(context).push( +/// new CupertinoPageRoute( +/// builder: (BuildContext context) { +/// return new CupertinoPageScaffold( +/// navigationBar: new CupertinoNavigationBar( +/// middle: new Text('Page 2 of tab $index'), +/// ), +/// child: new Center( +/// child: new CupertinoButton( +/// child: const Text('Back'), +/// onPressed: () { Navigator.of(context).pop(); }, +/// ), +/// ), +/// ); +/// }, +/// ), +/// ); +/// }, +/// ), +/// ), +/// ); +/// }, +/// ); +/// }, +/// ) +/// ``` +/// +/// See also: +/// +/// * [CupertinoTabBar] bottom tab bars inserted in the scaffold. +/// * [CupertinoTabView] a typical root content of each tap that holds its own +/// [Navigator] stack. +/// * [CupertinoPageRoute] a route hosting modal pages with iOS style transitions. +/// * [CupertinoPageScaffold] typical contents of an iOS modal page implementing +/// layout with a navigation bar on top. class CupertinoTabScaffold extends StatefulWidget { const CupertinoTabScaffold({ Key key, @@ -48,6 +107,10 @@ class CupertinoTabScaffold extends StatefulWidget { /// An [IndexedWidgetBuilder] that's called when tabs become active. /// + /// The widgets built by [IndexedWidgetBuilder] is typically a [CupertinoTabView] + /// in order to achieve the parallel hierarchies information architecture seen + /// on iOS apps with tab bars. + /// /// When the tab becomes inactive, its content is still cached in the widget /// tree [Offstage] and its animations disabled. /// @@ -138,7 +201,6 @@ class _TabViewState extends State<_TabView> { children: new List.generate(widget.tabNumber, (int index) { final bool active = index == widget.currentTabIndex; - // TODO(xster): lazily replace empty tabs with Navigators instead. if (active || tabs[index] != null) tabs[index] = widget.tabBuilder(context, index); diff --git a/packages/flutter/lib/src/cupertino/tab_view.dart b/packages/flutter/lib/src/cupertino/tab_view.dart new file mode 100644 index 0000000000..eefdc71d41 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/tab_view.dart @@ -0,0 +1,154 @@ +// Copyright 2017 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'route.dart'; + +/// A single tab view with its own [Navigator] state and history. +/// +/// A typical tab view used as the content of each tab in a [CupertinoTabScaffold] +/// where multiple tabs with parallel navigation states and history can +/// co-exist. +/// +/// [CupertinoTabView] configures the top-level [Navigator] to search for routes +/// in the following order: +/// +/// 1. For the `/` route, the [builder] property, if non-null, is used. +/// +/// 2. Otherwise, the [routes] table is used, if it has an entry for the route, +/// including `/` if [builder] is not specified. +/// +/// 3. Otherwise, [onGenerateRoute] is called, if provided. It should return a +/// non-null value for any _valid_ route not handled by [builder] and [routes]. +/// +/// 4. Finally if all else fails [onUnknownRoute] is called. +/// +/// These navigation properties are not shared with any sibling [CupertinoTabView] +/// nor any ancestor or descendent [Navigator] instances. +/// +/// See also: +/// +/// * [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 { + const CupertinoTabView({ + Key key, + this.builder, + this.routes, + this.onGenerateRoute, + this.onUnknownRoute, + this.navigatorObservers: const [], + }) : assert(navigatorObservers != null), + super(key: key); + + /// The widget builder for the default route of the tab view + /// ([Navigator.defaultRouteName], which is `/`). + /// + /// If a [builder] is specified, then [routes] must not include an entry for `/`, + /// as [builder] takes its place. + final WidgetBuilder builder; + + /// This tab view's routing table. + /// + /// When a named route is pushed with [Navigator.pushNamed] inside this tab view, + /// the route name is looked up in this map. If the name is present, + /// the associated [WidgetBuilder] is used to construct a [CupertinoPageRoute] + /// that performs an appropriate transition to the new route. + /// + /// If the tab view only has one page, then you can specify it using [builder] instead. + /// + /// If [builder] is specified, then it implies an entry in this table for the + /// [Navigator.defaultRouteName] route (`/`), and it is an error to + /// redundantly provide such a route in the [routes] table. + /// + /// If a route is requested that is not specified in this table (or by + /// [builder]), then the [onGenerateRoute] callback is called to build the page + /// instead. + /// + /// This routing table is not shared with any routing tables of ancestor or + /// descendent [Navigator]s. + final Map routes; + + /// The route generator callback used when the tab view is navigated to a named route. + /// + /// This is used if [routes] does not contain the requested route. + final RouteFactory onGenerateRoute; + + /// Called when [onGenerateRoute] also fails to generate a route. + /// + /// This callback is typically used for error handling. For example, this + /// callback might always generate a "not found" page that describes the route + /// that wasn't found. + /// + /// The default implementation pushes a route that displays an ugly error + /// message. + final RouteFactory onUnknownRoute; + + /// The list of observers for the [Navigator] created in this tab view. + /// + /// This list of observers is not shared with ancestor or descendent [Navigator]s. + final List navigatorObservers; + + @override + Widget build(BuildContext context) { + return new Navigator( + onGenerateRoute: _onGenerateRoute, + onUnknownRoute: _onUnknownRoute, + observers: navigatorObservers, + ); + } + + Route _onGenerateRoute(RouteSettings settings) { + final String name = settings.name; + WidgetBuilder routeBuilder; + if (name == Navigator.defaultRouteName && builder != null) + routeBuilder = builder; + else if (routes != null) + routeBuilder = routes[name]; + if (routeBuilder != null) { + return new CupertinoPageRoute( + builder: routeBuilder, + settings: settings, + ); + } + if (onGenerateRoute != null) + return onGenerateRoute(settings); + return null; + } + + Route _onUnknownRoute(RouteSettings settings) { + assert(() { + if (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' + ' 1. For the "/" route, the "builder" property, if non-null, is used.\n' + ' 2. Otherwise, the "routes" table is used, if it has an entry for ' + 'the route.\n' + ' 3. Otherwise, onGenerateRoute is called. It should return a ' + 'non-null value for any valid route not handled by "builder" and "routes".\n' + ' 4. Finally if all else fails onUnknownRoute is called.\n' + 'Unfortunately, onUnknownRoute was not set.' + ); + } + return true; + }); + final Route result = onUnknownRoute(settings); + assert(() { + if (result == null) { + throw new FlutterError( + 'The onUnknownRoute callback returned null.\n' + 'When the $runtimeType requested the route $settings from its ' + 'onUnknownRoute callback, the callback returned null. Such callbacks ' + 'must never return null.' + ); + } + return true; + }); + return result; + } +} \ No newline at end of file diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index a5605bda47..7afa11c6f0 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -488,7 +488,6 @@ class _MaterialAppState extends State { return null; } - Route _onUnknownRoute(RouteSettings settings) { assert(() { if (widget.onUnknownRoute == null) { diff --git a/packages/flutter/lib/src/painting/edge_insets.dart b/packages/flutter/lib/src/painting/edge_insets.dart index 603bc96212..b9297f42ec 100644 --- a/packages/flutter/lib/src/painting/edge_insets.dart +++ b/packages/flutter/lib/src/painting/edge_insets.dart @@ -559,6 +559,22 @@ class EdgeInsets extends EdgeInsetsGeometry { @override EdgeInsets resolve(TextDirection direction) => this; + + /// Creates a copy of this EdgeInsets but with the given fields replaced + /// with the new values. + EdgeInsets copyWith({ + double left, + double top, + double right, + double bottom, +}) { + return new EdgeInsets.only( + left: left ?? this.left, + top: top ?? this.top, + right: right ?? this.right, + bottom: bottom ?? this.bottom, + ); + } } /// An immutable set of offsets in each of the four cardinal directions, but diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index 4fd7272d8f..0111fd9544 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -77,4 +77,121 @@ void main() { expect(tester.getSize(find.byWidget(page1Center)).height, 600.0 - 44.0 - 50.0); }); + + testWidgets('iOS independent tab navigation', (WidgetTester tester) async { + // A full on iOS information architecture app with 2 tabs, and 2 pages + // in each with independent navigation states. + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabScaffold( + tabBar: new CupertinoTabBar( + items: [ + const BottomNavigationBarItem( + icon: const ImageIcon(const TestImageProvider(24, 24)), + title: const Text('Tab 1'), + ), + const BottomNavigationBarItem( + icon: const ImageIcon(const TestImageProvider(24, 24)), + title: const Text('Tab 2'), + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + // For 1-indexed readability. + ++index; + return new CupertinoTabView( + builder: (BuildContext context) { + return new CupertinoPageScaffold( + navigationBar: new CupertinoNavigationBar( + middle: new Text('Page 1 of tab $index'), + ), + child: new Center( + child: new CupertinoButton( + child: const Text('Next'), + onPressed: () { + Navigator.of(context).push( + new CupertinoPageRoute( + builder: (BuildContext context) { + return new CupertinoPageScaffold( + navigationBar: new CupertinoNavigationBar( + middle: new Text('Page 2 of tab $index'), + ), + child: new Center( + child: new CupertinoButton( + child: const Text('Back'), + onPressed: (){ + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ); + }, + ); + }, + ), + ); + + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 1 of tab 2'), findsNothing); // Lazy building so not built yet. + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(find.text('Page 1 of tab 1'), findsNothing); // It's offstage now. + expect(find.text('Page 1 of tab 1', skipOffstage: false), findsOneWidget); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + + // Navigate in tab 2. + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Page 2 of tab 2'), isOnstage); + expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + // Independent navigation stacks. + expect(find.text('Page 1 of tab 1'), isOnstage); + expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); + + // Navigate in tab 1. + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Page 2 of tab 1'), isOnstage); + expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(find.text('Page 2 of tab 2'), isOnstage); + expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage); + + // Pop in tab 2 + await tester.tap(find.text('Back')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + 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/cupertino/tab_test.dart b/packages/flutter/test/cupertino/tab_test.dart new file mode 100644 index 0000000000..a3bb8b3d18 --- /dev/null +++ b/packages/flutter/test/cupertino/tab_test.dart @@ -0,0 +1,137 @@ +// Copyright 2017 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_test/flutter_test.dart'; + +void main() { + testWidgets('Use home', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabView( + builder: (BuildContext context) => const Text('home'), + ); + }, + ); + }, + ), + ); + + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('Use routes', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabView( + routes: { + '/': (BuildContext context) => const Text('first route'), + }, + ); + }, + ); + }, + ), + ); + + expect(find.text('first route'), findsOneWidget); + }); + + testWidgets('Use home and named routes', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabView( + builder: (BuildContext context) { + return new CupertinoButton( + child: const Text('go to second page'), + onPressed: () { + Navigator.of(context).pushNamed('/2'); + }, + ); + }, + routes: { + '/2': (BuildContext context) => const Text('second named route'), + }, + ); + }, + ); + }, + ), + ); + + expect(find.text('go to second page'), findsOneWidget); + await tester.tap(find.text('go to second page')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('second named route'), findsOneWidget); + }); + + testWidgets('Use onGenerateRoute', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabView( + onGenerateRoute: (RouteSettings settings) { + if (settings.name == Navigator.defaultRouteName) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return const Text('generated home'); + } + ); + } + }, + ); + }, + ); + }, + ), + ); + + expect(find.text('generated home'), findsOneWidget); + }); + + testWidgets('Use onUnknownRoute', (WidgetTester tester) async { + String unknownForRouteCalled; + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return new CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return new CupertinoTabView( + onUnknownRoute: (RouteSettings settings) { + unknownForRouteCalled = settings.name; + }, + ); + }, + ); + }, + ), + ); + + expect(tester.takeException(), isFlutterError); + expect(unknownForRouteCalled, '/'); + }); +} diff --git a/packages/flutter/test/painting/edge_insets_test.dart b/packages/flutter/test/painting/edge_insets_test.dart index 93331c3d74..5239074c5f 100644 --- a/packages/flutter/test/painting/edge_insets_test.dart +++ b/packages/flutter/test/painting/edge_insets_test.dart @@ -94,6 +94,12 @@ void main() { expect(const EdgeInsetsDirectional.only(top: 1.0).add(const EdgeInsets.only(top: 2.0)), const EdgeInsets.only(top: 3.0)); }); + test('EdgeInsets copyWith', () { + final EdgeInsets sourceEdgeInsets = const EdgeInsets.only(left: 1.0, top: 2.0, bottom: 3.0, right: 4.0); + final EdgeInsets copy = sourceEdgeInsets.copyWith(left: 5.0, top: 6.0); + expect(copy, const EdgeInsets.only(left: 5.0, top: 6.0, bottom: 3.0, right: 4.0)); + }); + test('EdgeInsetsGeometry.lerp(...)', () { expect(EdgeInsetsGeometry.lerp(const EdgeInsetsDirectional.only(end: 10.0), null, 0.5), const EdgeInsetsDirectional.only(end: 5.0)); expect(EdgeInsetsGeometry.lerp(const EdgeInsetsDirectional.only(start: 10.0), null, 0.5), const EdgeInsetsDirectional.only(start: 5.0));