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';
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(_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(_kNavBarHeight);
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,
),
);
}
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;
return result;
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,
),
],
),
);
}
@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> {
/// 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;
}
......
......@@ -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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<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 {
......
......@@ -20,10 +20,9 @@ void main() {
new WidgetsApp(
color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) {
// TODO(xster): change to a CupertinoPageRoute.
return new PageRouteBuilder<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> 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<Null>(
return new CupertinoPageRoute<Null>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
builder: (BuildContext context) {
return new CupertinoScaffold.tabbed(
navigationBar: const CupertinoNavigationBar(
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