Commit 3a0b83b1 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Added support for a pinned floating SliverAppBar (#8345)

parent 9ada90a1
......@@ -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,8 +448,11 @@ class _AppBarState extends State<AppBar> {
),
);
Widget appBar = new SizedBox(
height: kToolbarHeight,
// 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,
......@@ -435,14 +461,19 @@ class _AppBarState extends State<AppBar> {
child: toolbar,
),
),
),
);
if (config.bottom != null) {
appBar = new Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
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,
),
);
......
......@@ -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.
......
......@@ -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;
}
}
......@@ -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 { }
......
......@@ -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: <Widget>[
new SliverAppBar(
title: new Text('AppBar Title'),
floating: floating,
pinned: pinned,
expandedHeight: expandedHeight,
bottom: new TabBar(
tabs: <String>['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);
});
}
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