diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 71fe06a54..08674aa44 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -157,6 +157,18 @@ class TileImage extends ChangeNotifier { } void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { + if (imageInfo.image.width == 1 && imageInfo.image.height == 1) { + // TileProvider.transparentImage. Evicted so the next tile at these + // coordinates refetches instead of hitting it in the URL-keyed cache. + try { + unawaited(imageProvider.evict().catchError((Object _) => false)); + } catch (_) { + // As in `dispose`, `evict` can also throw synchronously. + } + _onImageLoadError(const TileLoadAbandonedException(), null); + return; + } + loadError = false; this.imageInfo = imageInfo; @@ -176,15 +188,17 @@ class TileImage extends ChangeNotifier { } } - // Initiates fading in and marks this TileImage as readyToDisplay when fading - // finishes. If fading is disabled or a loading error occurred this TileImage - // becomes readyToDisplay immediately. + // Called once loading has settled, successfully or not. Initiates fading in + // and marks this TileImage as readyToDisplay when fading finishes; immediate + // if fading is disabled, or if loading failed and there is an errorImage to + // paint instead. A failure with nothing to paint never becomes + // readyToDisplay. void _display() { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); if (loadError) { - _readyToDisplay = true; + _readyToDisplay = errorImage != null; if (!_disposed) notifyListeners(); return; } @@ -258,3 +272,16 @@ class TileImage extends ChangeNotifier { return 'TileImage($coordinates, readyToDisplay: $_readyToDisplay)'; } } + +/// Thrown when a tile resolves to [TileProvider.transparentImage], which a +/// [TileProvider] returns in place of an error it cannot throw, usually a +/// cancellation. +class TileLoadAbandonedException implements Exception { + /// Create a [TileLoadAbandonedException]. + const TileLoadAbandonedException(); + + @override + String toString() => + 'TileLoadAbandonedException: the tile provider returned the transparent ' + 'placeholder instead of tile data'; +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index c53e27ad0..7e30e365c 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -722,7 +722,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { zoom.round().clamp(widget.minNativeZoom, widget.maxNativeZoom); void _onTileLoadError(TileImage tile, Object error, StackTrace? stackTrace) { - debugPrint(error.toString()); + // An abandoned load is routine while panning, not a fault to report. + if (error is! TileLoadAbandonedException) debugPrint(error.toString()); widget.errorTileCallback?.call(tile, error, stackTrace); } diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart new file mode 100644 index 000000000..4a9462f93 --- /dev/null +++ b/test/layer/tile_layer/tile_image_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_utils/test_tile_image.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future loadFailingTile({ImageProvider? errorImage}) { + final loaded = Completer(); + final tileImage = TileImage( + coordinates: const TileCoordinates(0, 0, 0), + vsync: const _TestTickerProvider(), + imageProvider: _FailingImageProvider(), + onLoadComplete: (_) => loaded.complete(), + onLoadError: (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + cancelLoading: Completer(), + errorImage: errorImage, + )..load(); + return loaded.future.then((_) => tileImage); + } + + test('a failed tile with no error image is not ready to display', () async { + final tileImage = await loadFailingTile(); + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isFalse); + }); + + test('a failed tile with an error image is ready to display', () async { + final tileImage = await loadFailingTile(errorImage: testWhiteTileImage); + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isTrue); + }); + + test('the transparent placeholder counts as a failure, not a tile', () async { + final loaded = Completer(); + Object? reportedError; + final tileImage = TileImage( + coordinates: const TileCoordinates(0, 0, 0), + vsync: const _TestTickerProvider(), + imageProvider: MemoryImage(TileProvider.transparentImage), + onLoadComplete: (_) => loaded.complete(), + onLoadError: (_, error, __) => reportedError = error, + tileDisplay: const TileDisplay.instantaneous(), + cancelLoading: Completer(), + errorImage: null, + )..load(); + await loaded.future; + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isFalse); + expect(tileImage.imageInfo, isNull); + expect(reportedError, isA()); + }); +} + +class _FailingImageProvider extends ImageProvider<_FailingImageProvider> { + @override + Future<_FailingImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + _FailingImageProvider key, + ImageDecoderCallback decode, + ) => + OneFrameImageStreamCompleter(Future.error(Exception('tile unavailable'))); +} + +class _TestTickerProvider implements TickerProvider { + const _TestTickerProvider(); + + @override + Ticker createTicker(TickerCallback onTick) => Ticker((_) {}); +}