Commit c8ad2014 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Save scroll position when scrolling. (#9740)

parent 89044a0e
......@@ -12,6 +12,7 @@ import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'framework.dart';
import 'notification_listener.dart';
import 'page_storage.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart';
import 'scroll_metrics.dart';
......@@ -187,6 +188,20 @@ class _PagePosition extends ScrollPositionWithSingleContext {
double get page => pixels == null ? null : getPageFromPixels(pixels.clamp(minScrollExtent, maxScrollExtent), viewportDimension);
@override
void saveScrollOffset() {
PageStorage.of(context.storageContext)?.writeState(context.storageContext, getPageFromPixels(pixels, viewportDimension));
}
@override
void restoreScrollOffset() {
if (pixels == null) {
final double value = PageStorage.of(context.storageContext)?.readState(context.storageContext);
if (value != null)
correctPixels(getPixelsFromPage(value));
}
}
@override
bool applyViewportDimension(double viewportDimension) {
final double oldViewportDimensions = this.viewportDimension;
......
......@@ -27,6 +27,14 @@ abstract class ScrollContext {
/// [ScrollBehavior.buildViewportChrome].
BuildContext get notificationContext;
/// The [BuildContext] that should be used when searching for a [PageStorage].
///
/// This context is typically the context of the scrollable widget itself. In
/// particular, it should involve any [GlobalKey]s that are dynamically
/// created as part of creating the scrolling widget, since those would be
/// different each time the widget is created.
BuildContext get storageContext;
/// A [TickerProvider] to use when animating the scroll position.
TickerProvider get vsync;
......
......@@ -180,7 +180,24 @@ class ScrollController extends ChangeNotifier {
/// resizes.
///
/// By default, returns a [ScrollPositionWithSingleContext].
ScrollPosition createScrollPosition(
///
/// The arguments are generally passed to the [ScrollPosition] being created:
///
/// * `physics`: An instance of [ScrollPhysics] that determines how the
/// [ScrollPosition] should react to user interactions, how it should
/// simulate scrolling when released or flung, etc. The value will not be
/// null. It typically comes from the [ScrollView] or other widget that
/// creates the [Scrollable], or, if none was provided, from the ambient
/// [ScrollConfiguration].
/// * `context`: A [ScrollContext] used for communicating with the object
/// that is to own the [ScrollPosition] (typically, this is the
/// [Scrollable] itself).
/// * `oldPosition`: If this is not the first time a [ScrollPosition] has
/// been created for this [Scrollable], this will be the previous instance.
/// This is used when the environment has changed and the [Scrollable]
/// needs to recreate the [ScrollPosition] object. It is null the first
/// time the [ScrollPosition] is created.
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition,
......
......@@ -12,6 +12,7 @@ import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'page_storage.dart';
import 'scroll_activity.dart';
import 'scroll_context.dart';
import 'scroll_metrics.dart';
......@@ -71,6 +72,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
assert(context.vsync != null);
if (oldPosition != null)
absorb(oldPosition);
restoreScrollOffset();
}
/// How the scroll position should respond to user input.
......@@ -259,15 +261,54 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// To cause the position to jump or animate to a new value, consider [jumpTo]
/// or [animateTo].
///
/// This should not be called during layout. Consider [correctPixels] if you
/// find you need to adjust the position during layout.
/// This should not be called during layout (e.g. when setting the initial
/// scroll offset). Consider [correctPixels] if you find you need to adjust
/// the position during layout.
@protected
void forcePixels(double value) {
assert(_pixels != null);
assert(pixels != null);
_pixels = value;
notifyListeners();
}
/// Called whenever scrolling ends, to store the current scroll offset in a
/// storage mechanism with a lifetime that matches the app's lifetime.
///
/// The stored value will be used by [restoreScrollOffset] when the
/// [ScrollPosition] is recreated, in the case of the [Scrollable] being
/// disposed then recreated in the same session. This might happen, for
/// instance, if a [ListView] is on one of the pages inside a [TabBarView],
/// and that page is displayed, then hidden, then displayed again.
///
/// The default implementation writes the [pixels] using the nearest
/// [PageStorage] found from the [context]'s [ScrollContext.storageContext]
/// property.
@protected
void saveScrollOffset() {
PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels);
}
/// Called whenever the [ScrollPosition] is created, to restore the scroll
/// offset if possible.
///
/// The value is stored by [saveScrollOffset] when the scroll position
/// changes, so that it can be restored in the case of the [Scrollable] being
/// disposed then recreated in the same session. This might happen, for
/// instance, if a [ListView] is on one of the pages inside a [TabBarView],
/// and that page is displayed, then hidden, then displayed again.
///
/// The default implementation reads the value from the nearest [PageStorage]
/// found from the [context]'s [ScrollContext.storageContext] property, and
/// sets it using [correctPixels], if [pixels] is still null.
@protected
void restoreScrollOffset() {
if (pixels == null) {
final double value = PageStorage.of(context.storageContext)?.readState(context.storageContext);
if (value != null)
correctPixels(value);
}
}
/// Returns the overscroll by applying the boundary conditions.
///
/// If the given value is in bounds, returns 0.0. Otherwise, returns the
......@@ -467,7 +508,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
oldIgnorePointer = _activity.shouldIgnorePointer;
wasScrolling = _activity.isScrolling;
if (wasScrolling && !newActivity.isScrolling)
didEndScroll();
didEndScroll(); // notifies and then saves the scroll offset
_activity.dispose();
} else {
oldIgnorePointer = false;
......@@ -495,8 +536,11 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
}
/// Called by [beginActivity] to report when an activity has ended.
///
/// This also saves the scroll offset using [saveScrollOffset].
void didEndScroll() {
activity.dispatchScrollEndNotification(cloneMetrics(), context.notificationContext);
saveScrollOffset();
}
/// Called by [setPixels] to report overscroll when an attempt is made to
......
......@@ -358,6 +358,9 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
@override
BuildContext get notificationContext => _gestureDetectorKey.currentContext;
@override
BuildContext get storageContext => context;
// TOUCH HANDLERS
Drag _drag;
......
......@@ -8,6 +8,10 @@ import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
ScrollController _controller = new ScrollController(
initialScrollOffset: 110.0,
);
class ThePositiveNumbers extends StatelessWidget {
const ThePositiveNumbers({ @required this.from });
final int from;
......@@ -15,6 +19,7 @@ class ThePositiveNumbers extends StatelessWidget {
Widget build(BuildContext context) {
return new ListView.builder(
itemExtent: 100.0,
controller: _controller,
itemBuilder: (BuildContext context, int index) {
return new Text('${index + from}', key: new ValueKey<int>(index));
}
......@@ -22,98 +27,112 @@ class ThePositiveNumbers extends StatelessWidget {
}
}
Future<Null> performTest(WidgetTester tester) async {
Future<Null> performTest(WidgetTester tester, bool maintainState) async {
final GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();
await tester.pumpWidget(new Navigator(
key: navigatorKey,
onGenerateRoute: (RouteSettings settings) {
if (settings.name == '/') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: const ThePositiveNumbers(from: 0)),
maintainState: maintainState,
);
} else if (settings.name == '/second') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: const ThePositiveNumbers(from: 10000)),
maintainState: maintainState,
);
}
return null;
}
));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// 110.0, so we should have 7 items, 1..7.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.text('6'), findsOneWidget);
expect(find.text('7'), findsOneWidget);
expect(find.text('8'), findsNothing);
expect(find.text('10'), findsNothing);
expect(find.text('100'), findsNothing);
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(1000.0);
await tester.pump(const Duration(seconds: 1));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsNothing);
expect(find.text('8'), findsNothing);
expect(find.text('9'), findsNothing);
expect(find.text('10'), findsOneWidget);
expect(find.text('11'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('13'), findsOneWidget);
expect(find.text('14'), findsOneWidget);
expect(find.text('15'), findsOneWidget);
expect(find.text('16'), findsNothing);
expect(find.text('100'), findsNothing);
navigatorKey.currentState.pushNamed('/second');
await tester.pump(); // navigating always takes two frames, one to start...
await tester.pump(const Duration(seconds: 1)); // ...and one to end the transition
// the second list is now visible, starting at 10001
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsNothing);
expect(find.text('10'), findsNothing);
expect(find.text('11'), findsNothing);
expect(find.text('10000'), findsNothing);
expect(find.text('10001'), findsOneWidget);
expect(find.text('10002'), findsOneWidget);
expect(find.text('10003'), findsOneWidget);
expect(find.text('10004'), findsOneWidget);
expect(find.text('10005'), findsOneWidget);
expect(find.text('10006'), findsOneWidget);
expect(find.text('10007'), findsOneWidget);
expect(find.text('10008'), findsNothing);
expect(find.text('10010'), findsNothing);
expect(find.text('10100'), findsNothing);
navigatorKey.currentState.pop();
await tester.pump(); // again, navigating always takes two frames
// Ensure we don't clamp the scroll offset even during the navigation.
// https://github.com/flutter/flutter/issues/4883
final ScrollableState state = tester.state(find.byType(Scrollable).first);
expect(state.position.pixels, equals(1000.0));
await tester.pump(const Duration(seconds: 1));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsNothing);
expect(find.text('8'), findsNothing);
expect(find.text('9'), findsNothing);
expect(find.text('10'), findsOneWidget);
expect(find.text('11'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('13'), findsOneWidget);
expect(find.text('14'), findsOneWidget);
expect(find.text('15'), findsOneWidget);
expect(find.text('16'), findsNothing);
expect(find.text('100'), findsNothing);
}
void main() {
testWidgets('whether we remember our scroll position', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();
await tester.pumpWidget(new Navigator(
key: navigatorKey,
onGenerateRoute: (RouteSettings settings) {
if (settings.name == '/') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: const ThePositiveNumbers(from: 0)),
);
} else if (settings.name == '/second') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: const ThePositiveNumbers(from: 10000)),
);
}
return null;
}
));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// zero, so we should have exactly 6 items, 0..5.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.text('6'), findsNothing);
expect(find.text('10'), findsNothing);
expect(find.text('100'), findsNothing);
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(1000.0);
await tester.pump(const Duration(seconds: 1));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing);
expect(find.text('8'), findsNothing);
expect(find.text('9'), findsNothing);
expect(find.text('10'), findsOneWidget);
expect(find.text('11'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('13'), findsOneWidget);
expect(find.text('14'), findsOneWidget);
expect(find.text('15'), findsOneWidget);
expect(find.text('16'), findsNothing);
expect(find.text('100'), findsNothing);
navigatorKey.currentState.pushNamed('/second');
await tester.pump(); // navigating always takes two frames, one to start...
await tester.pump(const Duration(seconds: 1)); // ...and one to end the transition
// the second list is now visible, starting at 10000
expect(find.text('10000'), findsOneWidget);
expect(find.text('10001'), findsOneWidget);
expect(find.text('10002'), findsOneWidget);
expect(find.text('10003'), findsOneWidget);
expect(find.text('10004'), findsOneWidget);
expect(find.text('10005'), findsOneWidget);
expect(find.text('10006'), findsNothing);
expect(find.text('10010'), findsNothing);
expect(find.text('10100'), findsNothing);
navigatorKey.currentState.pop();
await tester.pump(); // again, navigating always takes two frames
// Ensure we don't clamp the scroll offset even during the navigation.
// https://github.com/flutter/flutter/issues/4883
final ScrollableState state = tester.state(find.byType(Scrollable).first);
expect(state.position.pixels, equals(1000.0));
await tester.pump(const Duration(seconds: 1));
// we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing);
expect(find.text('8'), findsNothing);
expect(find.text('9'), findsNothing);
expect(find.text('10'), findsOneWidget);
expect(find.text('11'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('13'), findsOneWidget);
expect(find.text('14'), findsOneWidget);
expect(find.text('15'), findsOneWidget);
expect(find.text('16'), findsNothing);
expect(find.text('100'), findsNothing);
await performTest(tester, true);
await performTest(tester, false);
});
}
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