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

Add iOS 11 style large titles to cupertino nav bar (#12002)

* Things lay out but the effects not right yet

* Remaining functionalities and tests

* one line large title only

* Add more docs

* review
parent e830c5eb
...@@ -9,12 +9,32 @@ import 'package:flutter/widgets.dart'; ...@@ -9,12 +9,32 @@ import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
// Standard iOS 10 nav bar height without the status bar. /// Standard iOS nav bar height without the status bar.
const double _kNavBarHeight = 44.0; 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 _kDefaultNavBarBackgroundColor = const Color(0xCCF8F8F8);
const Color _kDefaultNavBarBorderColor = const Color(0x4C000000); 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. /// An iOS-styled navigation bar.
/// ///
/// The navigation bar is a toolbar that minimally consists of a widget, normally /// The navigation bar is a toolbar that minimally consists of a widget, normally
...@@ -28,6 +48,10 @@ const Color _kDefaultNavBarBorderColor = const Color(0x4C000000); ...@@ -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 /// 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. /// 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): document automatic addition of a CupertinoBackButton.
// TODO(xster): add sample code using icons. // TODO(xster): add sample code using icons.
...@@ -41,6 +65,7 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid ...@@ -41,6 +65,7 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid
this.trailing, this.trailing,
this.backgroundColor: _kDefaultNavBarBackgroundColor, this.backgroundColor: _kDefaultNavBarBackgroundColor,
this.actionsForegroundColor: CupertinoColors.activeBlue, this.actionsForegroundColor: CupertinoColors.activeBlue,
this.largeTitle: false,
}) : assert(middle != null, 'There must be a middle widget, usually a title.'), }) : assert(middle != null, 'There must be a middle widget, usually a title.'),
super(key: key); super(key: key);
...@@ -73,8 +98,110 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid ...@@ -73,8 +98,110 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid
/// True if the nav bar's background color has no transparency. /// True if the nav bar's background color has no transparency.
bool get opaque => backgroundColor.alpha == 0xFF; 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(_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 @override
Size get preferredSize => const Size.fromHeight(_kNavBarHeight); Size get preferredSize => const Size.fromHeight(_kNavBarPersistentHeight);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -101,21 +228,18 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid ...@@ -101,21 +228,18 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid
child: middle, 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. // TODO(xster): automatically build a CupertinoBackButton.
Widget result = new DecoratedBox( return new SizedBox(
decoration: new BoxDecoration( height: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top,
border: const Border(
bottom: const BorderSide(
color: _kDefaultNavBarBorderColor,
width: 0.0, // One physical pixel.
style: BorderStyle.solid,
),
),
color: backgroundColor,
),
child: new SizedBox(
height: _kNavBarHeight + MediaQuery.of(context).padding.top,
child: IconTheme.merge( child: IconTheme.merge(
data: new IconThemeData( data: new IconThemeData(
color: actionsForegroundColor, color: actionsForegroundColor,
...@@ -126,30 +250,117 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid ...@@ -126,30 +250,117 @@ class CupertinoNavigationBar extends StatelessWidget implements PreferredSizeWid
top: MediaQuery.of(context).padding.top, top: MediaQuery.of(context).padding.top,
// TODO(xster): dynamically reduce padding when an automatic // TODO(xster): dynamically reduce padding when an automatic
// CupertinoBackButton is present. // CupertinoBackButton is present.
left: 16.0, left: _kNavBarEdgePadding,
right: 16.0, right: _kNavBarEdgePadding,
), ),
child: new NavigationToolbar( child: new NavigationToolbar(
leading: styledLeading, leading: styledLeading,
middle: styledMiddle, middle: animatedStyledMiddle,
trailing: styledTrailing, trailing: styledTrailing,
centerMiddle: true, centerMiddle: true,
), ),
), ),
), ),
),
); );
}
}
if (!opaque) { class _CupertinoLargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDelegate {
// For non-opaque backgrounds, apply a blur effect. const _CupertinoLargeTitleNavigationBarSliverDelegate({
result = new ClipRect( @required this.persistentHeight,
child: new BackdropFilter( this.leading,
filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), @required this.middle,
child: result, 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: <Widget>[
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,
),
],
), ),
); );
} }
return result; @override
bool shouldRebuild(_CupertinoLargeTitleNavigationBarSliverDelegate oldDelegate) {
return persistentHeight != oldDelegate.persistentHeight ||
leading != oldDelegate.leading ||
middle != oldDelegate.middle ||
trailing != oldDelegate.trailing ||
backgroundColor != oldDelegate.backgroundColor ||
actionsForegroundColor != oldDelegate.actionsForegroundColor;
} }
} }
...@@ -113,9 +113,10 @@ class _CupertinoScaffoldState extends State<CupertinoScaffold> { ...@@ -113,9 +113,10 @@ class _CupertinoScaffoldState extends State<CupertinoScaffold> {
/// Pad the given middle widget with or without top and bottom offsets depending /// Pad the given middle widget with or without top and bottom offsets depending
/// on whether the middle widget should slide behind translucent bars. /// on whether the middle widget should slide behind translucent bars.
Widget _padMiddle(Widget middle) { Widget _padMiddle(Widget middle) {
double topPadding = MediaQuery.of(context).padding.top; double topPadding = 0.0;
if (widget.navigationBar is CupertinoNavigationBar) { if (widget.navigationBar is CupertinoNavigationBar) {
final CupertinoNavigationBar top = widget.navigationBar; final CupertinoNavigationBar top = widget.navigationBar;
topPadding += MediaQuery.of(context).padding.top;
if (top.opaque) if (top.opaque)
topPadding += top.preferredSize.height; topPadding += top.preferredSize.height;
} }
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.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; int count = 0;
...@@ -14,9 +14,9 @@ void main() { ...@@ -14,9 +14,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
return new PageRouteBuilder<Null>( return new CupertinoPageRoute<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return const CupertinoNavigationBar( return const CupertinoNavigationBar(
leading: const CupertinoButton(child: const Text('Something'), onPressed: null,), leading: const CupertinoButton(child: const Text('Something'), onPressed: null,),
middle: const Text('Title'), middle: const Text('Title'),
...@@ -36,9 +36,9 @@ void main() { ...@@ -36,9 +36,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
return new PageRouteBuilder<Null>( return new CupertinoPageRoute<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return const CupertinoNavigationBar( return const CupertinoNavigationBar(
middle: const Text('Title'), middle: const Text('Title'),
backgroundColor: const Color(0xFFE5E5E5), backgroundColor: const Color(0xFFE5E5E5),
...@@ -56,9 +56,9 @@ void main() { ...@@ -56,9 +56,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
return new PageRouteBuilder<Null>( return new CupertinoPageRoute<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return const CupertinoNavigationBar( return const CupertinoNavigationBar(
middle: const Text('Title'), middle: const Text('Title'),
); );
...@@ -76,9 +76,9 @@ void main() { ...@@ -76,9 +76,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
return new PageRouteBuilder<Null>( return new CupertinoPageRoute<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return const CupertinoNavigationBar( return const CupertinoNavigationBar(
leading: const _ExpectStyles(color: const Color(0xFF001122), index: 0x000001), leading: const _ExpectStyles(color: const Color(0xFF001122), index: 0x000001),
middle: const _ExpectStyles(color: const Color(0xFF000000), index: 0x000100), middle: const _ExpectStyles(color: const Color(0xFF000000), index: 0x000100),
...@@ -92,6 +92,118 @@ void main() { ...@@ -92,6 +92,118 @@ void main() {
); );
expect(count, 0x010101); 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<Null>(
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<Null>(
settings: settings,
builder: (BuildContext context) {
return new CupertinoScaffold(
child: new CustomScrollView(
controller: scrollController,
slivers: <Widget>[
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<Element> 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<double> opacities = titles.map((Element element) {
final RenderOpacity renderOpacity = element.ancestorRenderObjectOfType(const TypeMatcher<RenderOpacity>());
return renderOpacity.opacity;
});
expect(opacities, <double> [
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<RenderOpacity>());
return renderOpacity.opacity;
});
expect(opacities, <double> [
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 { class _ExpectStyles extends StatelessWidget {
......
...@@ -20,10 +20,9 @@ void main() { ...@@ -20,10 +20,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
// TODO(xster): change to a CupertinoPageRoute. return new CupertinoPageRoute<Null>(
return new PageRouteBuilder<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return const CupertinoScaffold( return const CupertinoScaffold(
// Default nav bar is translucent. // Default nav bar is translucent.
navigationBar: const CupertinoNavigationBar( navigationBar: const CupertinoNavigationBar(
...@@ -47,10 +46,9 @@ void main() { ...@@ -47,10 +46,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
// TODO(xster): change to a CupertinoPageRoute. return new CupertinoPageRoute<Null>(
return new PageRouteBuilder<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return new CupertinoScaffold.tabbed( return new CupertinoScaffold.tabbed(
navigationBar: const CupertinoNavigationBar( navigationBar: const CupertinoNavigationBar(
backgroundColor: CupertinoColors.white, backgroundColor: CupertinoColors.white,
...@@ -77,10 +75,9 @@ void main() { ...@@ -77,10 +75,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
// TODO(xster): change to a CupertinoPageRoute. return new CupertinoPageRoute<Null>(
return new PageRouteBuilder<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return new CupertinoScaffold.tabbed( return new CupertinoScaffold.tabbed(
navigationBar: const CupertinoNavigationBar( navigationBar: const CupertinoNavigationBar(
backgroundColor: CupertinoColors.white, backgroundColor: CupertinoColors.white,
...@@ -142,10 +139,9 @@ void main() { ...@@ -142,10 +139,9 @@ void main() {
new WidgetsApp( new WidgetsApp(
color: const Color(0xFFFFFFFF), color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) { onGenerateRoute: (RouteSettings settings) {
// TODO(xster): change to a CupertinoPageRoute. return new CupertinoPageRoute<Null>(
return new PageRouteBuilder<Null>(
settings: settings, settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { builder: (BuildContext context) {
return new CupertinoScaffold.tabbed( return new CupertinoScaffold.tabbed(
navigationBar: const CupertinoNavigationBar( navigationBar: const CupertinoNavigationBar(
backgroundColor: CupertinoColors.white, backgroundColor: CupertinoColors.white,
......
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