From b49e9cfec62f9bbc96897c3faa16f496e8828322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tautvydas=20S=CC=8Cidlauskas?= Date: Fri, 9 Oct 2026 12:20:20 +0300 Subject: [PATCH] fix: keep tiles of other zoom levels drawn in place of failed tiles --- lib/src/layer/tile_layer/tile_image.dart | 18 ++- test/layer/tile_layer/tile_layer_test.dart | 161 +++++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 test/layer/tile_layer/tile_layer_test.dart diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 71fe06a54..f2bf7230a 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -170,20 +170,32 @@ class TileImage extends ChangeNotifier { loadError = true; if (!_disposed) { - _display(); + if (errorImage != null) { + _display(); + } else { + // Not displayable, so that loaded tiles of other zoom levels stay + // visible in its place. Listeners still rebuild, so that a + // `tileBuilder` can draw the failed tile. + notifyListeners(); + } onLoadError(this, exception, stackTrace); onLoadComplete(coordinates); } } // 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. + // finishes. If fading is disabled or a loading error occurred (with an + // errorImage) this TileImage becomes readyToDisplay immediately. void _display() { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); if (loadError) { + assert( + errorImage != null, + 'A TileImage should not be displayed if loading errors and there is no ' + 'errorImage to show.', + ); _readyToDisplay = true; if (!_disposed) notifyListeners(); return; diff --git a/test/layer/tile_layer/tile_layer_test.dart b/test/layer/tile_layer/tile_layer_test.dart new file mode 100644 index 000000000..41ee1bc47 --- /dev/null +++ b/test/layer/tile_layer/tile_layer_test.dart @@ -0,0 +1,161 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../test_utils/test_tile_image.dart'; + +void main() { + const center = LatLng(45.5231, -122.6765); + + // Decoding does not finish under the fake clock of widget tests, so tiles + // are served already decoded. + late ui.Image tileImage; + setUpAll( + () async => tileImage = await createTestImage(width: 256, height: 256)); + + TileProvider failingAboveZoom3() => + _FailingAboveZoomTileProvider(maxZoom: 3, image: tileImage); + + Future pumpMap( + WidgetTester tester, + TileLayer tileLayer, + ) async { + final controller = MapController(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 200, + height: 200, + child: FlutterMap( + mapController: controller, + options: const MapOptions(initialCenter: center, initialZoom: 3), + children: [tileLayer], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; + } + + Iterable tilesDrawn(WidgetTester tester) => + tester.widgetList(find.byType(Tile)).map((tile) => tile.tileImage); + + group('tiles fail to load after zooming in', () { + Future zoomIn(WidgetTester tester, MapController controller) async { + controller.move(center, 5); + await tester.pumpAndSettle(); + } + + testWidgets('without errorImage - tiles of the previous zoom still drawn', + (tester) async { + final controller = await pumpMap( + tester, + TileLayer(tileProvider: failingAboveZoom3()), + ); + + await zoomIn(tester, controller); + + final drawn = tilesDrawn(tester); + expect(drawn.where((tile) => tile.coordinates.z == 5), isNotEmpty); + expect(drawn.where((tile) => tile.coordinates.z == 5 && tile.loadError), + isNotEmpty); + expect(drawn.where((tile) => tile.coordinates.z == 3), isNotEmpty); + }); + + testWidgets( + 'with tileBuilder - builder gets the failed tiles, ' + 'tiles of the previous zoom still drawn', (tester) async { + final builtWithError = {}; + final controller = await pumpMap( + tester, + TileLayer( + tileProvider: failingAboveZoom3(), + tileBuilder: (context, tileWidget, tile) { + if (tile.loadError) builtWithError.add(tile.coordinates); + return tileWidget; + }, + ), + ); + + await zoomIn(tester, controller); + + expect(builtWithError, isNotEmpty); + expect( + builtWithError.map((coordinates) => coordinates.z), everyElement(5)); + expect( + tilesDrawn(tester).where((tile) => tile.coordinates.z == 3), + isNotEmpty, + ); + }); + + testWidgets('with errorImage - error image drawn instead', (tester) async { + final controller = await pumpMap( + tester, + TileLayer( + tileProvider: failingAboveZoom3(), + errorImage: testWhiteTileImage, + ), + ); + + await zoomIn(tester, controller); + + final drawn = tilesDrawn(tester); + expect(drawn.where((tile) => tile.coordinates.z == 5), + everyElement(predicate((tile) => tile.readyToDisplay))); + expect(drawn.where((tile) => tile.coordinates.z == 3), isEmpty); + }); + }); +} + +/// Serves tiles up to [maxZoom], like a cache would without a connection, +/// and fails above it. +class _FailingAboveZoomTileProvider extends TileProvider { + _FailingAboveZoomTileProvider({required this.maxZoom, required this.image}); + + final int maxZoom; + final ui.Image image; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + _TileImageProvider(coordinates, coordinates.z <= maxZoom ? image : null); +} + +class _TileImageProvider extends ImageProvider<_TileImageProvider> { + const _TileImageProvider(this.coordinates, this.image); + + final TileCoordinates coordinates; + + /// Null for a tile that fails to load. + final ui.Image? image; + + @override + Future<_TileImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + _TileImageProvider key, + ImageDecoderCallback decode, + ) => + OneFrameImageStreamCompleter( + switch (image) { + final image? => SynchronousFuture(ImageInfo(image: image.clone())), + null => Future.error(Exception('No connection')), + }, + ); + + @override + bool operator ==(Object other) => + other is _TileImageProvider && other.coordinates == coordinates; + + @override + int get hashCode => coordinates.hashCode; +}