Commit bcfc293c authored by Dan Field's avatar Dan Field Committed by Flutter GitHub Bot

recommendDeferredLoading (#49319)

parent 6b8f013a
......@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'binding.dart' show WidgetsBinding;
import 'framework.dart';
import 'overscroll_indicator.dart';
import 'scroll_metrics.dart';
import 'scroll_simulation.dart';
......@@ -135,6 +136,58 @@ class ScrollPhysics {
return parent.shouldAcceptUserOffset(position);
}
/// Provides a heuristic to determine if expensive frame-bound tasks should be
/// deferred.
///
/// The velocity parameter must not be null, but may be positive, negative, or
/// zero.
///
/// The metrics parameter must not be null.
///
/// The context parameter must not be null. It normally refers to the
/// [BuildContext] of the widget making the call, such as an [Image] widget
/// in a [ListView].
///
/// This can be used to determine whether decoding or fetching complex data
/// for the currently visible part of the viewport should be delayed
/// to avoid doing work that will not have a chance to appear before a new
/// frame is rendered.
///
/// For example, a list of images could use this logic to delay decoding
/// images until scrolling is slow enough to actually render the decoded
/// image to the screen.
///
/// The default implementation is a heuristic that compares the current
/// scroll velocity in local logical pixels to the longest side of the window
/// in physical pixels. Implementers can change this heuristic by overriding
/// this method and providing their custom physics to the scrollable widget.
/// For example, an application that changes the local coordinate system with
/// a large perspective transform could provide a more or less aggressive
/// heuristic depending on whether the transform was increasing or decreasing
/// the overall scale between the global screen and local scrollable
/// coordinate systems.
///
/// The default implementation is stateless, and simply provides a point-in-
/// time decision about how fast the scrollable is scrolling. It would always
/// return true for a scrollable that is animating back and forth at high
/// velocity in a loop. It is assumed that callers will handle such
/// a case, or that a custom stateful implementation would be written that
/// tracks the sign of the velocity on successive calls.
///
/// Returning true from this method indicates that the current scroll velocity
/// is great enough that expensive operations impacting the UI should be
/// deferred.
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
assert(velocity != null);
assert(metrics != null);
assert(context != null);
if (parent == null) {
final double maxPhysicalPixels = WidgetsBinding.instance.window.physicalSize.longestSide;
return velocity.abs() > maxPhysicalPixels;
}
return parent.recommendDeferredLoading(velocity, metrics, context);
}
/// Determines the overscroll by applying the boundary conditions.
///
/// Called by [ScrollPosition.applyBoundaryConditions], which is called by
......
......@@ -741,6 +741,23 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
UserScrollNotification(metrics: copyWith(), context: context.notificationContext, direction: direction).dispatch(context.notificationContext);
}
/// Provides a heuristic to determine if expensive frame-bound tasks should be
/// deferred.
///
/// The actual work of this is delegated to the [physics] via
/// [ScrollPhysics.recommendDeferredScrolling] called with the current
/// [activity]'s [ScrollActivity.velocity].
///
/// Returning true from this method indicates that the [ScrollPhysics]
/// evaluate the current scroll velocity to be great enough that expensive
/// operations impacting the UI should be deferred.
bool recommendDeferredLoading(BuildContext context) {
assert(context != null);
assert(activity != null);
assert(activity.velocity != null);
return physics.recommendDeferredLoading(activity.velocity, copyWith(), context);
}
@override
void dispose() {
activity?.dispose(); // it will be null if it got absorbed by another ScrollPosition
......
......@@ -246,11 +246,35 @@ class Scrollable extends StatefulWidget {
/// ```dart
/// ScrollableState scrollable = Scrollable.of(context);
/// ```
///
/// Calling this method will create a dependency on the closest [Scrollable]
/// in the [context], if there is one.
static ScrollableState of(BuildContext context) {
final _ScrollableScope widget = context.dependOnInheritedWidgetOfExactType<_ScrollableScope>();
return widget?.scrollable;
}
/// Provides a heuristic to determine if expensive frame-bound tasks should be
/// deferred for the [context] at a specific point in time.
///
/// Calling this method does _not_ create a dependency on any other widget.
/// This also means that the value returned is only good for the point in time
/// when it is called, and callers will not get updated if the value changes.
///
/// The heuristic used is determined by the [physics] of this [Scrollable]
/// via [ScrollPhysics.recommendDeferredScrolling]. That method is called with
/// the current [activity]'s [ScrollActivity.velocity].
///
/// If there is no [Scrollable] in the widget tree above the [context], this
/// method returns false.
static bool recommendDeferredLoadingForContext(BuildContext context) {
final _ScrollableScope widget = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>()?.widget as _ScrollableScope;
if (widget == null) {
return false;
}
return widget.position.recommendDeferredLoading(context);
}
/// Scrolls the scrollables that enclose the given context so as to make the
/// given context visible.
static Future<void> ensureVisible(
......
......@@ -676,4 +676,213 @@ void main() {
// of Platform.isMacOS, don't skip this on web anymore.
// https://github.com/flutter/flutter/issues/31366
}, skip: kIsWeb);
testWidgets('Can recommendDeferredLoadingForContext - animation', (WidgetTester tester) async {
final List<String> widgetTracker = <String>[];
int cheapWidgets = 0;
int expensiveWidgets = 0;
final ScrollController controller = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView.builder(
controller: controller,
itemBuilder: (BuildContext context, int index) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
cheapWidgets += 1;
widgetTracker.add('cheap');
return const SizedBox(height: 50.0);
}
widgetTracker.add('expensive');
expensiveWidgets += 1;
return const SizedBox(height: 50.0);
},
),
));
await tester.pumpAndSettle();
expect(expensiveWidgets, 17);
expect(cheapWidgets, 0);
// The position value here is different from the maximum velocity we will
// reach, which is controlled by a combination of curve, duration, and
// position.
// This is just meant to be a pretty good simulation. A linear curve
// with these same parameters will never back off on the velocity enough
// to reset here.
controller.animateTo(
5000,
duration: const Duration(seconds: 2),
curve: Curves.linear,
);
expect(expensiveWidgets, 17);
expect(widgetTracker.every((String type) => type == 'expensive'), true);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(expensiveWidgets, 17);
expect(cheapWidgets, 25);
expect(widgetTracker.skip(17).every((String type) => type == 'cheap'), true);
await tester.pumpAndSettle();
expect(expensiveWidgets, 22);
expect(cheapWidgets, 95);
expect(widgetTracker.skip(17).skip(25).take(70).every((String type) => type == 'cheap'), true);
expect(widgetTracker.skip(17).skip(25).skip(70).every((String type) => type == 'expensive'), true);
});
testWidgets('Can recommendDeferredLoadingForContext - ballistics', (WidgetTester tester) async {
int cheapWidgets = 0;
int expensiveWidgets = 0;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
cheapWidgets += 1;
return const SizedBox(height: 50.0);
}
expensiveWidgets += 1;
return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0);
},
),
));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('Box 0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('Box 52')), findsNothing);
expect(expensiveWidgets, 17);
expect(cheapWidgets, 0);
// Getting the tester to simulate a life-like fling is difficult.
// Instead, just manually drive the activity with a ballistic simulation as
// if the user has flung the list.
Scrollable.of(find.byType(SizedBox).evaluate().first).position.activity.delegate.goBallistic(4000);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing);
expect(find.byKey(const ValueKey<String>('Box 52')), findsOneWidget);
expect(expensiveWidgets, 38);
expect(cheapWidgets, 20);
});
testWidgets('Can recommendDeferredLoadingForContext - override heuristic', (WidgetTester tester) async {
int cheapWidgets = 0;
int expensiveWidgets = 0;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView.builder(
physics: SuperPessimisticScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
cheapWidgets += 1;
return SizedBox(key: ValueKey<String>('Cheap box $index'), height: 50.0);
}
expensiveWidgets += 1;
return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0);
},
),
));
await tester.pumpAndSettle();
final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position;
final SuperPessimisticScrollPhysics physics = position.physics as SuperPessimisticScrollPhysics;
expect(find.byKey(const ValueKey<String>('Box 0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsNothing);
expect(physics.count, 17);
expect(expensiveWidgets, 17);
expect(cheapWidgets, 0);
// Getting the tester to simulate a life-like fling is difficult.
// Instead, just manually drive the activity with a ballistic simulation as
// if the user has flung the list.
position.activity.delegate.goBallistic(4000);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing);
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget);
expect(expensiveWidgets, 18);
expect(cheapWidgets, 40);
expect(physics.count, 40 + 18);
});
testWidgets('Can recommendDeferredLoadingForContext - override heuristic and always return true', (WidgetTester tester) async {
int cheapWidgets = 0;
int expensiveWidgets = 0;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView.builder(
physics: const ExtraSuperPessimisticScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
if (Scrollable.recommendDeferredLoadingForContext(context)) {
cheapWidgets += 1;
return SizedBox(key: ValueKey<String>('Cheap box $index'), height: 50.0);
}
expensiveWidgets += 1;
return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0);
},
),
));
await tester.pumpAndSettle();
final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position;
expect(find.byKey(const ValueKey<String>('Cheap box 0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsNothing);
expect(expensiveWidgets, 0);
expect(cheapWidgets, 17);
// Getting the tester to simulate a life-like fling is difficult.
// Instead, just manually drive the activity with a ballistic simulation as
// if the user has flung the list.
position.activity.delegate.goBallistic(4000);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('Cheap box 0')), findsNothing);
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget);
expect(expensiveWidgets, 0);
expect(cheapWidgets, 58);
});
}
// ignore: must_be_immutable
class SuperPessimisticScrollPhysics extends ScrollPhysics {
SuperPessimisticScrollPhysics({ScrollPhysics parent}) : super(parent: parent);
int count = 0;
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
count++;
return velocity > 1;
}
@override
ScrollPhysics applyTo(ScrollPhysics ancestor) {
return SuperPessimisticScrollPhysics(parent: buildParent(ancestor));
}
}
class ExtraSuperPessimisticScrollPhysics extends ScrollPhysics {
const ExtraSuperPessimisticScrollPhysics({ScrollPhysics parent}) : super(parent: parent);
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return true;
}
@override
ScrollPhysics applyTo(ScrollPhysics ancestor) {
return ExtraSuperPessimisticScrollPhysics(parent: buildParent(ancestor));
}
}
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