Commit 28bb89c6 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Support for snapping floating app bars (#9156)

parent bf017b79
......@@ -5,6 +5,7 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
......@@ -494,6 +495,58 @@ class _AppBarState extends State<AppBar> {
}
}
class _FloatingAppBar extends StatefulWidget {
_FloatingAppBar({ Key key, this.child }) : super(key: key);
final Widget child;
@override
_FloatingAppBarState createState() => new _FloatingAppBarState();
}
// A wrapper for the widget created by _SliverAppBarDelegate that starts and
/// stops the floating appbar's snap-into-view or snap-out-of-view animation.
class _FloatingAppBarState extends State<_FloatingAppBar> {
ScrollPosition _position;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_position != null)
_position.isScrollingNotifier.removeListener(_isScrollingListener);
_position = Scrollable.of(context)?.position;
if (_position != null)
_position.isScrollingNotifier.addListener(_isScrollingListener);
}
@override
void dispose() {
if (_position != null)
_position.isScrollingNotifier.removeListener(_isScrollingListener);
super.dispose();
}
RenderSliverFloatingPersistentHeader _headerRenderer() {
return context.ancestorRenderObjectOfType(const TypeMatcher<RenderSliverFloatingPersistentHeader>());
}
void _isScrollingListener() {
if (_position == null)
return;
// When a scroll stops, then maybe snap the appbar into view.
// Similarly, when a scroll starts, then maybe stop the snap animation.
final RenderSliverFloatingPersistentHeader header = _headerRenderer();
if (_position.isScrollingNotifier.value)
header?.maybeStopSnapAnimation(_position.userScrollDirection);
else
header?.maybeStartSnapAnimation(_position.userScrollDirection);
}
@override
Widget build(BuildContext context) => widget.child;
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
@required this.leading,
......@@ -513,6 +566,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
@required this.topPadding,
@required this.floating,
@required this.pinned,
@required this.snapConfiguration,
}) : _bottomHeight = bottom?.bottomHeight ?? 0.0 {
assert(primary || topPadding == 0.0);
}
......@@ -543,12 +597,15 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
@override
double get maxExtent => math.max(topPadding + (expandedHeight ?? kToolbarHeight + _bottomHeight), minExtent);
@override
final FloatingHeaderSnapConfiguration snapConfiguration;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final double visibleMainHeight = maxExtent - shrinkOffset - topPadding;
final double toolbarOpacity = pinned && !floating ? 1.0
: ((visibleMainHeight - _bottomHeight) / kToolbarHeight).clamp(0.0, 1.0);
return FlexibleSpaceBar.createSettings(
final Widget appBar = FlexibleSpaceBar.createSettings(
minExtent: minExtent,
maxExtent: maxExtent,
currentExtent: math.max(minExtent, maxExtent - shrinkOffset),
......@@ -570,6 +627,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
bottomOpacity: pinned ? 1.0 : (visibleMainHeight / _bottomHeight).clamp(0.0, 1.0),
),
);
return floating ? new _FloatingAppBar(child: appBar) : appBar;
}
@override
......@@ -590,7 +648,8 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|| expandedHeight != oldDelegate.expandedHeight
|| topPadding != oldDelegate.topPadding
|| pinned != oldDelegate.pinned
|| floating != oldDelegate.floating;
|| floating != oldDelegate.floating
|| snapConfiguration != oldDelegate.snapConfiguration;
}
@override
......@@ -628,7 +687,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
/// * [FlexibleSpaceBar], which is used with [flexibleSpace] when the app bar
/// can expand and collapse.
/// * <https://material.google.com/layout/structure.html#structure-toolbars>
class SliverAppBar extends StatelessWidget {
class SliverAppBar extends StatefulWidget {
/// Creates a material design app bar that can be placed in a [CustomScrollView].
SliverAppBar({
Key key,
......@@ -647,11 +706,13 @@ class SliverAppBar extends StatelessWidget {
this.expandedHeight,
this.floating: false,
this.pinned: false,
this.snap: false,
}) : super(key: key) {
assert(primary != null);
assert(floating != null);
assert(pinned != null);
assert(pinned && floating ? bottom != null : true);
assert(snap != null);
}
/// A widget to display before the [title].
......@@ -776,6 +837,13 @@ class SliverAppBar extends StatelessWidget {
///
/// Otherwise, the user will need to scroll near the top of the scroll view to
/// reveal the app bar.
///
/// See also:
///
/// * If [snap] is true then a scroll that exposes the app bar will trigger
/// an animation that slides the entire app bar into view. Similarly if
/// a scroll dismisses the app bar, the animation will slide it completely
/// out of view.
final bool floating;
/// Whether the app bar should remain visible at the start of the scroll view.
......@@ -784,33 +852,80 @@ class SliverAppBar extends StatelessWidget {
/// remain visible rather than being scrolled out of view.
final bool pinned;
/// If [snap] and [floating] are true then the floating app bar will "snap"
/// into view.
///
/// If [snap] is true then a scroll that exposes the floating app bar will
/// trigger an animation that slides the entire app bar into view. Similarly if
/// a scroll dismisses the app bar, the animation will slide the app bar
/// completely out of view.
///
/// Snapping only applies when the app bar is floating, not when the appbar
/// appears at the top of its scroll view.
final bool snap;
@override
_SliverAppBarState createState() => new _SliverAppBarState();
}
// This class is only Stateful because it owns the TickerProvider used
// by the floating appbar snap animation (via FloatingHeaderSnapConfiguration).
class _SliverAppBarState extends State<SliverAppBar> with TickerProviderStateMixin {
FloatingHeaderSnapConfiguration _snapConfiguration;
void _updateSnapConfiguration() {
if (widget.snap && widget.floating) {
_snapConfiguration = new FloatingHeaderSnapConfiguration(
vsync: this,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 200),
);
} else {
_snapConfiguration = null;
}
}
@override
void initState() {
super.initState();
_updateSnapConfiguration();
}
@override
void didUpdateWidget(SliverAppBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.snap != oldWidget.snap || widget.floating != oldWidget.floating)
_updateSnapConfiguration();
}
@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;
final double topPadding = widget.primary ? MediaQuery.of(context).padding.top : 0.0;
final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)
? widget.bottom.bottomHeight + topPadding : null;
return new SliverPersistentHeader(
floating: floating,
pinned: pinned,
floating: widget.floating,
pinned: widget.pinned,
delegate: new _SliverAppBarDelegate(
leading: leading,
title: title,
actions: actions,
flexibleSpace: flexibleSpace,
bottom: bottom,
elevation: elevation,
backgroundColor: backgroundColor,
brightness: brightness,
iconTheme: iconTheme,
textTheme: textTheme,
primary: primary,
centerTitle: centerTitle,
expandedHeight: expandedHeight,
leading: widget.leading,
title: widget.title,
actions: widget.actions,
flexibleSpace: widget.flexibleSpace,
bottom: widget.bottom,
elevation: widget.elevation,
backgroundColor: widget.backgroundColor,
brightness: widget.brightness,
iconTheme: widget.iconTheme,
textTheme: widget.textTheme,
primary: widget.primary,
centerTitle: widget.centerTitle,
expandedHeight: widget.expandedHeight,
collapsedHeight: collapsedHeight,
topPadding: topPadding,
floating: floating,
pinned: pinned,
floating: widget.floating,
pinned: widget.pinned,
snapConfiguration: _snapConfiguration,
),
);
}
......
......@@ -4,8 +4,10 @@
import 'dart:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/scheduler.dart';
import 'package:vector_math/vector_math_64.dart';
import 'binding.dart';
......@@ -251,11 +253,48 @@ abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistent
double childMainAxisPosition(RenderBox child) => 0.0;
}
/// Specifies how a floating header is to be "snapped" (animated) into or out
/// of view.
///
/// See also:
///
/// * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
/// [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
/// start or stop the floating header's animation.
/// * [SliverAppBar], which creates a header that can be pinned, floating,
/// and snapped into view via the corresponding parameters.
class FloatingHeaderSnapConfiguration {
/// Creates an object that specifies how a floating header is to be "snapped"
/// (animated) into or out of view.
FloatingHeaderSnapConfiguration({
@required this.vsync,
this.curve: Curves.ease,
this.duration: const Duration(milliseconds: 300),
}) {
assert(vsync != null);
assert(curve != null);
assert(duration != null);
}
/// The [TickerProvider] for the [AnimationController] that causes a
/// floating header to snap in or out of view.
final TickerProvider vsync;
/// The snap animation curve.
final Curve curve;
/// The snap animation's duration.
final Duration duration;
}
abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersistentHeader {
RenderSliverFloatingPersistentHeader({
RenderBox child,
}) : super(child: child);
FloatingHeaderSnapConfiguration snapConfiguration,
}) : _snapConfiguration = snapConfiguration, super(child: child);
AnimationController _controller;
Animation<double> _animation;
double _lastActualScrollOffset;
double _effectiveScrollOffset;
......@@ -263,6 +302,39 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste
// direction. Negative if we're scrolled off the top.
double _childPosition;
@override
void detach() {
_controller?.dispose();
_controller = null; // lazily recreated if we're reattached.
super.detach();
}
/// Defines the parameters used to snap (animate) the floating header in and
/// out of view.
///
/// If [snapConfiguration] is null then the floating header does not snap.
///
/// See also:
///
/// * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
/// [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
/// start or stop the floating header's animation.
/// * [SliverAppBar], which creates a header that can be pinned, floating,
/// and snapped into view via the corresponding parameters.
FloatingHeaderSnapConfiguration get snapConfiguration => _snapConfiguration;
FloatingHeaderSnapConfiguration _snapConfiguration;
set snapConfiguration(FloatingHeaderSnapConfiguration value) {
if (value == _snapConfiguration)
return;
if (value == null) {
_controller?.dispose();
} else {
if (_snapConfiguration != null && value.vsync != _snapConfiguration.vsync)
_controller?.resync(value.vsync);
}
_snapConfiguration = value;
}
// Update [geometry] and return the new value for [childMainAxisPosition].
@protected
double updateGeometry() {
......@@ -280,6 +352,42 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste
return math.min(0.0, paintExtent - childExtent);
}
/// If the header isn't already fully exposed, then scroll it into view.
void maybeStartSnapAnimation(ScrollDirection direction) {
if (snapConfiguration == null)
return;
if (direction == ScrollDirection.forward && _effectiveScrollOffset <= 0.0)
return;
if (direction == ScrollDirection.reverse && _effectiveScrollOffset >= maxExtent)
return;
final TickerProvider vsync = snapConfiguration.vsync;
final Duration duration = snapConfiguration.duration;
_controller ??= new AnimationController(vsync: vsync, duration: duration)
..addListener(() {
if (_effectiveScrollOffset == _animation.value)
return;
_effectiveScrollOffset = _animation.value;
markNeedsLayout();
});
// Recreating the animation rather than updating a cached value, only
// to avoid the extra complexity of managing the animation's lifetime.
_animation = new Tween<double>(
begin: _effectiveScrollOffset,
end: direction == ScrollDirection.forward ? 0.0 : maxExtent,
).animate(new CurvedAnimation(
parent: _controller,
curve: snapConfiguration.curve,
));
_controller.forward(from: 0.0);
}
/// If a header snap animation is underway then stop it.
void maybeStopSnapAnimation(ScrollDirection direction) {
_controller?.stop();
}
@override
void performLayout() {
......@@ -321,7 +429,8 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste
abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPersistentHeader {
RenderSliverFloatingPinnedPersistentHeader({
RenderBox child,
}) : super(child: child);
FloatingHeaderSnapConfiguration snapConfiguration,
}) : super(child: child, snapConfiguration: snapConfiguration);
@override
double updateGeometry() {
......
......@@ -466,6 +466,13 @@ class ScrollPosition extends ViewportOffset {
ScrollActivity get activity => _activity;
ScrollActivity _activity;
/// This notifier's value is true if a scroll is underway and false if the scroll
/// position is idle.
///
/// Listeners added by stateful widgets should be in the widget's
/// [State.dispose] method.
final ValueNotifier<bool> isScrollingNotifier = new ValueNotifier<bool>(false);
/// Change the current [activity], disposing of the old one and
/// sending scroll notifications as necessary.
///
......@@ -490,6 +497,7 @@ class ScrollPosition extends ViewportOffset {
_activity = newActivity;
if (oldIgnorePointer != shouldIgnorePointer)
state.setIgnorePointer(shouldIgnorePointer);
isScrollingNotifier.value = _activity?.isScrolling ?? false;
if (!activity.isScrolling)
updateUserScrollDirection(ScrollDirection.idle);
if (!wasScrolling && activity.isScrolling)
......
......@@ -65,7 +65,8 @@ class Scrollable extends StatefulWidget {
/// ScrollableState scrollable = Scrollable.of(context);
/// ```
static ScrollableState of(BuildContext context) {
return context.ancestorStateOfType(const TypeMatcher<ScrollableState>());
final _ScrollableScope widget = context.inheritFromWidgetOfExactType(_ScrollableScope);
return widget?.scrollable;
}
/// Scrolls the closest enclosing scrollable to make the given context visible.
......@@ -96,6 +97,28 @@ class Scrollable extends StatefulWidget {
}
}
// Enable Scrollable.of() to work as if ScrollableState was an inherited widget.
// ScrollableState.build() always rebuilds its _ScrollableScope.
class _ScrollableScope extends InheritedWidget {
_ScrollableScope({
Key key,
@required this.scrollable,
@required this.position,
@required Widget child
}) : super(key: key, child: child) {
assert(scrollable != null);
assert(child != null);
}
final ScrollableState scrollable;
final ScrollPosition position;
@override
bool updateShouldNotify(_ScrollableScope old) {
return position != old.position;
}
}
/// State object for a [Scrollable] widget.
///
/// To manipulate a [Scrollable] widget's scroll position, use the object
......@@ -312,7 +335,11 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
child: new IgnorePointer(
key: _ignorePointerKey,
ignoring: _shouldIgnorePointer,
child: widget.viewportBuilder(context, position),
child: new _ScrollableScope(
scrollable: this,
position: position,
child: widget.viewportBuilder(context, position),
),
),
);
return _configuration.buildViewportChrome(context, result, widget.axisDirection);
......
......@@ -19,6 +19,13 @@ abstract class SliverPersistentHeaderDelegate {
double get maxExtent;
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);
/// Specifies how floating headers should animate in and out of view.
///
/// If the value of this property is null, then floating headers will
/// not animate into place.
@protected
FloatingHeaderSnapConfiguration get snapConfiguration => null;
}
class SliverPersistentHeader extends StatelessWidget {
......@@ -224,7 +231,15 @@ class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjec
@override
_RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
return new _RenderSliverFloatingPersistentHeaderForWidgets();
// Not passing this snapConfiguration as a constructor parameter to avoid the
// additional layers added due to https://github.com/dart-lang/sdk/issues/15101
return new _RenderSliverFloatingPersistentHeaderForWidgets()
..snapConfiguration = delegate.snapConfiguration;
}
@override
void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) {
renderObject.snapConfiguration = delegate.snapConfiguration;
}
}
......@@ -241,7 +256,15 @@ class _SliverFloatingPinnedPersistentHeader extends _SliverPersistentHeaderRende
@override
_RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
return new _RenderSliverFloatingPinnedPersistentHeaderForWidgets();
// Not passing this snapConfiguration as a constructor parameter to avoid the
// additional layers added due to https://github.com/dart-lang/sdk/issues/15101
return new _RenderSliverFloatingPinnedPersistentHeaderForWidgets()
..snapConfiguration = delegate.snapConfiguration;
}
@override
void updateRenderObject(BuildContext context, _RenderSliverFloatingPinnedPersistentHeaderForWidgets renderObject) {
renderObject.snapConfiguration = delegate.snapConfiguration;
}
}
......
......@@ -7,7 +7,7 @@ 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 }) {
Widget buildSliverAppBarApp({ bool floating, bool pinned, double expandedHeight, bool snap: false }) {
return new Scaffold(
body: new DefaultTabController(
length: 3,
......@@ -19,6 +19,7 @@ Widget buildSliverAppBarApp({ bool floating, bool pinned, double expandedHeight
floating: floating,
pinned: pinned,
expandedHeight: expandedHeight,
snap: snap,
bottom: new TabBar(
tabs: <String>['A','B','C'].map((String t) => new Tab(text: 'TAB $t')).toList(),
),
......@@ -44,17 +45,11 @@ bool appBarIsVisible(WidgetTester tester) {
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 appBarHeight(WidgetTester tester) => tester.getSize(find.byType(AppBar)).height;
double appBarTop(WidgetTester tester) => tester.getTopLeft(find.byType(AppBar)).y;
double appBarBottom(WidgetTester tester) => tester.getBottomLeft(find.byType(AppBar)).y;
double tabBarHeight(WidgetTester tester) {
final Element element = tester.element(find.byType(TabBar));
final RenderBox box = element.findRenderObject();
return box.size.height;
}
double tabBarHeight(WidgetTester tester) => tester.getSize(find.byType(TabBar)).height;
void main() {
testWidgets('AppBar centers title on iOS', (WidgetTester tester) async {
......@@ -459,7 +454,7 @@ void main() {
final double initialAppBarHeight = 128.0;
final double initialTabBarHeight = tabBarHeight(tester);
// Scroll the not-pinned appbar, collapsing the expanded height. At this
// Scroll the floating-pinned appbar, collapsing the expanded height. At this
// point only the tabBar is visible.
controller.jumpTo(600.0);
await tester.pump();
......@@ -468,11 +463,178 @@ void main() {
expect(appBarHeight(tester), lessThan(initialAppBarHeight));
expect(appBarHeight(tester), initialTabBarHeight);
// Scroll the not-pinned appbar back into view
// Scroll the floating-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, floating with snap:true', (WidgetTester tester) async {
await tester.pumpWidget(buildSliverAppBarApp(
floating: true,
pinned: false,
snap: true,
expandedHeight: 128.0,
));
expect(appBarIsVisible(tester), true);
expect(appBarTop(tester), 0.0);
expect(appBarHeight(tester), 128.0);
expect(appBarBottom(tester), 128.0);
// Scroll to the middle of the list. The (floating) appbar is no longer visible.
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.jumpTo(256.00);
await tester.pumpAndSettle();
expect(appBarIsVisible(tester), false);
expect(appBarTop(tester), lessThanOrEqualTo(-128.0));
// Drag the scrollable up and down. The app bar should not snap open, its
// height should just track the the drag offset.
TestGesture gesture = await tester.startGesture(const Point(50.0, 256.0));
await gesture.moveBy(const Offset(0.0, 128.0)); // drag the appbar all the way open
await tester.pump();
expect(appBarTop(tester), 0.0);
expect(appBarHeight(tester), 128.0);
await gesture.moveBy(const Offset(0.0, -50.0));
await tester.pump();
expect(appBarBottom(tester), 78.0); // 78 == 128 - 50
// Trigger the snap open animation: drag down and release
await gesture.moveBy(const Offset(0.0, 10.0));
await gesture.up();
// Now verify that the appbar is animating open
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
double bottom = appBarBottom(tester);
expect(bottom, greaterThan(88.0)); // 88 = 78 + 10
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(appBarBottom(tester), greaterThan(bottom));
// The animation finishes when the appbar is full height.
await tester.pumpAndSettle();
expect(appBarHeight(tester), 128.0);
// Now that the app bar is open, perform the same drag scenario
// in reverse: drag the appbar up and down and then trigger the
// snap closed animation.
gesture = await tester.startGesture(const Point(50.0, 256.0));
await gesture.moveBy(const Offset(0.0, -128.0)); // drag the appbar closed
await tester.pump();
expect(appBarBottom(tester), 0.0);
await gesture.moveBy(const Offset(0.0, 100.0));
await tester.pump();
expect(appBarBottom(tester), 100.0);
// Trigger the snap close animation: drag upwards and release
await gesture.moveBy(const Offset(0.0, -10.0));
await gesture.up();
// Now verify that the appbar is animating closed
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
bottom = appBarBottom(tester);
expect(bottom, lessThan(90.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(appBarBottom(tester), lessThan(bottom));
// The animation finishes when the appbar is off screen.
await tester.pumpAndSettle();
expect(appBarTop(tester), lessThanOrEqualTo(0.0));
expect(appBarBottom(tester), lessThanOrEqualTo(0.0));
});
testWidgets('SliverAppBar expandedHeight, floating and pinned with snap:true', (WidgetTester tester) async {
await tester.pumpWidget(buildSliverAppBarApp(
floating: true,
pinned: true,
snap: true,
expandedHeight: 128.0,
));
expect(appBarIsVisible(tester), true);
expect(appBarTop(tester), 0.0);
expect(appBarHeight(tester), 128.0);
expect(appBarBottom(tester), 128.0);
// Scroll to the middle of the list. The only the tab bar is visible
// because this is a pinned appbar.
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.jumpTo(256.0);
await tester.pumpAndSettle();
expect(appBarIsVisible(tester), true);
expect(appBarTop(tester), 0.0);
expect(appBarHeight(tester), kTextTabBarHeight);
// Drag the scrollable up and down. The app bar should not snap open, the
// bottof of the appbar should just track the drag offset.
TestGesture gesture = await tester.startGesture(const Point(50.0, 200.0));
await gesture.moveBy(const Offset(0.0, 100.0));
await tester.pump();
expect(appBarHeight(tester), 100.0);
await gesture.moveBy(const Offset(0.0, -25.0));
await tester.pump();
expect(appBarHeight(tester), 75.0);
// Trigger the snap animation: drag down and release
await gesture.moveBy(const Offset(0.0, 10.0));
await gesture.up();
// Now verify that the appbar is animating open
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final double height = appBarHeight(tester);
expect(height, greaterThan(85.0));
expect(height, lessThan(128.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(appBarHeight(tester), greaterThan(height));
expect(appBarHeight(tester), lessThan(128.0));
// The animation finishes when the appbar is fully expanded
await tester.pumpAndSettle();
expect(appBarTop(tester), 0.0);
expect(appBarHeight(tester), 128.0);
expect(appBarBottom(tester), 128.0);
// Now that the appbar is fully expanded, Perform the same drag
// scenario in reverse: drag the appbar up and down and then trigger
// the snap closed animation.
gesture = await tester.startGesture(const Point(50.0, 256.0));
await gesture.moveBy(const Offset(0.0, -128.0));
await tester.pump();
expect(appBarBottom(tester), kTextTabBarHeight);
await gesture.moveBy(const Offset(0.0, 100.0));
await tester.pump();
expect(appBarBottom(tester), 100.0);
// Trigger the snap close animation: drag upwards and release
await gesture.moveBy(const Offset(0.0, -10.0));
await gesture.up();
// Now verify that the appbar is animating closed
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final double bottom = appBarBottom(tester);
expect(bottom, lessThan(90.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(appBarBottom(tester), lessThan(bottom));
// The animation finishes when the appbar shrinks back to its pinned height
await tester.pumpAndSettle();
expect(appBarTop(tester), lessThanOrEqualTo(0.0));
expect(appBarBottom(tester), kTextTabBarHeight);
});
}
// Copyright 2016 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_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
class ScrollPositionListener extends StatefulWidget {
ScrollPositionListener({ Key key, this.child, this.log}) : super(key: key);
final Widget child;
final ValueChanged<String> log;
@override
_ScrollPositionListenerState createState() => new _ScrollPositionListenerState();
}
class _ScrollPositionListenerState extends State<ScrollPositionListener> {
ScrollPosition _position;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_position?.removeListener(listener);
_position = Scrollable.of(context)?.position;
_position?.addListener(listener);
widget.log("didChangeDependencies ${_position?.pixels}");
}
@override
void dispose() {
_position?.removeListener(listener);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
void listener() {
widget.log("listener ${_position?.pixels}");
}
}
void main() {
testWidgets('Scrollable.of() dependent rebuilds when Scrollable position changes', (WidgetTester tester) async {
String logValue;
final ScrollController controller = new ScrollController();
// Changing the SingleChildScrollView's physics causes the
// ScrollController's ScrollPosition to be rebuilt.
Widget buildFrame(ScrollPhysics physics) {
return new SingleChildScrollView(
controller: controller,
physics: physics,
child: new ScrollPositionListener(
log: (String s) { logValue = s; },
child: const SizedBox(height: 400.0),
),
);
}
await tester.pumpWidget(buildFrame(null));
expect(logValue, "didChangeDependencies 0.0");
controller.jumpTo(100.0);
expect(logValue, "listener 100.0");
await tester.pumpWidget(buildFrame(const ClampingScrollPhysics()));
expect(logValue, "didChangeDependencies 100.0");
controller.jumpTo(200.0);
expect(logValue, "listener 200.0");
controller.jumpTo(300.0);
expect(logValue, "listener 300.0");
await tester.pumpWidget(buildFrame(const BouncingScrollPhysics()));
expect(logValue, "didChangeDependencies 300.0");
controller.jumpTo(400.0);
expect(logValue, "listener 400.0");
});
}
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