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 { ...@@ -250,7 +250,7 @@ class RestorationManager extends ChangeNotifier {
} }
bool _debugDoingUpdate = false; bool _debugDoingUpdate = false;
bool _postFrameScheduled = false; bool _serializationScheduled = false;
final Set<RestorationBucket> _bucketsNeedingSerialization = <RestorationBucket>{}; final Set<RestorationBucket> _bucketsNeedingSerialization = <RestorationBucket>{};
...@@ -270,8 +270,8 @@ class RestorationManager extends ChangeNotifier { ...@@ -270,8 +270,8 @@ class RestorationManager extends ChangeNotifier {
assert(bucket._manager == this); assert(bucket._manager == this);
assert(!_debugDoingUpdate); assert(!_debugDoingUpdate);
_bucketsNeedingSerialization.add(bucket); _bucketsNeedingSerialization.add(bucket);
if (!_postFrameScheduled) { if (!_serializationScheduled) {
_postFrameScheduled = true; _serializationScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((Duration _) => _doSerialization()); SchedulerBinding.instance!.addPostFrameCallback((Duration _) => _doSerialization());
} }
} }
...@@ -295,11 +295,14 @@ class RestorationManager extends ChangeNotifier { ...@@ -295,11 +295,14 @@ class RestorationManager extends ChangeNotifier {
} }
void _doSerialization() { void _doSerialization() {
if (!_serializationScheduled) {
return;
}
assert(() { assert(() {
_debugDoingUpdate = true; _debugDoingUpdate = true;
return true; return true;
}()); }());
_postFrameScheduled = false; _serializationScheduled = false;
for (final RestorationBucket bucket in _bucketsNeedingSerialization) { for (final RestorationBucket bucket in _bucketsNeedingSerialization) {
bucket.finalize(); bucket.finalize();
...@@ -312,6 +315,32 @@ class RestorationManager extends ChangeNotifier { ...@@ -312,6 +315,32 @@ class RestorationManager extends ChangeNotifier {
return true; 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 /// A [RestorationBucket] holds pieces of the restoration data that a part of
......
...@@ -434,12 +434,14 @@ class _FixedExtentScrollable extends Scrollable { ...@@ -434,12 +434,14 @@ class _FixedExtentScrollable extends Scrollable {
ScrollPhysics physics, ScrollPhysics physics,
@required this.itemExtent, @required this.itemExtent,
@required ViewportBuilder viewportBuilder, @required ViewportBuilder viewportBuilder,
String restorationId,
}) : super ( }) : super (
key: key, key: key,
axisDirection: axisDirection, axisDirection: axisDirection,
controller: controller, controller: controller,
physics: physics, physics: physics,
viewportBuilder: viewportBuilder, viewportBuilder: viewportBuilder,
restorationId: restorationId,
); );
final double itemExtent; final double itemExtent;
...@@ -585,6 +587,7 @@ class ListWheelScrollView extends StatefulWidget { ...@@ -585,6 +587,7 @@ class ListWheelScrollView extends StatefulWidget {
this.onSelectedItemChanged, this.onSelectedItemChanged,
this.renderChildrenOutsideViewport = false, this.renderChildrenOutsideViewport = false,
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId,
@required List<Widget> children, @required List<Widget> children,
}) : assert(children != null), }) : assert(children != null),
assert(diameterRatio != null), assert(diameterRatio != null),
...@@ -625,6 +628,7 @@ class ListWheelScrollView extends StatefulWidget { ...@@ -625,6 +628,7 @@ class ListWheelScrollView extends StatefulWidget {
this.onSelectedItemChanged, this.onSelectedItemChanged,
this.renderChildrenOutsideViewport = false, this.renderChildrenOutsideViewport = false,
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId,
@required this.childDelegate, @required this.childDelegate,
}) : assert(childDelegate != null), }) : assert(childDelegate != null),
assert(diameterRatio != null), assert(diameterRatio != null),
...@@ -713,6 +717,9 @@ class ListWheelScrollView extends StatefulWidget { ...@@ -713,6 +717,9 @@ class ListWheelScrollView extends StatefulWidget {
/// Defaults to [Clip.hardEdge]. /// Defaults to [Clip.hardEdge].
final Clip clipBehavior; final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
@override @override
_ListWheelScrollViewState createState() => _ListWheelScrollViewState(); _ListWheelScrollViewState createState() => _ListWheelScrollViewState();
} }
...@@ -765,6 +772,7 @@ class _ListWheelScrollViewState extends State<ListWheelScrollView> { ...@@ -765,6 +772,7 @@ class _ListWheelScrollViewState extends State<ListWheelScrollView> {
controller: scrollController, controller: scrollController,
physics: widget.physics, physics: widget.physics,
itemExtent: widget.itemExtent, itemExtent: widget.itemExtent,
restorationId: widget.restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) { viewportBuilder: (BuildContext context, ViewportOffset offset) {
return ListWheelViewport( return ListWheelViewport(
diameterRatio: widget.diameterRatio, diameterRatio: widget.diameterRatio,
......
...@@ -376,6 +376,7 @@ class NestedScrollView extends StatefulWidget { ...@@ -376,6 +376,7 @@ class NestedScrollView extends StatefulWidget {
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.floatHeaderSlivers = false, this.floatHeaderSlivers = false,
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId,
}) : assert(scrollDirection != null), }) : assert(scrollDirection != null),
assert(reverse != null), assert(reverse != null),
assert(headerSliverBuilder != null), assert(headerSliverBuilder != null),
...@@ -457,6 +458,9 @@ class NestedScrollView extends StatefulWidget { ...@@ -457,6 +458,9 @@ class NestedScrollView extends StatefulWidget {
/// Defaults to [Clip.hardEdge]. /// Defaults to [Clip.hardEdge].
final Clip clipBehavior; final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor /// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor
/// [NestedScrollView]. /// [NestedScrollView].
/// ///
...@@ -636,6 +640,7 @@ class NestedScrollViewState extends State<NestedScrollView> { ...@@ -636,6 +640,7 @@ class NestedScrollViewState extends State<NestedScrollView> {
), ),
handle: _absorberHandle, handle: _absorberHandle,
clipBehavior: widget.clipBehavior, clipBehavior: widget.clipBehavior,
restorationId: widget.restorationId,
); );
}, },
), ),
...@@ -653,6 +658,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView { ...@@ -653,6 +658,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView {
@required this.handle, @required this.handle,
@required this.clipBehavior, @required this.clipBehavior,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
String restorationId,
}) : super( }) : super(
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
reverse: reverse, reverse: reverse,
...@@ -660,6 +666,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView { ...@@ -660,6 +666,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView {
controller: controller, controller: controller,
slivers: slivers, slivers: slivers,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
restorationId: restorationId,
); );
final SliverOverlapAbsorberHandle handle; final SliverOverlapAbsorberHandle handle;
......
...@@ -390,6 +390,22 @@ class _PagePosition extends ScrollPositionWithSingleContext implements PageMetri ...@@ -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 @override
bool applyViewportDimension(double viewportDimension) { bool applyViewportDimension(double viewportDimension) {
final double oldViewportDimensions = this.viewportDimension; final double oldViewportDimensions = this.viewportDimension;
...@@ -570,6 +586,7 @@ class PageView extends StatefulWidget { ...@@ -570,6 +586,7 @@ class PageView extends StatefulWidget {
List<Widget> children = const <Widget>[], List<Widget> children = const <Widget>[],
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false, this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(allowImplicitScrolling != null), }) : assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController, controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildListDelegate(children), childrenDelegate = SliverChildListDelegate(children),
...@@ -605,6 +622,7 @@ class PageView extends StatefulWidget { ...@@ -605,6 +622,7 @@ class PageView extends StatefulWidget {
int itemCount, int itemCount,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false, this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(allowImplicitScrolling != null), }) : assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController, controller = controller ?? _defaultPageController,
childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount), childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
...@@ -703,6 +721,7 @@ class PageView extends StatefulWidget { ...@@ -703,6 +721,7 @@ class PageView extends StatefulWidget {
@required this.childrenDelegate, @required this.childrenDelegate,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false, this.allowImplicitScrolling = false,
this.restorationId,
}) : assert(childrenDelegate != null), }) : assert(childrenDelegate != null),
assert(allowImplicitScrolling != null), assert(allowImplicitScrolling != null),
controller = controller ?? _defaultPageController, controller = controller ?? _defaultPageController,
...@@ -721,6 +740,9 @@ class PageView extends StatefulWidget { ...@@ -721,6 +740,9 @@ class PageView extends StatefulWidget {
/// will traverse to the next page in the page view. /// will traverse to the next page in the page view.
final bool allowImplicitScrolling; final bool allowImplicitScrolling;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// The axis along which the page view scrolls. /// The axis along which the page view scrolls.
/// ///
/// Defaults to [Axis.horizontal]. /// Defaults to [Axis.horizontal].
...@@ -824,6 +846,7 @@ class _PageViewState extends State<PageView> { ...@@ -824,6 +846,7 @@ class _PageViewState extends State<PageView> {
axisDirection: axisDirection, axisDirection: axisDirection,
controller: widget.controller, controller: widget.controller,
physics: physics, physics: physics,
restorationId: widget.restorationId,
viewportBuilder: (BuildContext context, ViewportOffset position) { viewportBuilder: (BuildContext context, ViewportOffset position) {
return Viewport( return Viewport(
// TODO(dnfield): we should provide a way to set cacheExtent // TODO(dnfield): we should provide a way to set cacheExtent
......
...@@ -35,6 +35,7 @@ abstract class ScrollContext { ...@@ -35,6 +35,7 @@ abstract class ScrollContext {
/// particular, it should involve any [GlobalKey]s that are dynamically /// particular, it should involve any [GlobalKey]s that are dynamically
/// created as part of creating the scrolling widget, since those would be /// created as part of creating the scrolling widget, since those would be
/// different each time the widget is created. /// different each time the widget is created.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
BuildContext get storageContext; BuildContext get storageContext;
/// A [TickerProvider] to use when animating the scroll position. /// A [TickerProvider] to use when animating the scroll position.
...@@ -61,4 +62,12 @@ abstract class ScrollContext { ...@@ -61,4 +62,12 @@ abstract class ScrollContext {
/// Set the [SemanticsAction]s that should be expose to the semantics tree. /// Set the [SemanticsAction]s that should be expose to the semantics tree.
void setSemanticsActions(Set<SemanticsAction> actions); 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 { ...@@ -128,6 +128,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// ///
/// * [ScrollController.keepScrollOffset] and [PageController.keepPage], which /// * [ScrollController.keepScrollOffset] and [PageController.keepPage], which
/// create scroll positions and initialize this property. /// create scroll positions and initialize this property.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
final bool keepScrollOffset; final bool keepScrollOffset;
/// A label that is used in the [toString] output. /// A label that is used in the [toString] output.
...@@ -358,6 +359,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -358,6 +359,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// The default implementation writes the [pixels] using the nearest /// The default implementation writes the [pixels] using the nearest
/// [PageStorage] found from the [context]'s [ScrollContext.storageContext] /// [PageStorage] found from the [context]'s [ScrollContext.storageContext]
/// property. /// property.
// TODO(goderbauer): Deprecate this when state restoration supports all features of PageStorage.
@protected @protected
void saveScrollOffset() { void saveScrollOffset() {
PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels); PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels);
...@@ -378,6 +380,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -378,6 +380,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// ///
/// This method is called from the constructor, so layout has not yet /// 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. /// 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 @protected
void restoreScrollOffset() { void restoreScrollOffset() {
if (pixels == null) { if (pixels == null) {
...@@ -387,6 +390,42 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -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. /// Returns the overscroll by applying the boundary conditions.
/// ///
/// If the given value is in bounds, returns 0.0. Otherwise, returns the /// If the given value is in bounds, returns 0.0. Otherwise, returns the
...@@ -761,6 +800,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -761,6 +800,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// This also saves the scroll offset using [saveScrollOffset]. /// This also saves the scroll offset using [saveScrollOffset].
void didEndScroll() { void didEndScroll() {
activity.dispatchScrollEndNotification(copyWith(), context.notificationContext); activity.dispatchScrollEndNotification(copyWith(), context.notificationContext);
saveOffset();
if (keepScrollOffset) if (keepScrollOffset)
saveScrollOffset(); saveScrollOffset();
} }
......
...@@ -91,6 +91,7 @@ abstract class ScrollView extends StatelessWidget { ...@@ -91,6 +91,7 @@ abstract class ScrollView extends StatelessWidget {
this.semanticChildCount, this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
}) : assert(scrollDirection != null), }) : assert(scrollDirection != null),
assert(reverse != null), assert(reverse != null),
assert(shrinkWrap != null), assert(shrinkWrap != null),
...@@ -260,6 +261,9 @@ abstract class ScrollView extends StatelessWidget { ...@@ -260,6 +261,9 @@ abstract class ScrollView extends StatelessWidget {
/// dismiss the keyboard automatically. /// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
/// Returns the [AxisDirection] in which the scroll view scrolls. /// Returns the [AxisDirection] in which the scroll view scrolls.
/// ///
/// Combines the [scrollDirection] with the [reverse] boolean to obtain the /// Combines the [scrollDirection] with the [reverse] boolean to obtain the
...@@ -333,6 +337,7 @@ abstract class ScrollView extends StatelessWidget { ...@@ -333,6 +337,7 @@ abstract class ScrollView extends StatelessWidget {
controller: scrollController, controller: scrollController,
physics: physics, physics: physics,
semanticChildCount: semanticChildCount, semanticChildCount: semanticChildCount,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) { viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers); return buildViewport(context, offset, axisDirection, slivers);
}, },
...@@ -568,6 +573,7 @@ class CustomScrollView extends ScrollView { ...@@ -568,6 +573,7 @@ class CustomScrollView extends ScrollView {
this.slivers = const <Widget>[], this.slivers = const <Widget>[],
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
String restorationId,
}) : super( }) : super(
key: key, key: key,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
...@@ -581,6 +587,7 @@ class CustomScrollView extends ScrollView { ...@@ -581,6 +587,7 @@ class CustomScrollView extends ScrollView {
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
semanticChildCount: semanticChildCount, semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
restorationId: restorationId,
); );
/// The slivers to place inside the viewport. /// The slivers to place inside the viewport.
...@@ -615,6 +622,7 @@ abstract class BoxScrollView extends ScrollView { ...@@ -615,6 +622,7 @@ abstract class BoxScrollView extends ScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : super( }) : super(
key: key, key: key,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
...@@ -627,6 +635,7 @@ abstract class BoxScrollView extends ScrollView { ...@@ -627,6 +635,7 @@ abstract class BoxScrollView extends ScrollView {
semanticChildCount: semanticChildCount, semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// The amount of space by which to inset the children. /// The amount of space by which to inset the children.
...@@ -1027,6 +1036,7 @@ class ListView extends BoxScrollView { ...@@ -1027,6 +1036,7 @@ class ListView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : childrenDelegate = SliverChildListDelegate( }) : childrenDelegate = SliverChildListDelegate(
children, children,
addAutomaticKeepAlives: addAutomaticKeepAlives, addAutomaticKeepAlives: addAutomaticKeepAlives,
...@@ -1046,6 +1056,7 @@ class ListView extends BoxScrollView { ...@@ -1046,6 +1056,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length, semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, linear array of widgets that are created on demand. /// Creates a scrollable, linear array of widgets that are created on demand.
...@@ -1098,6 +1109,7 @@ class ListView extends BoxScrollView { ...@@ -1098,6 +1109,7 @@ class ListView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(itemCount == null || itemCount >= 0), }) : assert(itemCount == null || itemCount >= 0),
assert(semanticChildCount == null || semanticChildCount <= itemCount), assert(semanticChildCount == null || semanticChildCount <= itemCount),
childrenDelegate = SliverChildBuilderDelegate( childrenDelegate = SliverChildBuilderDelegate(
...@@ -1120,6 +1132,7 @@ class ListView extends BoxScrollView { ...@@ -1120,6 +1132,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? itemCount, semanticChildCount: semanticChildCount ?? itemCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a fixed-length scrollable linear array of list "items" separated /// Creates a fixed-length scrollable linear array of list "items" separated
...@@ -1187,6 +1200,7 @@ class ListView extends BoxScrollView { ...@@ -1187,6 +1200,7 @@ class ListView extends BoxScrollView {
double cacheExtent, double cacheExtent,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(itemBuilder != null), }) : assert(itemBuilder != null),
assert(separatorBuilder != null), assert(separatorBuilder != null),
assert(itemCount != null && itemCount >= 0), assert(itemCount != null && itemCount >= 0),
...@@ -1229,6 +1243,7 @@ class ListView extends BoxScrollView { ...@@ -1229,6 +1243,7 @@ class ListView extends BoxScrollView {
semanticChildCount: itemCount, semanticChildCount: itemCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, linear array of widgets with a custom child model. /// Creates a scrollable, linear array of widgets with a custom child model.
...@@ -1328,6 +1343,7 @@ class ListView extends BoxScrollView { ...@@ -1328,6 +1343,7 @@ class ListView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(childrenDelegate != null), }) : assert(childrenDelegate != null),
super( super(
key: key, key: key,
...@@ -1342,6 +1358,7 @@ class ListView extends BoxScrollView { ...@@ -1342,6 +1358,7 @@ class ListView extends BoxScrollView {
semanticChildCount: semanticChildCount, semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// If non-null, forces the children to have the given extent in the scroll /// If non-null, forces the children to have the given extent in the scroll
...@@ -1621,6 +1638,7 @@ class GridView extends BoxScrollView { ...@@ -1621,6 +1638,7 @@ class GridView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null), }) : assert(gridDelegate != null),
childrenDelegate = SliverChildListDelegate( childrenDelegate = SliverChildListDelegate(
children, children,
...@@ -1641,6 +1659,7 @@ class GridView extends BoxScrollView { ...@@ -1641,6 +1659,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length, semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, 2D array of widgets that are created on demand. /// Creates a scrollable, 2D array of widgets that are created on demand.
...@@ -1681,6 +1700,7 @@ class GridView extends BoxScrollView { ...@@ -1681,6 +1700,7 @@ class GridView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null), }) : assert(gridDelegate != null),
childrenDelegate = SliverChildBuilderDelegate( childrenDelegate = SliverChildBuilderDelegate(
itemBuilder, itemBuilder,
...@@ -1702,6 +1722,7 @@ class GridView extends BoxScrollView { ...@@ -1702,6 +1722,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? itemCount, semanticChildCount: semanticChildCount ?? itemCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, 2D array of widgets with both a custom /// Creates a scrollable, 2D array of widgets with both a custom
...@@ -1726,6 +1747,7 @@ class GridView extends BoxScrollView { ...@@ -1726,6 +1747,7 @@ class GridView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : assert(gridDelegate != null), }) : assert(gridDelegate != null),
assert(childrenDelegate != null), assert(childrenDelegate != null),
super( super(
...@@ -1741,6 +1763,7 @@ class GridView extends BoxScrollView { ...@@ -1741,6 +1763,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount, semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, 2D array of widgets with a fixed number of tiles in /// Creates a scrollable, 2D array of widgets with a fixed number of tiles in
...@@ -1778,6 +1801,7 @@ class GridView extends BoxScrollView { ...@@ -1778,6 +1801,7 @@ class GridView extends BoxScrollView {
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : gridDelegate = SliverGridDelegateWithFixedCrossAxisCount( }) : gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
mainAxisSpacing: mainAxisSpacing, mainAxisSpacing: mainAxisSpacing,
...@@ -1803,6 +1827,7 @@ class GridView extends BoxScrollView { ...@@ -1803,6 +1827,7 @@ class GridView extends BoxScrollView {
semanticChildCount: semanticChildCount ?? children.length, semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// Creates a scrollable, 2D array of widgets with tiles that each have a /// Creates a scrollable, 2D array of widgets with tiles that each have a
...@@ -1835,10 +1860,12 @@ class GridView extends BoxScrollView { ...@@ -1835,10 +1860,12 @@ class GridView extends BoxScrollView {
bool addAutomaticKeepAlives = true, bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true, bool addRepaintBoundaries = true,
bool addSemanticIndexes = true, bool addSemanticIndexes = true,
double cacheExtent,
List<Widget> children = const <Widget>[], List<Widget> children = const <Widget>[],
int semanticChildCount, int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
String restorationId,
}) : gridDelegate = SliverGridDelegateWithMaxCrossAxisExtent( }) : gridDelegate = SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent, maxCrossAxisExtent: maxCrossAxisExtent,
mainAxisSpacing: mainAxisSpacing, mainAxisSpacing: mainAxisSpacing,
...@@ -1860,9 +1887,11 @@ class GridView extends BoxScrollView { ...@@ -1860,9 +1887,11 @@ class GridView extends BoxScrollView {
physics: physics, physics: physics,
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
padding: padding, padding: padding,
cacheExtent: cacheExtent,
semanticChildCount: semanticChildCount ?? children.length, semanticChildCount: semanticChildCount ?? children.length,
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
keyboardDismissBehavior: keyboardDismissBehavior, keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
); );
/// A delegate that controls the layout of the children within the [GridView]. /// A delegate that controls the layout of the children within the [GridView].
......
...@@ -12,6 +12,7 @@ import 'package:flutter/gestures.dart'; ...@@ -12,6 +12,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -19,6 +20,8 @@ import 'focus_manager.dart'; ...@@ -19,6 +20,8 @@ import 'focus_manager.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'notification_listener.dart'; import 'notification_listener.dart';
import 'restoration.dart';
import 'restoration_properties.dart';
import 'scroll_configuration.dart'; import 'scroll_configuration.dart';
import 'scroll_context.dart'; import 'scroll_context.dart';
import 'scroll_controller.dart'; import 'scroll_controller.dart';
...@@ -90,6 +93,7 @@ class Scrollable extends StatefulWidget { ...@@ -90,6 +93,7 @@ class Scrollable extends StatefulWidget {
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.semanticChildCount, this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.restorationId,
}) : assert(axisDirection != null), }) : assert(axisDirection != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(viewportBuilder != null), assert(viewportBuilder != null),
...@@ -226,6 +230,22 @@ class Scrollable extends StatefulWidget { ...@@ -226,6 +230,22 @@ class Scrollable extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final DragStartBehavior dragStartBehavior; 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. /// The axis along which the scroll view scrolls.
/// ///
/// Determined by the [axisDirection]. /// Determined by the [axisDirection].
...@@ -239,6 +259,7 @@ class Scrollable extends StatefulWidget { ...@@ -239,6 +259,7 @@ class Scrollable extends StatefulWidget {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection)); properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
properties.add(DiagnosticsProperty<ScrollPhysics>('physics', physics)); 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. /// The state from the closest instance of this class that encloses the given context.
...@@ -340,7 +361,7 @@ class _ScrollableScope extends InheritedWidget { ...@@ -340,7 +361,7 @@ class _ScrollableScope extends InheritedWidget {
/// ///
/// This class is not intended to be subclassed. To specialize the behavior of a /// This class is not intended to be subclassed. To specialize the behavior of a
/// [Scrollable], provide it with a [ScrollPhysics]. /// [Scrollable], provide it with a [ScrollPhysics].
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin
implements ScrollContext { implements ScrollContext {
/// The manager for this [Scrollable] widget's viewport position. /// The manager for this [Scrollable] widget's viewport position.
/// ///
...@@ -350,6 +371,8 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -350,6 +371,8 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
ScrollPosition get position => _position; ScrollPosition get position => _position;
ScrollPosition _position; ScrollPosition _position;
final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset();
@override @override
AxisDirection get axisDirection => widget.axisDirection; AxisDirection get axisDirection => widget.axisDirection;
...@@ -378,10 +401,28 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -378,10 +401,28 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
controller?.attach(position); 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 @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies();
_updatePosition(); _updatePosition();
super.didChangeDependencies();
} }
bool _shouldUpdatePosition(Scrollable oldWidget) { bool _shouldUpdatePosition(Scrollable oldWidget) {
...@@ -414,6 +455,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -414,6 +455,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
void dispose() { void dispose() {
widget.controller?.detach(position); widget.controller?.detach(position);
position.dispose(); position.dispose();
_persistedScrollOffset.dispose();
super.dispose(); super.dispose();
} }
...@@ -663,6 +705,9 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -663,6 +705,9 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ScrollPosition>('position', position)); properties.add(DiagnosticsProperty<ScrollPosition>('position', position));
} }
@override
String get restorationId => widget.restorationId;
} }
/// With [_ScrollSemantics] certain child [SemanticsNode]s can be /// With [_ScrollSemantics] certain child [SemanticsNode]s can be
...@@ -1029,3 +1074,28 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -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 { ...@@ -222,6 +222,7 @@ class SingleChildScrollView extends StatelessWidget {
this.child, this.child,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId,
}) : assert(scrollDirection != null), }) : assert(scrollDirection != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(clipBehavior != null), assert(clipBehavior != null),
...@@ -299,6 +300,9 @@ class SingleChildScrollView extends StatelessWidget { ...@@ -299,6 +300,9 @@ class SingleChildScrollView extends StatelessWidget {
/// Defaults to [Clip.hardEdge]. /// Defaults to [Clip.hardEdge].
final Clip clipBehavior; final Clip clipBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String restorationId;
AxisDirection _getDirection(BuildContext context) { AxisDirection _getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse); return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
} }
...@@ -317,6 +321,7 @@ class SingleChildScrollView extends StatelessWidget { ...@@ -317,6 +321,7 @@ class SingleChildScrollView extends StatelessWidget {
axisDirection: axisDirection, axisDirection: axisDirection,
controller: scrollController, controller: scrollController,
physics: physics, physics: physics,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) { viewportBuilder: (BuildContext context, ViewportOffset offset) {
return _SingleChildViewport( return _SingleChildViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -197,6 +198,42 @@ void main() { ...@@ -197,6 +198,42 @@ void main() {
}); });
expect(rootBucket, isNull); 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', () { 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