From bcfc293ca903e2e63e82865d262075ce3425fb79 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Sat, 25 Jan 2020 01:18:03 -0800 Subject: [PATCH] recommendDeferredLoading (#49319) --- .../lib/src/widgets/scroll_physics.dart | 53 +++++ .../lib/src/widgets/scroll_position.dart | 17 ++ .../flutter/lib/src/widgets/scrollable.dart | 24 ++ .../flutter/test/widgets/scrollable_test.dart | 209 ++++++++++++++++++ 4 files changed, 303 insertions(+) diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index 48dcfb7940..834092ebd4 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 3ccb85133e..89ae779abb 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index cbf6de687f..a68684dbe8 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -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 ensureVisible( diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 023684912b..0a90ffb099 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -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 widgetTracker = []; + 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('Box $index'), height: 50.0); + }, + ), + )); + + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('Box 0')), findsOneWidget); + expect(find.byKey(const ValueKey('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('Box 0')), findsNothing); + expect(find.byKey(const ValueKey('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('Cheap box $index'), height: 50.0); + } + expensiveWidgets += 1; + return SizedBox(key: ValueKey('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('Box 0')), findsOneWidget); + expect(find.byKey(const ValueKey('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('Box 0')), findsNothing); + expect(find.byKey(const ValueKey('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('Cheap box $index'), height: 50.0); + } + expensiveWidgets += 1; + return SizedBox(key: ValueKey('Box $index'), height: 50.0); + }, + ), + )); + await tester.pumpAndSettle(); + + final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position; + + expect(find.byKey(const ValueKey('Cheap box 0')), findsOneWidget); + expect(find.byKey(const ValueKey('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('Cheap box 0')), findsNothing); + expect(find.byKey(const ValueKey('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)); + } }