Commit f9e8da6d authored by xster's avatar xster Committed by GitHub

Create a CupertinoTab to support parallel navigation trees in iOS (#12130)

* Refactor CupertinoScaffold

* Rename rootTabPageBuilder to tabBuilder

* fix tab transparency padding

* add a CupertinoTab

* Add default background color

* Add a bunch of examples

* A bunch of tests

* Refactor CupertinoScaffold

* Rename rootTabPageBuilder to tabBuilder

* fix tab transparency padding

* Add default background color

* review notes

* fix test

* review

* Rename CupertinoTab to CupertinoTabView

* remove final ; in sample code for analyzer
parent 6d470927
......@@ -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';
......@@ -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({
......
......@@ -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.
//
......
......@@ -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<Widget> stacked = <Widget>[];
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) {
......
......@@ -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<T> extends PageRoute<T> {
/// Creates a page route for use in an iOS designed app.
///
......
......@@ -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: <BottomNavigationBarItem> [
/// // ...
/// ],
/// ),
/// 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<Null>(
/// 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<Widget>.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);
......
// 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 <NavigatorObserver>[],
}) : 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<String, WidgetBuilder> 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<NavigatorObserver> navigatorObservers;
@override
Widget build(BuildContext context) {
return new Navigator(
onGenerateRoute: _onGenerateRoute,
onUnknownRoute: _onUnknownRoute,
observers: navigatorObservers,
);
}
Route<dynamic> _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<dynamic>(
builder: routeBuilder,
settings: settings,
);
}
if (onGenerateRoute != null)
return onGenerateRoute(settings);
return null;
}
Route<dynamic> _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<dynamic> 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
......@@ -488,7 +488,6 @@ class _MaterialAppState extends State<MaterialApp> {
return null;
}
Route<dynamic> _onUnknownRoute(RouteSettings settings) {
assert(() {
if (widget.onUnknownRoute == null) {
......
......@@ -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
......
......@@ -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<Null>(
settings: settings,
builder: (BuildContext context) {
return new CupertinoTabScaffold(
tabBar: new CupertinoTabBar(
items: <BottomNavigationBarItem>[
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<Null>(
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);
});
}
// 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<Null>(
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<Null>(
settings: settings,
builder: (BuildContext context) {
return new CupertinoTabView(
routes: <String, WidgetBuilder>{
'/': (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<Null>(
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: <String, WidgetBuilder>{
'/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<Null>(
settings: settings,
builder: (BuildContext context) {
return new CupertinoTabView(
onGenerateRoute: (RouteSettings settings) {
if (settings.name == Navigator.defaultRouteName) {
return new CupertinoPageRoute<Null>(
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<Null>(
settings: settings,
builder: (BuildContext context) {
return new CupertinoTabView(
onUnknownRoute: (RouteSettings settings) {
unknownForRouteCalled = settings.name;
},
);
},
);
},
),
);
expect(tester.takeException(), isFlutterError);
expect(unknownForRouteCalled, '/');
});
}
......@@ -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));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment