Unverified Commit 25de9419 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Make Scrollables restorable (#63131)

parent a84e3902
......@@ -250,7 +250,7 @@ class RestorationManager extends ChangeNotifier {
}
bool _debugDoingUpdate = false;
bool _postFrameScheduled = false;
bool _serializationScheduled = false;
final Set<RestorationBucket> _bucketsNeedingSerialization = <RestorationBucket>{};
......@@ -270,8 +270,8 @@ class RestorationManager extends ChangeNotifier {
assert(bucket._manager == this);
assert(!_debugDoingUpdate);
_bucketsNeedingSerialization.add(bucket);
if (!_postFrameScheduled) {
_postFrameScheduled = true;
if (!_serializationScheduled) {
_serializationScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((Duration _) => _doSerialization());
}
}
......@@ -295,11 +295,14 @@ class RestorationManager extends ChangeNotifier {
}
void _doSerialization() {
if (!_serializationScheduled) {
return;
}
assert(() {
_debugDoingUpdate = true;
return true;
}());
_postFrameScheduled = false;
_serializationScheduled = false;
for (final RestorationBucket bucket in _bucketsNeedingSerialization) {
bucket.finalize();
......@@ -312,6 +315,32 @@ class RestorationManager extends ChangeNotifier {
return true;
}());
}
/// Called to manually flush the restoration data to the engine.
///
/// A change in restoration data is usually accompanied by scheduling a frame
/// (because the restoration data is modified inside a [State.setState] call,
/// because it is usually something that affects the interface). Restoration
/// data is automatically flushed to the engine at the end of a frame. As a
/// result, it is uncommon to need to call this method directly. However, if
/// restoration data is changed without triggering a frame, this method must
/// be called to ensure that the updated restoration data is sent to the
/// engine in a timely manner. An example of such a use case is the
/// [Scrollable], where the final scroll offset after a scroll activity
/// finishes is determined between frames without scheduling a new frame.
///
/// Calling this method is a no-op if a frame is already scheduled. In that
/// case, the restoration data will be flushed to the engine at the end of
/// that frame. If this method is called and no frame is scheduled, the
/// current restoration data is directly sent to the engine.
void flushData() {
assert(!_debugDoingUpdate);
if (SchedulerBinding.instance!.hasScheduledFrame) {
return;
}
_doSerialization();
assert(!_serializationScheduled);
}
}
/// A [RestorationBucket] holds pieces of the restoration data that a part of
......
......@@ -434,12 +434,14 @@ class _FixedExtentScrollable extends Scrollable {
ScrollPhysics physics,
@required this.itemExtent,
@required ViewportBuilder viewportBuilder,
String restorationId,
}) : super (
key: key,
axisDirection: axisDirection,
controller: controller,
physics: physics,
viewportBuilder: viewportBuilder,
restorationId: restorationId,
);
final double itemExtent;
......@@ -585,6 +587,7 @@ class ListWheelScrollView extends StatefulWidget {
this.onSelectedItemChanged,
this.renderChildrenOutsideViewport = false,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
@required List<Widget> children,
}) : assert(children != null),
assert(diameterRatio != null),
......@@ -625,6 +628,7 @@ class ListWheelScrollView extends StatefulWidget {
this.onSelectedItemChanged,
this.renderChildrenOutsideViewport = false,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
@required this.childDelegate,
}) : assert(childDelegate != null),
assert(diameterRatio != null),
......@@ -713,6 +717,9 @@ class ListWheelScrollView extends StatefulWidget {
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
@override
_ListWheelScrollViewState createState() => _ListWheelScrollViewState();
}
......@@ -765,6 +772,7 @@ class _ListWheelScrollViewState extends State<ListWheelScrollView> {
controller: scrollController,
physics: widget.physics,
itemExtent: widget.itemExtent,
restorationId: widget.restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return ListWheelViewport(
diameterRatio: widget.diameterRatio,
......
......@@ -376,6 +376,7 @@ class NestedScrollView extends StatefulWidget {
this.dragStartBehavior = DragStartBehavior.start,
this.floatHeaderSlivers = false,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
}) : assert(scrollDirection != null),
assert(reverse != null),
assert(headerSliverBuilder != null),
......@@ -457,6 +458,9 @@ class NestedScrollView extends StatefulWidget {
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor
/// [NestedScrollView].
///
......@@ -636,6 +640,7 @@ class NestedScrollViewState extends State<NestedScrollView> {
),
handle: _absorberHandle,
clipBehavior: widget.clipBehavior,
restorationId: widget.restorationId,
);
},
),
......@@ -653,6 +658,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView {
@required this.handle,
@required this.clipBehavior,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
String restorationId,
}) : super(
scrollDirection: scrollDirection,
reverse: reverse,
......@@ -660,6 +666,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView {
controller: controller,
slivers: slivers,
dragStartBehavior: dragStartBehavior,
restorationId: restorationId,
);
final SliverOverlapAbsorberHandle handle;
......
......@@ -390,6 +390,22 @@ class _PagePosition extends ScrollPositionWithSingleContext implements PageMetri
}
}
@override
void saveOffset() {
context.saveOffset(getPageFromPixels(pixels, viewportDimension));
}
@override
void restoreOffset(double offset, {bool initialRestore = false}) {
assert(initialRestore != null);
assert(offset != null);
if (initialRestore) {
_pageToUseOnStartup = offset;
} else {
jumpTo(getPixelsFromPage(offset));
}
}
@override
bool applyViewportDimension(double viewportDimension) {
final double oldViewportDimensions = this.viewportDimension;
......@@ -570,6 +586,7 @@ class PageView extends StatefulWidget {
List<Widget> children = const <Widget>[],
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildListDelegate(children),
......@@ -605,6 +622,7 @@ class PageView extends StatefulWidget {
int itemCount,
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
......@@ -703,6 +721,7 @@ class PageView extends StatefulWidget {
@required this.childrenDelegate,
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(childrenDelegate != null),
assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController,
......@@ -721,6 +740,9 @@ class PageView extends StatefulWidget {
/// will traverse to the next page in the page view.
final bool allowImplicitScrolling;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// The axis along which the page view scrolls.
///
/// Defaults to [Axis.horizontal].
......@@ -824,6 +846,7 @@ class _PageViewState extends State<PageView> {
axisDirection: axisDirection,
controller: widget.controller,
physics: physics,
restorationId: widget.restorationId,
viewportBuilder: (BuildContext context, ViewportOffset position) {
return Viewport(
// TODO(dnfield): we should provide a way to set cacheExtent
......
......@@ -35,6 +35,7 @@ abstract class ScrollContext {
/// 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.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
BuildContext get storageContext;
/// A [TickerProvider] to use when animating the scroll position.
......@@ -61,4 +62,12 @@ abstract class ScrollContext {
/// Set the [SemanticsAction]s that should be expose to the semantics tree.
void setSemanticsActions(Set<SemanticsAction> actions);
/// Called by the [ScrollPosition] whenever scrolling ends to persist the
/// provided scroll `offset` for state restoration purposes.
///
/// The [ScrollContext] may pass the value back to a [ScrollPosition] by
/// calling [ScrollPosition.restoreOffset] at a later point in time or after
/// the application has restarted to restore the scroll offset.
void saveOffset(double offset);
}
......@@ -128,6 +128,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
///
/// * [ScrollController.keepScrollOffset] and [PageController.keepPage], which
/// create scroll positions and initialize this property.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
final bool keepScrollOffset;
/// A label that is used in the [toString] output.
......@@ -358,6 +359,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// The default implementation writes the [pixels] using the nearest
/// [PageStorage] found from the [context]'s [ScrollContext.storageContext]
/// property.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
@protected
void saveScrollOffset() {
PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels);
......@@ -378,6 +380,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
///
/// This method is called from the constructor, so layout has not yet
/// occurred, and the viewport dimensions aren't yet known when it is called.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
@protected
void restoreScrollOffset() {
if (pixels == null) {
......@@ -387,6 +390,42 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
}
}
/// Called by [context] to restore the scroll offset to the provided value.
///
/// The provided value has previously been provided to the [context] by
/// calling [ScrollContext.saveOffset], e.g. from [saveOffset].
///
/// This method may be called right after the scroll position is created
/// before layout has occurred. In that case, `initialRestore` is set to true
/// and the viewport dimensions will not be known yet. If the [context]
/// doesn't have any information to restore the scroll offset this method is
/// not called.
///
/// The method may be called multiple times in the lifecycle of a
/// [ScrollPosition] to restore it to different scroll offsets.
void restoreOffset(double offset, {bool initialRestore = false}) {
assert(initialRestore != null);
assert(offset != null);
if (initialRestore) {
correctPixels(offset);
} else {
jumpTo(offset);
}
}
/// Called whenever scrolling ends, to persist the current scroll offset for
/// state restoration purposes.
///
/// The default implementation stores the current value of [pixels] on the
/// [context] by calling [ScrollContext.saveOffset]. At a later point in time
/// or after the application restarts, the [context] may restore the scroll
/// position to the persisted offset by calling [restoreOffset].
@protected
void saveOffset() {
assert(pixels != null);
context.saveOffset(pixels);
}
/// Returns the overscroll by applying the boundary conditions.
///
/// If the given value is in bounds, returns 0.0. Otherwise, returns the
......@@ -761,6 +800,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// This also saves the scroll offset using [saveScrollOffset].
void didEndScroll() {
activity.dispatchScrollEndNotification(copyWith(), context.notificationContext);
saveOffset();
if (keepScrollOffset)
saveScrollOffset();
}
......
......@@ -91,6 +91,7 @@ abstract class ScrollView extends StatelessWidget {
this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
}) : assert(scrollDirection != null),
assert(reverse != null),
assert(shrinkWrap != null),
......@@ -260,6 +261,9 @@ abstract class ScrollView extends StatelessWidget {
/// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// Returns the [AxisDirection] in which the scroll view scrolls.
///
/// Combines the [scrollDirection] with the [reverse] boolean to obtain the
......@@ -333,6 +337,7 @@ abstract class ScrollView extends StatelessWidget {
controller: scrollController,
physics: physics,
semanticChildCount: semanticChildCount,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers);
},
......@@ -568,6 +573,7 @@ class CustomScrollView extends ScrollView {
this.slivers = const <Widget>[],
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
String restorationId,
}) : super(
key: key,
scrollDirection: scrollDirection,
......@@ -581,6 +587,7 @@ class CustomScrollView extends ScrollView {
cacheExtent: cacheExtent,
semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior,
restorationId: restorationId,
);
/// The slivers to place inside the viewport.
......@@ -615,6 +622,7 @@ abstract class BoxScrollView extends ScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : super(
key: key,
scrollDirection: scrollDirection,
......@@ -627,6 +635,7 @@ abstract class BoxScrollView extends ScrollView {
semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// The amount of space by which to inset the children.
......@@ -1027,6 +1036,7 @@ class ListView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : childrenDelegate = SliverChildListDelegate(
children,
addAutomaticKeepAlives: addAutomaticKeepAlives,
......@@ -1046,6 +1056,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, linear array of widgets that are created on demand.
......@@ -1098,6 +1109,7 @@ class ListView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(itemCount == null || itemCount >= 0),
assert(semanticChildCount == null || semanticChildCount <= itemCount),
childrenDelegate = SliverChildBuilderDelegate(
......@@ -1120,6 +1132,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? itemCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a fixed-length scrollable linear array of list "items" separated
......@@ -1187,6 +1200,7 @@ class ListView extends BoxScrollView {
double cacheExtent,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(itemBuilder != null),
assert(separatorBuilder != null),
assert(itemCount != null && itemCount >= 0),
......@@ -1229,6 +1243,7 @@ class ListView extends BoxScrollView {
semanticChildCount: itemCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, linear array of widgets with a custom child model.
......@@ -1328,6 +1343,7 @@ class ListView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(childrenDelegate != null),
super(
key: key,
......@@ -1342,6 +1358,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// If non-null, forces the children to have the given extent in the scroll
......@@ -1621,6 +1638,7 @@ class GridView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null),
childrenDelegate = SliverChildListDelegate(
children,
......@@ -1641,6 +1659,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, 2D array of widgets that are created on demand.
......@@ -1681,6 +1700,7 @@ class GridView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null),
childrenDelegate = SliverChildBuilderDelegate(
itemBuilder,
......@@ -1702,6 +1722,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? itemCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, 2D array of widgets with both a custom
......@@ -1726,6 +1747,7 @@ class GridView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null),
assert(childrenDelegate != null),
super(
......@@ -1741,6 +1763,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, 2D array of widgets with a fixed number of tiles in
......@@ -1778,6 +1801,7 @@ class GridView extends BoxScrollView {
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: mainAxisSpacing,
......@@ -1803,6 +1827,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// Creates a scrollable, 2D array of widgets with tiles that each have a
......@@ -1835,10 +1860,12 @@ class GridView extends BoxScrollView {
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
double cacheExtent,
List<Widget> children = const <Widget>[],
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : gridDelegate = SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
mainAxisSpacing: mainAxisSpacing,
......@@ -1860,9 +1887,11 @@ class GridView extends BoxScrollView {
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
cacheExtent: cacheExtent,
semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
);
/// A delegate that controls the layout of the children within the [GridView].
......
......@@ -12,6 +12,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'basic.dart';
......@@ -19,6 +20,8 @@ import 'focus_manager.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'notification_listener.dart';
import 'restoration.dart';
import 'restoration_properties.dart';
import 'scroll_configuration.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart';
......@@ -90,6 +93,7 @@ class Scrollable extends StatefulWidget {
this.excludeFromSemantics = false,
this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start,
this.restorationId,
}) : assert(axisDirection != null),
assert(dragStartBehavior != null),
assert(viewportBuilder != null),
......@@ -226,6 +230,22 @@ class Scrollable extends StatefulWidget {
/// {@endtemplate}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.scrollable.restorationId}
/// Restoration ID to save and restore the scroll offset of the scrollable.
///
/// If a restoration id is provided, the scrollable will persist its current
/// scroll offset and restore it during state restoration.
///
/// The scroll offset is persisted in a [RestorationBucket] claimed from
/// the surrounding [RestorationScope] using the provided restoration ID.
///
/// See also:
///
/// * [RestorationManager], which explains how state restoration works in
/// Flutter.
/// {@endtemplate}
final String restorationId;
/// The axis along which the scroll view scrolls.
///
/// Determined by the [axisDirection].
......@@ -239,6 +259,7 @@ class Scrollable extends StatefulWidget {
super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
properties.add(DiagnosticsProperty<ScrollPhysics>('physics', physics));
properties.add(StringProperty('restorationId', restorationId));
}
/// The state from the closest instance of this class that encloses the given context.
......@@ -340,7 +361,7 @@ class _ScrollableScope extends InheritedWidget {
///
/// This class is not intended to be subclassed. To specialize the behavior of a
/// [Scrollable], provide it with a [ScrollPhysics].
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin
implements ScrollContext {
/// The manager for this [Scrollable] widget's viewport position.
///
......@@ -350,6 +371,8 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
ScrollPosition get position => _position;
ScrollPosition _position;
final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset();
@override
AxisDirection get axisDirection => widget.axisDirection;
......@@ -378,10 +401,28 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
controller?.attach(position);
}
@override
void restoreState(RestorationBucket oldBucket) {
registerForRestoration(_persistedScrollOffset, 'offset');
assert(position != null);
if (_persistedScrollOffset.value != null) {
position.restoreOffset(_persistedScrollOffset.value, initialRestore: oldBucket == null);
}
}
@override
void saveOffset(double offset) {
assert(debugIsSerializableForRestoration(offset));
_persistedScrollOffset.value = offset;
// [saveOffset] is called after a scrolling ends and it is usually not
// followed by a frame. Therefore, manually flush restoration data.
ServicesBinding.instance.restorationManager.flushData();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updatePosition();
super.didChangeDependencies();
}
bool _shouldUpdatePosition(Scrollable oldWidget) {
......@@ -414,6 +455,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
void dispose() {
widget.controller?.detach(position);
position.dispose();
_persistedScrollOffset.dispose();
super.dispose();
}
......@@ -663,6 +705,9 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ScrollPosition>('position', position));
}
@override
String get restorationId => widget.restorationId;
}
/// With [_ScrollSemantics] certain child [SemanticsNode]s can be
......@@ -1029,3 +1074,28 @@ class ScrollAction extends Action<ScrollIntent> {
);
}
}
// Not using a RestorableDouble because we want to allow null values and override
// [enabled].
class _RestorableScrollOffset extends RestorableValue<double> {
@override
double createDefaultValue() => null;
@override
void didUpdateValue(double oldValue) {
notifyListeners();
}
@override
double fromPrimitives(Object data) {
return data as double;
}
@override
Object toPrimitives() {
return value;
}
@override
bool get enabled => value != null;
}
......@@ -222,6 +222,7 @@ class SingleChildScrollView extends StatelessWidget {
this.child,
this.dragStartBehavior = DragStartBehavior.start,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
}) : assert(scrollDirection != null),
assert(dragStartBehavior != null),
assert(clipBehavior != null),
......@@ -299,6 +300,9 @@ class SingleChildScrollView extends StatelessWidget {
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
AxisDirection _getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
}
......@@ -317,6 +321,7 @@ class SingleChildScrollView extends StatelessWidget {
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return _SingleChildViewport(
axisDirection: axisDirection,
......
......@@ -7,6 +7,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -197,6 +198,42 @@ void main() {
});
expect(rootBucket, isNull);
});
testWidgets('flushData', (WidgetTester tester) async {
final List<MethodCall> callsToEngine = <MethodCall>[];
final Completer<Map<dynamic, dynamic>> result = Completer<Map<dynamic, dynamic>>();
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) {
callsToEngine.add(call);
return result.future;
});
final RestorationManager manager = RestorationManager();
final Future<RestorationBucket> rootBucketFuture = manager.rootBucket;
RestorationBucket rootBucket;
rootBucketFuture.then((RestorationBucket bucket) {
rootBucket = bucket;
});
result.complete(_createEncodedRestorationData1());
await tester.pump();
expect(rootBucket, isNotNull);
callsToEngine.clear();
// Schedule a frame.
SchedulerBinding.instance.ensureVisualUpdate();
rootBucket.write('foo', 1);
// flushData is no-op because frame is scheduled.
manager.flushData();
expect(callsToEngine, isEmpty);
// Data is flushed at the end of the frame.
await tester.pump();
expect(callsToEngine, hasLength(1));
callsToEngine.clear();
// flushData without frame sends data directly.
rootBucket.write('foo', 2);
manager.flushData();
expect(callsToEngine, hasLength(1));
});
});
test('debugIsSerializableForRestoration', () {
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('CustomScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: CustomScrollView(
restorationId: 'list',
cacheExtent: 0,
slivers: <Widget>[
SliverList(
delegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
],
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView(
restorationId: 'list',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.builder(
restorationId: 'list',
cacheExtent: 0,
itemBuilder: (BuildContext context, int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.separated restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.separated(
restorationId: 'list',
cacheExtent: 0,
itemCount: 50,
separatorBuilder: (BuildContext context, int index) => const SizedBox.shrink(),
itemBuilder: (BuildContext context, int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.custom(
restorationId: 'list',
cacheExtent: 0,
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.builder(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
itemBuilder: (BuildContext context, int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.custom(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.count restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.count(
restorationId: 'grid',
cacheExtent: 0,
crossAxisCount: 1,
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.extent restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.extent(
restorationId: 'grid',
cacheExtent: 0,
maxCrossAxisExtent: 50,
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('SingleChildScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: SingleChildScrollView(
restorationId: 'single',
child: Column(
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
);
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, 0));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, 50));
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(525);
await tester.pump();
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 525);
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0);
await tester.pump();
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, 0));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, 50));
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 525);
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
});
testWidgets('PageView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView(
restorationId: 'pager',
children: List<Widget>.generate(
50,
(int index) => Container(
child: Text('Tile $index'),
),
),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('PageView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView.builder(
restorationId: 'pager',
itemBuilder: (BuildContext context, int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('PageView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView.custom(
restorationId: 'pager',
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('ListWheelScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListWheelScrollView(
restorationId: 'wheel',
itemExtent: 50,
children: List<Widget>.generate(
50,
(int index) => Container(
child: Text('Tile $index'),
),
),
),
),
);
await restoreScrollAndVerify(tester, secondOffset: 542);
});
testWidgets('ListWheelScrollView.useDelegate restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListWheelScrollView.useDelegate(
restorationId: 'wheel',
itemExtent: 50,
childDelegate: ListWheelChildListDelegate(
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
);
await restoreScrollAndVerify(tester, secondOffset: 542);
});
testWidgets('NestedScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: TestHarness(
height: 200,
child: NestedScrollView(
restorationId: 'outer',
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: const Text('Books'),
pinned: true,
expandedHeight: 150.0,
forceElevated: innerBoxIsScrolled,
),
),
];
},
body: ListView(
restorationId: 'inner',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
),
),
);
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry.paintExtent, 150);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pump();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
await tester.drag(find.byType(ListView), const Offset(0, 600));
await tester.pump();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry.paintExtent, 150);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.restoreFrom(data);
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
});
testWidgets('RestorationData is flushed even if no frame is scheduled', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView(
restorationId: 'list',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => Container(
height: 50,
child: Text('Tile $index'),
),
),
),
),
);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
final TestRestorationData initialData = await tester.getRestorationData();
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)));
await gesture.moveBy(const Offset(0, -525));
await tester.pump();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
// Restoration data hasn't changed and no frame is scheduled.
expect(await tester.getRestorationData(), initialData);
expect(tester.binding.hasScheduledFrame, isFalse);
// Restoration data changes with up event.
await gesture.up();
expect(await tester.getRestorationData(), isNot(initialData));
});
}
Future<void> pageViewScrollAndRestore(WidgetTester tester) async {
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(50.0 * 10);
await tester.pumpAndSettle();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 50.0 * 10);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0);
await tester.pump();
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 50.0 * 10);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
}
Future<void> restoreScrollAndVerify(WidgetTester tester, {double secondOffset = 525}) async {
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is Scrollable);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
tester.state<ScrollableState>(findScrollable).position.jumpTo(secondOffset);
await tester.pump();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(findScrollable).position.pixels, secondOffset);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(findScrollable).position.jumpTo(0);
await tester.pump();
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(findScrollable).position.pixels, secondOffset);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
}
class TestHarness extends StatelessWidget {
const TestHarness({Key key, this.child, this.height = 100}) : super(key: key);
final Widget child;
final double height;
@override
Widget build(BuildContext context) {
return RootRestorationScope(
restorationId: 'root',
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
height: height,
width: 50,
child: child,
),
),
),
);
}
}
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