diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index bd11d96501..c5cc458ec0 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -96,6 +96,29 @@ class _ToolbarLayout extends MultiChildLayoutDelegate { bool shouldRelayout(_ToolbarLayout oldDelegate) => centerTitle != oldDelegate.centerTitle; } +// Bottom justify the kToolbarHeight child which may overflow the top. +class _ToolbarContainerLayout extends SingleChildLayoutDelegate { + const _ToolbarContainerLayout(); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.tighten(height: kToolbarHeight); + } + + @override + Size getSize(BoxConstraints constraints) { + return new Size(constraints.maxWidth, kToolbarHeight); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return new Offset(0.0, size.height - childSize.height); + } + + @override + bool shouldRelayout(_ToolbarContainerLayout oldDelegate) => false; +} + // TODO(eseidel) Toolbar needs to change size based on orientation: // http://material.google.com/layout/structure.html#structure-app-bar // Mobile Landscape: 48dp @@ -425,24 +448,32 @@ class _AppBarState extends State { ), ); - Widget appBar = new SizedBox( - height: kToolbarHeight, - child: new IconTheme.merge( - context: context, - data: appBarIconTheme, - child: new DefaultTextStyle( - style: sideStyle, - child: toolbar, + // If the toolbar is allocated less than kToolbarHeight make it + // appear to scroll upwards within its shrinking container. + Widget appBar = new ClipRect( + child: new CustomSingleChildLayout( + delegate: const _ToolbarContainerLayout(), + child: new IconTheme.merge( + context: context, + data: appBarIconTheme, + child: new DefaultTextStyle( + style: sideStyle, + child: toolbar, + ), ), ), ); - if (config.bottom != null) { appBar = new Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - appBar, + new Flexible( + child: new ConstrainedBox( + constraints: new BoxConstraints(maxHeight: kToolbarHeight), + child: appBar, + ), + ), config.bottomOpacity == 1.0 ? config.bottom : new Opacity( opacity: const Interval(0.25, 1.0, curve: Curves.fastOutSlowIn).transform(config.bottomOpacity), child: config.bottom, @@ -494,7 +525,9 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { @required this.primary, @required this.centerTitle, @required this.expandedHeight, + @required this.collapsedHeight, @required this.topPadding, + @required this.floating, @required this.pinned, }) : bottom = bottom, _bottomHeight = bottom?.bottomHeight ?? 0.0 { @@ -514,21 +547,24 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { final bool primary; final bool centerTitle; final double expandedHeight; + final double collapsedHeight; final double topPadding; + final bool floating; final bool pinned; final double _bottomHeight; @override - double get minExtent => topPadding + kToolbarHeight + _bottomHeight; + double get minExtent => collapsedHeight ?? (topPadding + kToolbarHeight + _bottomHeight); @override - double get maxExtent => math.max(topPadding + (expandedHeight ?? kToolbarHeight), minExtent); + double get maxExtent => math.max(topPadding + (expandedHeight ?? kToolbarHeight + _bottomHeight), minExtent); @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - double visibleMainHeight = maxExtent - shrinkOffset - topPadding; - double toolbarOpacity = pinned ? 1.0 : ((visibleMainHeight - _bottomHeight) / kToolbarHeight).clamp(0.0, 1.0); + final double visibleMainHeight = maxExtent - shrinkOffset - topPadding; + final double toolbarOpacity = pinned && !floating ? 1.0 + : ((visibleMainHeight - _bottomHeight) / kToolbarHeight).clamp(0.0, 1.0); return FlexibleSpaceBar.createSettings( minExtent: minExtent, maxExtent: maxExtent, @@ -569,7 +605,9 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { || primary != oldDelegate.primary || centerTitle != oldDelegate.centerTitle || expandedHeight != oldDelegate.expandedHeight - || topPadding != oldDelegate.topPadding; + || topPadding != oldDelegate.topPadding + || pinned != oldDelegate.pinned + || floating != oldDelegate.floating; } @override @@ -630,7 +668,7 @@ class SliverAppBar extends StatelessWidget { assert(primary != null); assert(floating != null); assert(pinned != null); - assert(!floating || !pinned); + assert(pinned && floating ? bottom != null : true); } /// A widget to display before the [title]. @@ -763,6 +801,10 @@ class SliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { + final double topPadding = primary ? MediaQuery.of(context).padding.top : 0.0; + final double collapsedHeight = (pinned && floating && bottom != null) + ? bottom.bottomHeight + topPadding : null; + return new SliverPersistentHeader( floating: floating, pinned: pinned, @@ -780,7 +822,9 @@ class SliverAppBar extends StatelessWidget { primary: primary, centerTitle: centerTitle, expandedHeight: expandedHeight, - topPadding: primary ? MediaQuery.of(context).padding.top : 0.0, + collapsedHeight: collapsedHeight, + topPadding: topPadding, + floating: floating, pinned: pinned, ), ); diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart index f4b993c155..e17eba7281 100644 --- a/packages/flutter/lib/src/rendering/shifted_box.dart +++ b/packages/flutter/lib/src/rendering/shifted_box.dart @@ -700,6 +700,10 @@ class RenderFractionallySizedOverflowBox extends RenderAligningShiftedBox { /// A delegate for computing the layout of a render object with a single child. abstract class SingleChildLayoutDelegate { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const SingleChildLayoutDelegate(); + // TODO(abarth): This class should take a Listenable to drive relayout. /// The size of this object given the incoming constraints. diff --git a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart index 70db13963b..125790af5b 100644 --- a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart @@ -265,6 +265,23 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste // direction. Negative if we're scrolled off the top. double _childPosition; + // Update [geometry] and return the new value for [childMainAxisPosition]. + @protected + double updateGeometry() { + final double maxExtent = this.maxExtent; + final double paintExtent = maxExtent - _effectiveScrollOffset; + final double layoutExtent = maxExtent - constraints.scrollOffset; + geometry = new SliverGeometry( + scrollExtent: maxExtent, + paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent), + layoutExtent: layoutExtent.clamp(0.0, constraints.remainingPaintExtent), + maxPaintExtent: maxExtent, + hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. + ); + return math.min(0.0, paintExtent - childExtent); + } + + @override void performLayout() { final double maxExtent = this.maxExtent; @@ -285,16 +302,7 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste _effectiveScrollOffset = constraints.scrollOffset; } layoutChild(_effectiveScrollOffset, maxExtent, overlapsContent: _effectiveScrollOffset < constraints.scrollOffset); - final double paintExtent = maxExtent - _effectiveScrollOffset; - final double layoutExtent = (maxExtent - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent); - geometry = new SliverGeometry( - scrollExtent: maxExtent, - paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent), - layoutExtent: layoutExtent, - maxPaintExtent: maxExtent, - hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. - ); - _childPosition = math.min(0.0, paintExtent - childExtent); + _childPosition = updateGeometry(); _lastActualScrollOffset = constraints.scrollOffset; } @@ -310,3 +318,25 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste description.add('effective scroll offset: ${_effectiveScrollOffset?.toStringAsFixed(1)}'); } } + +abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPersistentHeader { + RenderSliverFloatingPinnedPersistentHeader({ + RenderBox child, + }) : super(child: child); + + @override + double updateGeometry() { + final double minExtent = this.maxExtent; + final double maxExtent = this.maxExtent; + final double paintExtent = (maxExtent - _effectiveScrollOffset); + final double layoutExtent = (maxExtent - constraints.scrollOffset); + geometry = new SliverGeometry( + scrollExtent: maxExtent, + paintExtent: paintExtent.clamp(minExtent, constraints.remainingPaintExtent), + layoutExtent: layoutExtent.clamp(0.0, constraints.remainingPaintExtent - minExtent), + maxPaintExtent: maxExtent, + hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. + ); + return 0.0; + } +} diff --git a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart index 7a500b8830..ff2296a31f 100644 --- a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart @@ -31,7 +31,6 @@ class SliverPersistentHeader extends StatelessWidget { assert(delegate != null); assert(pinned != null); assert(floating != null); - assert(!pinned || !floating); } final SliverPersistentHeaderDelegate delegate; @@ -42,6 +41,8 @@ class SliverPersistentHeader extends StatelessWidget { @override Widget build(BuildContext context) { + if (floating && pinned) + return new _SliverFloatingPinnedPersistentHeader(delegate: delegate); if (pinned) return new _SliverPinnedPersistentHeader(delegate: delegate); if (floating) @@ -227,6 +228,23 @@ class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjec } } +// This class exists to work around https://github.com/dart-lang/sdk/issues/15101 +abstract class _RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPinnedPersistentHeader { } + +class _RenderSliverFloatingPinnedPersistentHeaderForWidgets extends _RenderSliverFloatingPinnedPersistentHeader with _RenderSliverPersistentHeaderForWidgetsMixin { } + +class _SliverFloatingPinnedPersistentHeader extends _SliverPersistentHeaderRenderObjectWidget { + _SliverFloatingPinnedPersistentHeader({ + Key key, + @required SliverPersistentHeaderDelegate delegate, + }) : super(key: key, delegate: delegate); + + @override + _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) { + return new _RenderSliverFloatingPinnedPersistentHeaderForWidgets(); + } +} + // This class exists to work around https://github.com/dart-lang/sdk/issues/15101 abstract class _RenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersistentHeader { } diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index 800f016fd0..c0f64d17fe 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -4,8 +4,55 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +Widget buildSliverAppBarApp({ bool floating, bool pinned, double expandedHeight }) { + return new Scaffold( + body: new CustomScrollView( + primary: true, + slivers: [ + new SliverAppBar( + title: new Text('AppBar Title'), + floating: floating, + pinned: pinned, + expandedHeight: expandedHeight, + bottom: new TabBar( + tabs: ['A','B','C'].map((String t) => new Tab(text: 'TAB $t')).toList(), + ), + ), + new SliverToBoxAdapter( + child: new Container( + height: 1200.0, + decoration: new BoxDecoration(backgroundColor: Colors.orange[400]), + ), + ), + ], + ), + ); +} + +ScrollController primaryScrollController(WidgetTester tester) { + return PrimaryScrollController.of(tester.element(find.byType(CustomScrollView))); +} + +bool appBarIsVisible(WidgetTester tester) { + final RenderSliver sliver = tester.element(find.byType(SliverAppBar)).findRenderObject(); + return sliver.geometry.visible; +} + +double appBarHeight(WidgetTester tester) { + final Element element = tester.element(find.byType(AppBar)); + final RenderBox box = element.findRenderObject(); + return box.size.height; +} + +double tabBarHeight(WidgetTester tester) { + final Element element = tester.element(find.byType(TabBar)); + final RenderBox box = element.findRenderObject(); + return box.size.height; +} + void main() { testWidgets('AppBar centers title on iOS', (WidgetTester tester) async { await tester.pumpWidget( @@ -305,4 +352,105 @@ void main() { expect(tester.getSize(shareButton), new Size(48.0, 56.0)); }); + testWidgets('SliverAppBar default configuration', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverAppBarApp( + floating: false, + pinned: false, + expandedHeight: null, + )); + + ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(appBarIsVisible(tester), true); + + final double initialAppBarHeight = appBarHeight(tester); + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar partially out of view + controller.jumpTo(50.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar out of view + controller.jumpTo(600.0); + await tester.pump(); + expect(appBarIsVisible(tester), false); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, pinned', (WidgetTester tester) async { + + await tester.pumpWidget(buildSliverAppBarApp( + floating: false, + pinned: true, + expandedHeight: 128.0, + )); + + ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), 128.0); + + final double initialAppBarHeight = 128.0; + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar, collapsing the expanded height. At this + // point both the toolbar and the tabbar are visible. + controller.jumpTo(600.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(tabBarHeight(tester), initialTabBarHeight); + expect(appBarHeight(tester), lessThan(initialAppBarHeight)); + expect(appBarHeight(tester), greaterThan(initialTabBarHeight)); + + // Scroll the not-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, pinned and floating', (WidgetTester tester) async { + + await tester.pumpWidget(buildSliverAppBarApp( + floating: true, + pinned: true, + expandedHeight: 128.0, + )); + + ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), 128.0); + + final double initialAppBarHeight = 128.0; + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar, collapsing the expanded height. At this + // point only the tabBar is visible. + controller.jumpTo(600.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(tabBarHeight(tester), initialTabBarHeight); + expect(appBarHeight(tester), lessThan(initialAppBarHeight)); + expect(appBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(appBarIsVisible(tester), true); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); }