// 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. import 'dart:ui' as ui show Image; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../painting/image_test_utils.dart'; void main() { late ui.Image testImage; setUpAll(() async { testImage = await createTestImage(width: 10, height: 10); }); tearDown(() { imageCache.clear(); }); T findPhysics<T extends ScrollPhysics>(WidgetTester tester) { return Scrollable.of(find.byType(TestWidget).evaluate().first)!.position.physics as T; } ScrollMetrics findMetrics(WidgetTester tester) { return Scrollable.of(find.byType(TestWidget).evaluate().first)!.position; } testWidgets('ScrollAwareImageProvider does not delay if widget is not in scrollable', (WidgetTester tester) async { final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>(); await tester.pumpWidget(TestWidget(key)); final DisposableBuildContext context = DisposableBuildContext(key.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(testImageProvider.configuration, ImageConfiguration.empty); expect(stream.completer, isNotNull); expect(stream.completer!.hasListeners, true); expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.currentSize, 0); testImageProvider.complete(); expect(imageCache.currentSize, 1); }); testWidgets('ScrollAwareImageProvider does not delay if in scrollable that is not scrolling', (WidgetTester tester) async { final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView( physics: RecordingPhysics(), children: <Widget>[ TestWidget(key), ], ), )); final DisposableBuildContext context = DisposableBuildContext(key.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(testImageProvider.configuration, ImageConfiguration.empty); expect(stream.completer, isNotNull); expect(stream.completer!.hasListeners, true); expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.currentSize, 0); testImageProvider.complete(); expect(imageCache.currentSize, 1); expect(findPhysics<RecordingPhysics>(tester).velocities, <double>[0]); }); testWidgets('ScrollAwareImageProvider does not delay if in scrollable that is scrolling slowly', (WidgetTester tester) async { final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[]; final ScrollController scrollController = ScrollController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( physics: RecordingPhysics(), controller: scrollController, itemBuilder: (BuildContext context, int index) { keys.add(GlobalKey<TestWidgetState>()); return TestWidget(keys.last); }, itemCount: 50, ), )); final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); scrollController.animateTo( 100, duration: const Duration(seconds: 2), curve: Curves.fastLinearToSlowEaseIn, ); await tester.pump(); final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester); expect(physics.velocities.length, 0); final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(physics.velocities.length, 1); expect( const ScrollPhysics().recommendDeferredLoading( physics.velocities.first, findMetrics(tester), find.byType(TestWidget).evaluate().first, ), false, ); expect(testImageProvider.configuration, ImageConfiguration.empty); expect(stream.completer, isNotNull); expect(stream.completer!.hasListeners, true); expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.currentSize, 0); testImageProvider.complete(); expect(imageCache.currentSize, 1); }); testWidgets('ScrollAwareImageProvider delays if in scrollable that is scrolling fast', (WidgetTester tester) async { final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[]; final ScrollController scrollController = ScrollController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( physics: RecordingPhysics(), controller: scrollController, itemBuilder: (BuildContext context, int index) { keys.add(GlobalKey<TestWidgetState>()); return TestWidget(keys.last); }, itemCount: 50, ), )); final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); scrollController.animateTo( 3000, duration: const Duration(seconds: 2), curve: Curves.fastLinearToSlowEaseIn, ); await tester.pump(); final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester); expect(physics.velocities.length, 0); final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(physics.velocities.length, 1); expect( const ScrollPhysics().recommendDeferredLoading( physics.velocities.first, findMetrics(tester), find.byType(TestWidget).evaluate().first, ), true, ); expect(testImageProvider.configuration, null); expect(stream.completer, null); expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.currentSize, 0); await tester.pump(const Duration(seconds: 1)); expect(physics.velocities.last, 0); expect(testImageProvider.configuration, ImageConfiguration.empty); expect(stream.completer, isNotNull); expect(stream.completer!.hasListeners, true); expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.currentSize, 0); testImageProvider.complete(); expect(imageCache.currentSize, 1); }); testWidgets('ScrollAwareImageProvider delays if in scrollable that is scrolling fast and fizzles if disposed', (WidgetTester tester) async { final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[]; final ScrollController scrollController = ScrollController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( physics: RecordingPhysics(), controller: scrollController, itemBuilder: (BuildContext context, int index) { keys.add(GlobalKey<TestWidgetState>()); return TestWidget(keys.last); }, itemCount: 50, ), )); final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); scrollController.animateTo( 3000, duration: const Duration(seconds: 2), curve: Curves.fastLinearToSlowEaseIn, ); await tester.pump(); final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester); expect(physics.velocities.length, 0); final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(physics.velocities.length, 1); expect( const ScrollPhysics().recommendDeferredLoading( physics.velocities.first, findMetrics(tester), find.byType(TestWidget).evaluate().first, ), true, ); expect(testImageProvider.configuration, null); expect(stream.completer, null); expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.currentSize, 0); // as if we had picked a context that scrolled out of the tree. context.dispose(); await tester.pump(const Duration(seconds: 1)); expect(physics.velocities.length, 1); expect(testImageProvider.configuration, null); expect(stream.completer, null); expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.currentSize, 0); testImageProvider.complete(); expect(imageCache.currentSize, 0); }); testWidgets('ScrollAwareImageProvider resolves from ImageCache and does not set completer twice', (WidgetTester tester) async { final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>(); final ScrollController scrollController = ScrollController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( physics: ControllablePhysics(), controller: scrollController, child: TestWidget(key), ), )); final DisposableBuildContext context = DisposableBuildContext(key.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester); physics.recommendDeferredLoadingValue = true; final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(testImageProvider.configuration, null); expect(stream.completer, null); expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.currentSize, 0); // Simulate a case where someone else has managed to complete this stream - // so it can land in the cache right before we stop scrolling fast. // If we miss the early return, we will fail. testImageProvider.complete(); imageCache.putIfAbsent(testImageProvider, () => testImageProvider.loadBuffer(testImageProvider, PaintingBinding.instance.instantiateImageCodecFromBuffer)); // We've stopped scrolling fast. physics.recommendDeferredLoadingValue = false; await tester.idle(); expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.currentSize, 1); expect(testImageProvider.loadCallCount, 1); expect(stream.completer, null); }); testWidgets('ScrollAwareImageProvider does not block LRU updates to image cache', (WidgetTester tester) async { final int oldSize = imageCache.maximumSize; imageCache.maximumSize = 1; final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>(); final ScrollController scrollController = ScrollController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( physics: ControllablePhysics(), controller: scrollController, child: TestWidget(key), ), )); final DisposableBuildContext context = DisposableBuildContext(key.currentState!); final TestImageProvider testImageProvider = TestImageProvider(testImage.clone()); final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>( context: context, imageProvider: testImageProvider, ); expect(testImageProvider.configuration, null); expect(imageCache.containsKey(testImageProvider), false); final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester); physics.recommendDeferredLoadingValue = true; final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty); expect(testImageProvider.configuration, null); expect(stream.completer, null); expect(imageCache.currentSize, 0); // Occupy the only slot in the cache with another image. final TestImageProvider testImageProvider2 = TestImageProvider(testImage.clone()); testImageProvider2.complete(); await precacheImage(testImageProvider2, context.context!); expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.containsKey(testImageProvider2), true); expect(imageCache.currentSize, 1); // Complete the original image while we're still scrolling fast. testImageProvider.complete(); stream.setCompleter(testImageProvider.loadBuffer(testImageProvider, PaintingBinding.instance.instantiateImageCodecFromBuffer)); // Verify that this hasn't changed the cache state yet expect(imageCache.containsKey(testImageProvider), false); expect(imageCache.containsKey(testImageProvider2), true); expect(imageCache.currentSize, 1); expect(testImageProvider.loadCallCount, 1); await tester.pump(); // After pumping a frame, the original image should be in the cache because // it took the LRU slot. expect(imageCache.containsKey(testImageProvider), true); expect(imageCache.containsKey(testImageProvider2), false); expect(imageCache.currentSize, 1); expect(testImageProvider.loadCallCount, 1); imageCache.maximumSize = oldSize; }); } class TestWidget extends StatefulWidget { const TestWidget(Key? key) : super(key: key); @override State<TestWidget> createState() => TestWidgetState(); } class TestWidgetState extends State<TestWidget> { @override Widget build(BuildContext context) => const SizedBox(height: 50); } class RecordingPhysics extends ScrollPhysics { RecordingPhysics({ super.parent }); final List<double> velocities = <double>[]; @override RecordingPhysics applyTo(ScrollPhysics? ancestor) { return RecordingPhysics(parent: buildParent(ancestor)); } @override bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) { velocities.add(velocity); return super.recommendDeferredLoading(velocity, metrics, context); } } // Ignore this so that we can mutate whether we defer loading or not at specific // times without worrying about actual scrolling mechanics. // ignore: must_be_immutable class ControllablePhysics extends ScrollPhysics { ControllablePhysics({ super.parent }); bool recommendDeferredLoadingValue = false; @override ControllablePhysics applyTo(ScrollPhysics? ancestor) { return ControllablePhysics(parent: buildParent(ancestor)); } @override bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) { return recommendDeferredLoadingValue; } }