scroll_aware_image_provider_test.dart 16.5 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

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

21 22 23 24
  tearDownAll(() {
    testImage.dispose();
  });

25
  tearDown(() {
26
    imageCache.clear();
27 28
  });

29
  T findPhysics<T extends ScrollPhysics>(WidgetTester tester) {
30
    return Scrollable.of(find.byType(TestWidget).evaluate().first).position.physics as T;
31 32
  }

33
  ScrollMetrics findMetrics(WidgetTester tester) {
34
    return Scrollable.of(find.byType(TestWidget).evaluate().first).position;
35 36
  }

37
  testWidgetsWithLeakTracking('ScrollAwareImageProvider does not delay if widget is not in scrollable', (WidgetTester tester) async {
38 39 40
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    await tester.pumpWidget(TestWidget(key));

41
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
42
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
43 44 45 46 47 48
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
49
    expect(imageCache.containsKey(testImageProvider), false);
50 51 52 53 54

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

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
55
    expect(stream.completer!.hasListeners, true);
56 57
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
58 59 60

    testImageProvider.complete();

61
    expect(imageCache.currentSize, 1);
62 63
  });

64
  testWidgetsWithLeakTracking('ScrollAwareImageProvider does not delay if in scrollable that is not scrolling', (WidgetTester tester) async {
65 66 67 68 69 70 71 72 73 74 75
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: ListView(
        physics: RecordingPhysics(),
        children: <Widget>[
          TestWidget(key),
        ],
      ),
    ));

76
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
77
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
78 79 80 81 82 83
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
84
    expect(imageCache.containsKey(testImageProvider), false);
85 86 87 88 89

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

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
90
    expect(stream.completer!.hasListeners, true);
91 92
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
93 94 95

    testImageProvider.complete();

96
    expect(imageCache.currentSize, 1);
97
    expect(findPhysics<RecordingPhysics>(tester).velocities, <double>[0]);
98 99
  });

100
  testWidgetsWithLeakTracking('ScrollAwareImageProvider does not delay if in scrollable that is scrolling slowly', (WidgetTester tester) async {
101 102
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
103
    addTearDown(scrollController.dispose);
104 105 106 107 108 109 110 111 112 113 114 115 116
    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,
      ),
    ));

117
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
118
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
119 120 121 122 123 124
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
125
    expect(imageCache.containsKey(testImageProvider), false);
126 127 128 129 130 131 132

    scrollController.animateTo(
      100,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
133
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
134 135 136 137 138 139 140

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
141
        findMetrics(tester),
142 143 144 145 146 147 148
        find.byType(TestWidget).evaluate().first,
      ),
      false,
    );

    expect(testImageProvider.configuration, ImageConfiguration.empty);
    expect(stream.completer, isNotNull);
149
    expect(stream.completer!.hasListeners, true);
150 151
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
152 153 154

    testImageProvider.complete();

155
    expect(imageCache.currentSize, 1);
156 157
  });

158
  testWidgetsWithLeakTracking('ScrollAwareImageProvider delays if in scrollable that is scrolling fast', (WidgetTester tester) async {
159 160
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
161
    addTearDown(scrollController.dispose);
162 163 164 165 166 167 168 169 170 171 172 173 174
    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,
      ),
    ));

175
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
176
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
177 178 179 180 181 182
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
183
    expect(imageCache.containsKey(testImageProvider), false);
184 185 186 187 188 189 190

    scrollController.animateTo(
      3000,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
191
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
192 193 194 195 196 197 198

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
199
        findMetrics(tester),
200 201 202 203 204 205 206 207
        find.byType(TestWidget).evaluate().first,
      ),
      true,
    );

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

208 209
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
210 211 212 213 214 215

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

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

218 219
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 0);
220 221 222

    testImageProvider.complete();

223
    expect(imageCache.currentSize, 1);
224 225
  });

226
  testWidgetsWithLeakTracking('ScrollAwareImageProvider delays if in scrollable that is scrolling fast and fizzles if disposed', (WidgetTester tester) async {
227 228
    final List<GlobalKey<TestWidgetState>> keys = <GlobalKey<TestWidgetState>>[];
    final ScrollController scrollController = ScrollController();
229
    addTearDown(scrollController.dispose);
230 231 232 233 234 235 236 237 238 239 240 241 242
    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,
      ),
    ));

243
    final DisposableBuildContext context = DisposableBuildContext(keys.last.currentState!);
244
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
245 246 247 248 249 250
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
251
    expect(imageCache.containsKey(testImageProvider), false);
252 253 254 255 256 257 258

    scrollController.animateTo(
      3000,
      duration: const Duration(seconds: 2),
      curve: Curves.fastLinearToSlowEaseIn,
    );
    await tester.pump();
259
    final RecordingPhysics physics = findPhysics<RecordingPhysics>(tester);
260 261 262 263 264 265 266

    expect(physics.velocities.length, 0);
    final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
    expect(physics.velocities.length, 1);
    expect(
      const ScrollPhysics().recommendDeferredLoading(
        physics.velocities.first,
267
        findMetrics(tester),
268 269 270 271 272 273 274 275
        find.byType(TestWidget).evaluate().first,
      ),
      true,
    );

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

276 277
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
278 279 280 281 282 283 284 285 286 287

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

288 289
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
290 291 292

    testImageProvider.complete();

293
    expect(imageCache.currentSize, 0);
294
  });
295

296
  testWidgetsWithLeakTracking('ScrollAwareImageProvider resolves from ImageCache and does not set completer twice', (WidgetTester tester) async {
297 298
    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    final ScrollController scrollController = ScrollController();
299
    addTearDown(scrollController.dispose);
300 301 302 303 304 305 306 307 308
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: SingleChildScrollView(
        physics: ControllablePhysics(),
        controller: scrollController,
        child: TestWidget(key),
      ),
    ));

309
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
310
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
311 312 313 314 315 316
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
317
    expect(imageCache.containsKey(testImageProvider), false);
318

319
    final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester);
320 321 322 323 324 325
    physics.recommendDeferredLoadingValue = true;

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

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);
326 327
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.currentSize, 0);
328

329
    // Simulate a case where someone else has managed to complete this stream -
330 331 332 333
    // so it can land in the cache right before we stop scrolling fast.
    // If we miss the early return, we will fail.
    testImageProvider.complete();

334
    imageCache.putIfAbsent(testImageProvider, () => testImageProvider.loadImage(testImageProvider, PaintingBinding.instance.instantiateImageCodecWithSize));
335 336 337 338
    // We've stopped scrolling fast.
    physics.recommendDeferredLoadingValue = false;
    await tester.idle();

339 340
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.currentSize, 1);
341
    expect(testImageProvider.loadCallCount, 1);
342 343
    expect(stream.completer, null);
  });
344

345
  testWidgetsWithLeakTracking('ScrollAwareImageProvider does not block LRU updates to image cache', (WidgetTester tester) async {
346 347
    final int oldSize = imageCache.maximumSize;
    imageCache.maximumSize = 1;
348 349 350

    final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
    final ScrollController scrollController = ScrollController();
351
    addTearDown(scrollController.dispose);
352 353 354 355 356 357 358 359 360
    await tester.pumpWidget(Directionality(
      textDirection: TextDirection.ltr,
      child: SingleChildScrollView(
        physics: ControllablePhysics(),
        controller: scrollController,
        child: TestWidget(key),
      ),
    ));

361
    final DisposableBuildContext context = DisposableBuildContext(key.currentState!);
362
    final TestImageProvider testImageProvider = TestImageProvider(testImage.clone());
363 364 365 366 367 368
    final ScrollAwareImageProvider<TestImageProvider> imageProvider = ScrollAwareImageProvider<TestImageProvider>(
      context: context,
      imageProvider: testImageProvider,
    );

    expect(testImageProvider.configuration, null);
369
    expect(imageCache.containsKey(testImageProvider), false);
370

371
    final ControllablePhysics physics = findPhysics<ControllablePhysics>(tester);
372 373 374 375 376 377
    physics.recommendDeferredLoadingValue = true;

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

    expect(testImageProvider.configuration, null);
    expect(stream.completer, null);
378
    expect(imageCache.currentSize, 0);
379 380

    // Occupy the only slot in the cache with another image.
381
    final TestImageProvider testImageProvider2 = TestImageProvider(testImage.clone());
382
    testImageProvider2.complete();
383
    await precacheImage(testImageProvider2, context.context!);
384 385 386
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.containsKey(testImageProvider2), true);
    expect(imageCache.currentSize, 1);
387 388 389

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

392
    // Verify that this hasn't changed the cache state yet
393 394 395
    expect(imageCache.containsKey(testImageProvider), false);
    expect(imageCache.containsKey(testImageProvider2), true);
    expect(imageCache.currentSize, 1);
396 397 398 399 400 401
    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.
402 403 404
    expect(imageCache.containsKey(testImageProvider), true);
    expect(imageCache.containsKey(testImageProvider2), false);
    expect(imageCache.currentSize, 1);
405 406
    expect(testImageProvider.loadCallCount, 1);

407
    imageCache.maximumSize = oldSize;
408
  });
409 410 411
}

class TestWidget extends StatefulWidget {
412
  const TestWidget(Key? key) : super(key: key);
413 414 415 416 417 418 419 420 421 422 423

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

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

class RecordingPhysics extends ScrollPhysics {
424
  RecordingPhysics({ super.parent });
425 426 427 428

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

  @override
429
  RecordingPhysics applyTo(ScrollPhysics? ancestor) {
430
    return RecordingPhysics(parent: buildParent(ancestor));
431 432 433 434 435 436 437 438
  }

  @override
  bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    velocities.add(velocity);
    return super.recommendDeferredLoading(velocity, metrics, context);
  }
}
439 440 441 442 443

// 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 {
444
  ControllablePhysics({ super.parent });
445 446 447 448

  bool recommendDeferredLoadingValue = false;

  @override
449
  ControllablePhysics applyTo(ScrollPhysics? ancestor) {
450
    return ControllablePhysics(parent: buildParent(ancestor));
451 452 453 454 455 456 457
  }

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