recommendDeferredLoading (#49319)
This commit is contained in:
parent
6b8f013a4e
commit
bcfc293ca9
@ -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));
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user