diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 2726576eb2c..064ad2b089a 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -14,6 +14,7 @@ export 'src/cupertino/colors.dart'; export 'src/cupertino/dialog.dart'; export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/page.dart'; +export 'src/cupertino/scaffold.dart'; export 'src/cupertino/slider.dart'; export 'src/cupertino/switch.dart'; export 'src/cupertino/thumb_painter.dart'; diff --git a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart index c1e7efa392f..e28125d9a18 100644 --- a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart +++ b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart @@ -31,7 +31,7 @@ const Color _kDefaultTabBarBorderColor = const Color(0x4C000000); /// default), it will produce a blurring effect to the content behind it. // // TODO(xster): document using with a CupertinoScaffold. -class CupertinoTabBar extends StatelessWidget { +class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { /// Creates a tab bar in the iOS style. CupertinoTabBar({ Key key, @@ -81,10 +81,14 @@ class CupertinoTabBar extends StatelessWidget { /// should configure itself to match the icon theme's size and color. final double iconSize; + /// True if the tab bar's background color has no transparency. + bool get opaque => backgroundColor.alpha == 0xFF; + + @override + Size get preferredSize => const Size.fromHeight(_kTabBarHeight); + @override Widget build(BuildContext context) { - final bool addBlur = backgroundColor.alpha != 0xFF; - Widget result = new DecoratedBox( decoration: new BoxDecoration( border: const Border( @@ -120,7 +124,7 @@ class CupertinoTabBar extends StatelessWidget { ), ); - if (addBlur) { + if (!opaque) { // For non-opaque backgrounds, apply a blur effect. result = new ClipRect( child: new BackdropFilter( @@ -141,6 +145,7 @@ class CupertinoTabBar extends StatelessWidget { _wrapActiveItem( new Expanded( child: new GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap == null ? null : () { onTap(index); }, child: new Padding( padding: const EdgeInsets.only(bottom: 4.0), @@ -175,4 +180,28 @@ class CupertinoTabBar extends StatelessWidget { ), ); } + + /// Create a clone of the current [CupertinoTabBar] but with provided + /// parameters overriden. + CupertinoTabBar copyWith({ + Key key, + List items, + Color backgroundColor, + Color activeColor, + Color inactiveColor, + Size iconSize, + int currentIndex, + ValueChanged onTap, + }) { + return new CupertinoTabBar( + key: key ?? this.key, + items: items ?? this.items, + backgroundColor: backgroundColor ?? this.backgroundColor, + activeColor: activeColor ?? this.activeColor, + inactiveColor: inactiveColor ?? this.inactiveColor, + iconSize: iconSize ?? this.iconSize, + currentIndex: currentIndex ?? this.currentIndex, + onTap: onTap ?? this.onTap, + ); + } } diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 82ecc791d09..fe9bd9b9ef2 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -69,13 +69,14 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid /// The [title] remains black if it's a text as per iOS standard design. final Color actionsForegroundColor; + /// True if the nav bar's background color has no transparency. + bool get opaque => backgroundColor.alpha == 0xFF; + @override Size get preferredSize => const Size.fromHeight(_kNavBarHeight); @override Widget build(BuildContext context) { - final bool addBlur = backgroundColor.alpha != 0xFF; - Widget styledMiddle = middle; if (styledMiddle.runtimeType == Text || styledMiddle.runtimeType == DefaultTextStyle) { // Let the middle be black rather than `actionsForegroundColor` in case @@ -132,7 +133,7 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid ), ); - if (addBlur) { + if (!opaque) { // For non-opaque backgrounds, apply a blur effect. result = new ClipRect( child: new BackdropFilter( diff --git a/packages/flutter/lib/src/cupertino/scaffold.dart b/packages/flutter/lib/src/cupertino/scaffold.dart new file mode 100644 index 00000000000..621ee019d21 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/scaffold.dart @@ -0,0 +1,229 @@ +// 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 'bottom_tab_bar.dart'; +import 'nav_bar.dart'; + +/// Implements a basic iOS application's layout and behavior structure. +/// +/// The scaffold lays out the navigation bar on top, the tab bar at the bottom +/// and tabbed or untabbed content between or behind the bars. +/// +/// For tabbed scaffolds, the tab's active item and the actively showing tab +/// in the content area are automatically connected. +// TODO(xster): describe navigator handlings. +// TODO(xster): add an example. +class CupertinoScaffold extends StatefulWidget { + /// Construct a [CupertinoScaffold] without tabs. + /// + /// The [tabBar] and [rootTabPageBuilder] fields are not used in a [CupertinoScaffold] + /// without tabs. + // TODO(xster): document that page transitions will happen behind the navigation + // bar. + const CupertinoScaffold({ + Key key, + this.navigationBar, + @required this.child, + }) : assert(child != null), + tabBar = null, + rootTabPageBuilder = null, + super(key: key); + + /// Construct a [CupertinoScaffold] with tabs. + /// + /// A [tabBar] and a [rootTabPageBuilder] are required. The [CupertinoScaffold] + /// will automatically listen to the provided [CupertinoTabBar]'s tap callbacks + /// to change the active tab. + /// + /// Tabs' contents are built with the provided [rootTabPageBuilder] at the active + /// tab index. [rootTabPageBuilder] 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. + /// + /// The [child] field is not used in a [CupertinoScaffold] with tabs. + const CupertinoScaffold.tabbed({ + Key key, + this.navigationBar, + @required this.tabBar, + @required this.rootTabPageBuilder, + }) : assert(tabBar != null), + assert(rootTabPageBuilder != null), + child = null, + super(key: key); + + /// The [navigationBar], typically a [CupertinoNavigationBar], is drawn at the + /// top of the screen. + /// + /// If translucent, the main content may slide behind it. + /// Otherwise, the main content's top margin will be offset by its height. + // TODO(xster): document its page transition animation when ready + final PreferredSizeWidget navigationBar; + + /// The [tabBar] is a [CupertinoTabBar] drawn at the bottom of the screen + /// that lets the user switch between different tabs in the main content area + /// when present. + /// + /// This parameter is required and must be non-null when the [new CupertinoScaffold.tabbed] + /// constructor is used. + /// + /// When provided, [CupertinoTabBar.currentIndex] will be ignored and will + /// be managed by the [CupertinoScaffold] to show the currently selected page + /// as the active item index. If [CupertinoTabBar.onTap] is provided, it will + /// still be called. [CupertinoScaffold] automatically also listen to the + /// [CupertinoTabBar]'s `onTap` to change the [CupertinoTabBar]'s `currentIndex` + /// and change the actively displayed tab in [CupertinoScaffold]'s own + /// main content area. + /// + /// If translucent, the main content may slide behind it. + /// Otherwise, the main content's bottom margin will be offset by its height. + final CupertinoTabBar tabBar; + + /// An [IndexedWidgetBuilder] that's called when tabs become active. + /// + /// Used when a tabbed scaffold is constructed via the [new CupertinoScaffold.tabbed] + /// constructor and must be non-null. + /// + /// When the tab becomes inactive, its content is still cached in the widget + /// tree [Offstage] and its animations disabled. + /// + /// Content can slide under the [navigationBar] or the [tabBar] when they're + /// translucent. + final IndexedWidgetBuilder rootTabPageBuilder; + + /// Widget to show in the main content area when the scaffold is used without + /// tabs. + /// + /// Used when the default [new CupertinoScaffold] constructor is used and must + /// be non-null. + /// + /// Content can slide under the [navigationBar] or the [tabBar] when they're + /// translucent. + final Widget child; + + @override + _CupertinoScaffoldState createState() => new _CupertinoScaffoldState(); +} + +class _CupertinoScaffoldState extends State { + int _currentPage = 0; + + /// 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; + if (widget.navigationBar is CupertinoNavigationBar) { + final CupertinoNavigationBar top = widget.navigationBar; + if (top.opaque) + topPadding += top.preferredSize.height; + } + + double bottomPadding = 0.0; + if (widget.tabBar?.opaque ?? false) + bottomPadding = widget.tabBar.preferredSize.height; + + return new Padding( + padding: new EdgeInsets.only(top: topPadding, bottom: bottomPadding), + child: middle, + ); + } + + @override + Widget build(BuildContext context) { + final List stacked = []; + + // The main content being at the bottom is added to the stack first. + if (widget.child != null) { + stacked.add(_padMiddle(widget.child)); + } else if (widget.rootTabPageBuilder != null) { + stacked.add(_padMiddle(new _TabView( + currentTabIndex: _currentPage, + tabNumber: widget.tabBar.items.length, + rootTabPageBuilder: widget.rootTabPageBuilder, + ))); + } + + if (widget.navigationBar != null) { + stacked.add(new Align( + alignment: FractionalOffset.topCenter, + child: widget.navigationBar, + )); + } + + if (widget.tabBar != null) { + stacked.add(new Align( + alignment: FractionalOffset.bottomCenter, + // Override the tab bar's currentIndex to the current tab and hook in + // our own listener to update the _currentPage on top of a possibly user + // provided callback. + child: widget.tabBar.copyWith( + currentIndex: _currentPage, + onTap: (int newIndex) { + setState(() { + _currentPage = newIndex; + }); + // Chain the user's original callback. + if (widget.tabBar.onTap != null) + widget.tabBar.onTap(newIndex); + } + ), + )); + } + + return new Stack( + children: stacked, + ); + } +} + +/// An widget laying out multiple tabs with only one active tab being built +/// at a time and on stage. Off stage tabs' animations are stopped. +class _TabView extends StatefulWidget { + _TabView({ + @required this.currentTabIndex, + @required this.tabNumber, + @required this.rootTabPageBuilder, + }) : assert(currentTabIndex != null), + assert(tabNumber != null && tabNumber > 0), + assert(rootTabPageBuilder != null); + + final int currentTabIndex; + final int tabNumber; + final IndexedWidgetBuilder rootTabPageBuilder; + + @override + _TabViewState createState() => new _TabViewState(); +} + +class _TabViewState extends State<_TabView> { + List tabs; + + @override + void initState() { + super.initState(); + tabs = new List(widget.tabNumber); + } + + @override + Widget build(BuildContext context) { + return new Stack( + 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.rootTabPageBuilder(context, index); + + return new Offstage( + offstage: !active, + child: new TickerMode( + enabled: active, + child: tabs[index] ?? new Container(), + ), + ); + }), + ); + } +} diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 9f0ed934312..e141999d5bd 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -1914,12 +1914,13 @@ class ListBody extends MultiChildRenderObjectWidget { /// placed relative to the stack according to their top, right, bottom, and left /// properties. /// -/// The stack paints its children in order. If you want to change the order in -/// which the children paint, you can rebuild the stack with the children in -/// the new order. If you reorder the children in this way, consider giving the -/// children non-null keys. These keys will cause the framework to move the -/// underlying objects for the children to their new locations rather than -/// recreate them at their new location. +/// The stack paints its children in order with the first child being at the +/// bottom. If you want to change the order in which the children paint, you +/// can rebuild the stack with the children in the new order. If you reorder +/// the children in this way, consider giving the children non-null keys. +/// These keys will cause the framework to move the underlying objects for +/// the children to their new locations rather than recreate them at their +/// new location. /// /// For more details about the stack layout algorithm, see [RenderStack]. /// diff --git a/packages/flutter/lib/src/widgets/navigation_toolbar.dart b/packages/flutter/lib/src/widgets/navigation_toolbar.dart index a03497c76b8..37391f6c3d5 100644 --- a/packages/flutter/lib/src/widgets/navigation_toolbar.dart +++ b/packages/flutter/lib/src/widgets/navigation_toolbar.dart @@ -83,7 +83,7 @@ class _ToolbarLayout extends MultiChildLayoutDelegate { // If false the middle widget should be left justified within the space // between the leading and trailing widgets. // If true the middle widget is centered within the toolbar (not within the horizontal - // space bewteen the leading and trailing widgets). + // space between the leading and trailing widgets). // TODO(xster): document RTL once supported. final bool centerMiddle; diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart new file mode 100644 index 00000000000..40514df5ffb --- /dev/null +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -0,0 +1,203 @@ +// 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/rendering_tester.dart'; +import '../services/mocks_for_image_cache.dart'; + +List selectedTabs; + +void main() { + setUp(() { + selectedTabs = []; + }); + + testWidgets('Contents are behind translucent bar', (WidgetTester tester) async { + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + // TODO(xster): change to a CupertinoPageRoute. + return new PageRouteBuilder( + settings: settings, + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return const CupertinoScaffold( + // Default nav bar is translucent. + navigationBar: const CupertinoNavigationBar( + middle: const Text('Title'), + ), + child: const Center(), + ); + }, + ); + }, + ), + ); + + expect(tester.getTopLeft(find.byType(Center)), const Offset(0.0, 0.0)); + }); + + testWidgets('Contents are between opaque bars', (WidgetTester tester) async { + final Center page1Center = const Center(); + + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + // TODO(xster): change to a CupertinoPageRoute. + return new PageRouteBuilder( + settings: settings, + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return new CupertinoScaffold.tabbed( + navigationBar: const CupertinoNavigationBar( + backgroundColor: CupertinoColors.white, + middle: const Text('Title'), + ), + tabBar: _buildTabBar(), + rootTabPageBuilder: (BuildContext context, int index) { + return index == 0 ? page1Center : new Stack(); + } + ); + }, + ); + }, + ), + ); + + expect(tester.getSize(find.byWidget(page1Center)).height, 600.0 - 44.0 - 50.0); + }); + + testWidgets('Tab switching', (WidgetTester tester) async { + final List tabsPainted = []; + + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + // TODO(xster): change to a CupertinoPageRoute. + return new PageRouteBuilder( + settings: settings, + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return new CupertinoScaffold.tabbed( + navigationBar: const CupertinoNavigationBar( + backgroundColor: CupertinoColors.white, + middle: const Text('Title'), + ), + tabBar: _buildTabBar(), + rootTabPageBuilder: (BuildContext context, int index) { + return new CustomPaint( + child: new Text('Page ${index + 1}'), + painter: new TestCallbackPainter( + onPaint: () { tabsPainted.add(index); } + ) + ); + } + ); + }, + ); + }, + ), + ); + + expect(tabsPainted, [0]); + RichText tab1 = tester.widget(find.descendant( + of: find.text('Tab 1'), + matching: find.byType(RichText), + )); + expect(tab1.text.style.color, CupertinoColors.activeBlue); + RichText tab2 = tester.widget(find.descendant( + of: find.text('Tab 2'), + matching: find.byType(RichText), + )); + expect(tab2.text.style.color, CupertinoColors.inactiveGray); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(tabsPainted, [0, 1]); + tab1 = tester.widget(find.descendant( + of: find.text('Tab 1'), + matching: find.byType(RichText), + )); + expect(tab1.text.style.color, CupertinoColors.inactiveGray); + tab2 = tester.widget(find.descendant( + of: find.text('Tab 2'), + matching: find.byType(RichText), + )); + expect(tab2.text.style.color, CupertinoColors.activeBlue); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(tabsPainted, [0, 1, 0]); + }); + + testWidgets('Tabs are lazy built and moved offstage when inactive', (WidgetTester tester) async { + final List tabsBuilt = []; + + await tester.pumpWidget( + new WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + // TODO(xster): change to a CupertinoPageRoute. + return new PageRouteBuilder( + settings: settings, + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return new CupertinoScaffold.tabbed( + navigationBar: const CupertinoNavigationBar( + backgroundColor: CupertinoColors.white, + middle: const Text('Title'), + ), + tabBar: _buildTabBar(), + rootTabPageBuilder: (BuildContext context, int index) { + tabsBuilt.add(index); + return new Text('Page ${index + 1}'); + } + ); + }, + ); + }, + ), + ); + + expect(tabsBuilt, [0]); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + // Both tabs are built but only one is onstage. + expect(tabsBuilt, [0, 0, 1]); + expect(find.text('Page 1', skipOffstage: false), isOffstage); + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(tabsBuilt, [0, 0, 1, 0, 1]); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2', skipOffstage: false), isOffstage); + }); +} + +CupertinoTabBar _buildTabBar() { + return 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'), + ), + ], + backgroundColor: CupertinoColors.white, + onTap: (int newTab) => selectedTabs.add(newTab), + ); +}