Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions lib/src/layer/tile_layer/tile_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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';
}
3 changes: 2 additions & 1 deletion lib/src/layer/tile_layer/tile_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,8 @@ class _TileLayerState extends State<TileLayer> 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);
}

Expand Down
83 changes: 83 additions & 0 deletions test/layer/tile_layer/tile_image_test.dart
Original file line number Diff line number Diff line change
@@ -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<TileImage> loadFailingTile({ImageProvider? errorImage}) {
final loaded = Completer<void>();
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<void>();
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<TileLoadAbandonedException>());
});
}

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((_) {});
}