scroll_aware_image_provider_test.dart 16.8 KB
Newer Older
1 2 3 4
// 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.

5 6
import 'dart:ui' as ui show Image;

7
import 'package:flutter/widgets.dart';
8
import 'package:flutter_test/flutter_test.dart';
9
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
10 11 12 13

import '../painting/image_test_utils.dart';

void main() {
14

15
  late ui.Image testImage;
16

17 18 19 20 21 22
  ui.Image cloneImage() {
    final ui.Image clone = testImage.clone();
    addTearDown(clone.dispose);
    return clone;
  }

23 24 25 26
  setUpAll(() async {
    testImage = await createTestImage(width: 10, height: 10);
  });

27 28 29 30
  tearDownAll(() {
    testImage.dispose();
  });

31
  tearDown(() {
32
    imageCache.clear();
33 34
  });

35
  T findPhysics<T extends ScrollPhysics>(WidgetTester tester) {
36
    return Scrollable.of(find.byType(TestWidget).evaluate().first).position.physics as T;
37 38
  }

39
  ScrollMetrics findMetrics(WidgetTester tester) {
40
    return Scrollable.of(find.byType(TestWidget).evaluate().first).position;
41 42
  }

43
  testWidgets('ScrollAwareImageProvider does not delay if widget is not in scrollable', (WidgetTester tester) async {
44 45 46
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    await tester.pumpWidget(TestWidget(key));

47
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
48
    addTearDown(context.dispose);
49
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
50 51 52 53 54 55
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
56
    expect(imageCache.containsKey(testImageProvider), false);
57 58 59 60 61

    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
62
    expect(stream.completer!.hasListeners, true);
63 64
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
65 66 67

    testImageProvider.complete();

68
    expect(imageCache.currentSize, 1);
69 70
  });

71
  testWidgets('ScrollAwareImageProvider does not delay if in scrollable that is not scrolling', (WidgetTester tester) async {
72 73 74 75 76 77 78 79 80 81 82
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: ListView(
        physics: RecordingPhysics(),
        children: <Widget>[
          TestWidget(key),
        ],
      ),
    ));

83
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
84
    addTearDown(context.dispose);
85
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
86 87 88 89 90 91
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
92
    expect(imageCache.containsKey(testImageProvider), false);
93 94 95 96 97

    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
98
    expect(stream.completer!.hasListeners, true);
99 100
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
101 102 103

    testImageProvider.complete();

104
    expect(imageCache.currentSize, 1);
105
    expect(findPhysics<RecordingPhysics>(tester).velocities, <double>[0]);
106 107
  });

108
  testWidgets('ScrollAwareImageProvider does not delay if in scrollable that is scrolling slowly', (WidgetTester tester) async {
109 110
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
111
    addTearDown(scrollController.dispose);
112 113 114 115 116 117 118 119 120 121 122 123 124
    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,
      ),
    ));

125
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
126
    addTearDown(context.dispose);
127
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
128 129 130 131 132 133
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
134
    expect(imageCache.containsKey(testImageProvider), false);
135 136 137 138 139 140 141

    scrollController.animateTo(
      100,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
142
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
143 144 145 146 147 148 149

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
150
        findMetrics(tester),
151 152 153 154 155 156 157
        find.byType(TestWidget).evaluate().first,
      ),
      false,
    );

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
158
    expect(stream.completer!.hasListeners, true);
159 160
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
161 162 163

    testImageProvider.complete();

164
    expect(imageCache.currentSize, 1);
165 166
  });

167
  testWidgets('ScrollAwareImageProvider delays if in scrollable that is scrolling fast', (WidgetTester tester) async {
168 169
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
170
    addTearDown(scrollController.dispose);
171 172 173 174 175 176 177 178 179 180 181 182 183
    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,
      ),
    ));

184
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
185
    addTearDown(context.dispose);
186
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
187 188 189 190 191 192
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
193
    expect(imageCache.containsKey(testImageProvider), false);
194 195 196 197 198 199 200

    scrollController.animateTo(
      3000,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
201
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
202 203 204 205 206 207 208

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
209
        findMetrics(tester),
210 211 212 213 214 215 216 217
        find.byType(TestWidget).evaluate().first,
      ),
      true,
    );

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);

218 219
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
220 221 222 223 224 225

    await tester.pump(const Duration(seconds: 1));
    expect(physics.velocities.last, 0);

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
226
    expect(stream.completer!.hasListeners, true);
227

228 229
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
230 231 232

    testImageProvider.complete();

233
    expect(imageCache.currentSize, 1);
234 235
  });

236
  testWidgets('ScrollAwareImageProvider delays if in scrollable that is scrolling fast and fizzles if disposed', (WidgetTester tester) async {
237 238
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
239
    addTearDown(scrollController.dispose);
240 241 242 243 244 245 246 247 248 249 250 251 252
    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,
      ),
    ));

253
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
254
    addTearDown(context.dispose);
255
    final TestImageProvider testImageProvider = TestImageProvider(cloneImage());
256 257 258 259 260 261
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
262
    expect(imageCache.containsKey(testImageProvider), false);
263 264 265 266 267 268 269

    scrollController.animateTo(
      3000,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
270
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
271 272 273 274 275 276 277

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
278
        findMetrics(tester),
279 280 281 282 283 284 285 286
        find.byType(TestWidget).evaluate().first,
      ),
      true,
    );

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);

287 288
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
289 290 291 292 293 294 295 296 297 298

    // 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);

299 300
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
301 302 303

    testImageProvider.complete();

304
    expect(imageCache.currentSize, 0);
305
  });
306

307
  testWidgets('ScrollAwareImageProvider resolves from ImageCache and does not set completer twice', (WidgetTester tester) async {
308 309
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    final ScrollController scrollController = ScrollController();
310
    addTearDown(scrollController.dispose);
311 312 313 314 315 316 317 318 319
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: SingleChildScrollView(
        physics: ControllablePhysics(),
        controller: scrollController,
        child: TestWidget(key),
      ),
    ));

320
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
321
    addTearDown(context.dispose);
322
    final TestImageProvider testImageProvider = TestImageProvider(cloneImage());
323 324 325 326 327 328
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
329
    expect(imageCache.containsKey(testImageProvider), false);
330

331
    final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester);
332 333 334 335 336 337
    physics.recommendDeferredLoadingValue = true;

    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);
338 339
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
340

341
    // Simulate a case where someone else has managed to complete this stream -
342 343 344 345
    // so it can land in the cache right before we stop scrolling fast.
    // If we miss the early return, we will fail.
    testImageProvider.complete();

346
    imageCache.putIfAbsent(testImageProvider, () => testImageProvider.loadImage(testImageProvider, PaintingBinding.instance.instantiateImageCodecWithSize));
347 348 349 350
    // We've stopped scrolling fast.
    physics.recommendDeferredLoadingValue = false;
    await tester.idle();

351 352
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 1);
353
    expect(testImageProvider.loadCallCount, 1);
354 355
    expect(stream.completer, null);
  });
356

357 358 359
  testWidgets('ScrollAwareImageProvider does not block LRU updates to image cache',
  experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(),
  (WidgetTester tester) async {
360 361
    final int oldSize = imageCache.maximumSize;
    imageCache.maximumSize = 1;
362 363 364

    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    final ScrollController scrollController = ScrollController();
365
    addTearDown(scrollController.dispose);
366 367 368 369 370 371 372 373 374
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: SingleChildScrollView(
        physics: ControllablePhysics(),
        controller: scrollController,
        child: TestWidget(key),
      ),
    ));

375
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
376
    addTearDown(context.dispose);
377
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
378 379 380 381 382 383
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
384
    expect(imageCache.containsKey(testImageProvider), false);
385

386
    final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester);
387 388 389 390 391 392
    physics.recommendDeferredLoadingValue = true;

    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);
393
    expect(imageCache.currentSize, 0);
394 395

    // Occupy the only slot in the cache with another image.
396
    final TestImageProvider testImageProvider2 = TestImageProvider(testImage.clone());
397
    testImageProvider2.complete();
398
    await precacheImage(testImageProvider2, context.context!);
399 400 401
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.containsKey(testImageProvider2), true);
    expect(imageCache.currentSize, 1);
402 403 404

    // Complete the original image while we're still scrolling fast.
    testImageProvider.complete();
405
    stream.setCompleter(testImageProvider.loadImage(testImageProvider, PaintingBinding.instance.instantiateImageCodecWithSize));
406

407
    // Verify that this hasn't changed the cache state yet
408 409 410
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.containsKey(testImageProvider2), true);
    expect(imageCache.currentSize, 1);
411 412 413 414 415 416
    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.
417 418 419
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.containsKey(testImageProvider2), false);
    expect(imageCache.currentSize, 1);
420 421
    expect(testImageProvider.loadCallCount, 1);

422
    imageCache.maximumSize = oldSize;
423
  });
424 425 426
}

class TestWidget extends StatefulWidget {
427
  const TestWidget(Key? key) : super(key: key);
428 429 430 431 432 433 434 435 436 437 438

  @override
  State<TestWidget> createState() => TestWidgetState();
}

class TestWidgetState extends State<TestWidget> {
  @override
  Widget build(BuildContext context) => const SizedBox(height: 50);
}

class RecordingPhysics extends ScrollPhysics {
439
  RecordingPhysics({ super.parent });
440 441 442 443

  final List<double> velocities = <double>[];

  @override
444
  RecordingPhysics applyTo(ScrollPhysics? ancestor) {
445
    return RecordingPhysics(parent: buildParent(ancestor));
446 447 448 449 450 451 452 453
  }

  @override
  bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    velocities.add(velocity);
    return super.recommendDeferredLoading(velocity, metrics, context);
  }
}
454 455 456 457 458

// 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 {
459
  ControllablePhysics({ super.parent });
460 461 462 463

  bool recommendDeferredLoadingValue = false;

  @override
464
  ControllablePhysics applyTo(ScrollPhysics? ancestor) {
465
    return ControllablePhysics(parent: buildParent(ancestor));
466 467 468 469 470 471 472
  }

  @override
  bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    return recommendDeferredLoadingValue;
  }
}