diff --git a/example/lib/pages/tile_loading_error_handle.dart b/example/lib/pages/tile_loading_error_handle.dart index 64de27f77..577b12704 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_loading_error_handle.dart @@ -1,3 +1,6 @@ +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_example/widgets/drawer.dart'; @@ -13,10 +16,12 @@ class TileLoadingErrorHandle extends StatefulWidget { } class _TileLoadingErrorHandleState extends State { + static const _showSnackBarDuration = Duration(seconds: 1); + bool _simulateTileLoadErrors = false; + DateTime? _lastShowedTileLoadError; + @override Widget build(BuildContext context) { - var needLoadingError = true; - return Scaffold( appBar: AppBar(title: const Text('Tile Loading Error Handle')), drawer: buildDrawer(context, TileLoadingErrorHandle.route), @@ -24,34 +29,37 @@ class _TileLoadingErrorHandleState extends State { padding: const EdgeInsets.all(8), child: Column( children: [ + SwitchListTile( + title: const Text('Simulate tile loading errors'), + value: _simulateTileLoadErrors, + onChanged: (newValue) => setState(() { + _simulateTileLoadErrors = newValue; + }), + ), const Padding( padding: EdgeInsets.only(top: 8, bottom: 8), - child: Text('Turn on Airplane mode and try to move or zoom map'), + child: Text( + 'Enable tile load error simulation or disable internet and try to move or zoom map.'), ), Flexible( child: Builder(builder: (BuildContext context) { return FlutterMap( - options: MapOptions( - initialCenter: const LatLng(51.5, -0.09), + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), initialZoom: 5, - onPositionChanged: (MapPosition mapPosition, bool _) { - needLoadingError = true; - }, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - - // For example purposes. It is recommended to use - // TileProvider with a caching and retry strategy, like - // NetworkTileProvider or CachedNetworkTileProvider userAgentPackageName: 'dev.fleaflet.flutter_map.example', + evictErrorTileStrategy: EvictErrorTileStrategy.none, errorTileCallback: (tile, error, stackTrace) { - if (needLoadingError) { + if (_showErrorSnackBar) { + _lastShowedTileLoadError = DateTime.now(); WidgetsBinding.instance.addPostFrameCallback((_) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - duration: const Duration(seconds: 1), + duration: _showSnackBarDuration, content: Text( error.toString(), style: const TextStyle(color: Colors.black), @@ -59,9 +67,11 @@ class _TileLoadingErrorHandleState extends State { backgroundColor: Colors.deepOrange, )); }); - needLoadingError = false; } }, + tileProvider: _simulateTileLoadErrors + ? _SimulateErrorsTileProvider() + : null, ), ], ); @@ -72,4 +82,48 @@ class _TileLoadingErrorHandleState extends State { ), ); } + + bool get _showErrorSnackBar => + _lastShowedTileLoadError == null || + DateTime.now().difference(_lastShowedTileLoadError!) - + const Duration(milliseconds: 50) > + _showSnackBarDuration; +} + +class _SimulateErrorsTileProvider extends TileProvider { + _SimulateErrorsTileProvider() : super(); + + @override + ImageProvider getImage( + TileCoordinates coordinates, + TileLayer options, + ) => + _SimulateErrorImageProvider(); +} + +class _SimulateErrorImageProvider + extends ImageProvider<_SimulateErrorImageProvider> { + _SimulateErrorImageProvider(); + + @override + ImageStreamCompleter load( + _SimulateErrorImageProvider key, + Future Function( + Uint8List, { + bool allowUpscaling, + int? cacheHeight, + int? cacheWidth, + }) decode, + ) => + _SimulateErrorImageStreamCompleter(); + + @override + Future<_SimulateErrorImageProvider> obtainKey(ImageConfiguration _) => + Future.error('Simulated tile loading error'); +} + +class _SimulateErrorImageStreamCompleter extends ImageStreamCompleter { + _SimulateErrorImageStreamCompleter() { + throw 'Simulated tile loading error'; + } } diff --git a/lib/src/layer/tile_layer/tile_coordinates.dart b/lib/src/layer/tile_layer/tile_coordinates.dart index c1138e10b..724a08794 100644 --- a/lib/src/layer/tile_layer/tile_coordinates.dart +++ b/lib/src/layer/tile_layer/tile_coordinates.dart @@ -10,14 +10,7 @@ class TileCoordinates extends Point { @override String toString() => 'TileCoordinate($x, $y, $z)'; - @override - bool operator ==(Object other) { - if (other is! TileCoordinates) return false; - - return x == other.x && y == other.y && z == other.z; - } - - // Overriden because Point's distanceTo does not allow comparing with a point + // Overridden because Point's distanceTo does not allow comparing with a point // of a different type. @override double distanceTo(Point other) { @@ -26,6 +19,15 @@ class TileCoordinates extends Point { return sqrt(dx * dx + dy * dy); } + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TileCoordinates && + other.x == x && + other.y == y && + other.z == z; + } + @override int get hashCode => Object.hash(x.hashCode, y.hashCode, z.hashCode); } diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index a75fdf68d..520617245 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -1,11 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; class TileImage extends ChangeNotifier { bool _disposed = false; + // Controls fade-in opacity. + AnimationController? _animationController; + + // Whether the tile is displayable. See [readyToDisplay]. + bool _readyToDisplay = false; + /// Used by animationController. Still required if animation is disabled in /// case the tile display is changed at a later point. final TickerProvider vsync; @@ -14,8 +19,6 @@ class TileImage extends ChangeNotifier { /// indicate the position of the tile at that zoom level. final TileCoordinates coordinates; - AnimationController? _animationController; - /// Callback fired when loading finishes with or withut an error. This /// callback is not triggered after this TileImage is disposed. final void Function(TileCoordinates coordinates) onLoadComplete; @@ -27,29 +30,14 @@ class TileImage extends ChangeNotifier { onLoadError; /// Options for how the tile image is displayed. - TileDisplay _display; + TileDisplay _tileDisplay; /// An optional image to show when a loading error occurs. final ImageProvider? errorImage; ImageProvider imageProvider; - /// Current tiles are tiles which are in the current tile zoom AND: - /// * Are visible OR, - /// * Were previously visible and are still within the visible bounds - /// expanded by the [TileLayer.keepBuffer]. - bool current = true; - - /// Used during pruning to determine which tiles should be kept. - bool retain = false; - - /// Whether the tile is displayable. This means that either: - /// * Loading errored but there is a tile error image. - /// * Loading succeeded and the fade animation has finished. - /// * Loading succeeded and there is no fade animation. - bool _active = false; - - // True if an error occurred during loading. + /// True if an error occurred during loading. bool loadError = false; /// When loading started. @@ -70,7 +58,7 @@ class TileImage extends ChangeNotifier { required this.onLoadError, required TileDisplay tileDisplay, required this.errorImage, - }) : _display = tileDisplay, + }) : _tileDisplay = tileDisplay, _animationController = tileDisplay.when( instantaneous: (_) => null, fadeIn: (fadeIn) => AnimationController( @@ -79,8 +67,9 @@ class TileImage extends ChangeNotifier { ), ); - double get opacity => _display.when( - instantaneous: (instantaneous) => _active ? instantaneous.opacity : 0.0, + double get opacity => _tileDisplay.when( + instantaneous: (instantaneous) => + _readyToDisplay ? instantaneous.opacity : 0.0, fadeIn: (fadeIn) => _animationController!.value, )!; @@ -88,7 +77,14 @@ class TileImage extends ChangeNotifier { String get coordinatesKey => coordinates.key; - bool get active => _active; + /// Whether the tile is displayable. This means that either: + /// * Loading errored but an error image is configured. + /// * Loading succeeded and the fade animation has finished. + /// * Loading succeeded and there is no fade animation. + /// + /// Note that [opacity] can be less than 1 when this is true if instantaneous + /// tile display is used with a maximum opacity less than 1. + bool get readyToDisplay => _readyToDisplay; // Used to sort TileImages by their distance from the current zoom. double zIndex(double maxZoom, int currentZoom) => @@ -96,8 +92,8 @@ class TileImage extends ChangeNotifier { // Change the tile display options. set tileDisplay(TileDisplay newTileDisplay) { - final oldTileDisplay = _display; - _display = newTileDisplay; + final oldTileDisplay = _tileDisplay; + _tileDisplay = newTileDisplay; // Handle disabling/enabling of animation controller if necessary oldTileDisplay.when( @@ -108,7 +104,7 @@ class TileImage extends ChangeNotifier { _animationController = AnimationController( duration: fadeIn.duration, vsync: vsync, - value: _active ? 1.0 : 0.0, + value: _readyToDisplay ? 1.0 : 0.0, ); }, ); @@ -156,7 +152,7 @@ class TileImage extends ChangeNotifier { this.imageInfo = imageInfo; if (!_disposed) { - _activate(); + _display(); onLoadComplete(coordinates); } } @@ -165,42 +161,49 @@ class TileImage extends ChangeNotifier { loadError = true; if (!_disposed) { - _activate(); + if (errorImage != null) _display(); onLoadError(this, exception, stackTrace); onLoadComplete(coordinates); } } - void _activate() { + // 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. + void _display() { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); - _display.when( + 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; + } + + _tileDisplay.when( instantaneous: (_) { - _active = true; + _readyToDisplay = true; if (!_disposed) notifyListeners(); }, fadeIn: (fadeIn) { - if (loadError && errorImage != null) { - _active = true; - if (!_disposed) notifyListeners(); - return; - } - final fadeStartOpacity = previouslyLoaded ? fadeIn.reloadStartOpacity : fadeIn.startOpacity; if (fadeStartOpacity == 1.0) { - _active = true; + _readyToDisplay = true; if (!_disposed) notifyListeners(); - return; + } else { + _animationController!.reset(); + _animationController!.forward(from: fadeStartOpacity).then((_) { + _readyToDisplay = true; + if (!_disposed) notifyListeners(); + }); } - - _animationController!.reset(); - _animationController!.forward(from: fadeStartOpacity).then((_) { - _active = true; - if (!_disposed) notifyListeners(); - }); }, ); } @@ -223,8 +226,7 @@ class TileImage extends ChangeNotifier { } } - // Mark the image as inactive. - _active = false; + _readyToDisplay = false; _animationController?.stop(canceled: false); _animationController?.value = 0.0; notifyListeners(); @@ -244,6 +246,6 @@ class TileImage extends ChangeNotifier { @override String toString() { - return 'TileImage($coordinates, active: $_active)'; + return 'TileImage($coordinates, readyToDisplay: $_readyToDisplay)'; } } diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index 2f1a19ed6..8c87f5170 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -1,11 +1,10 @@ -import 'dart:math'; - import 'package:collection/collection.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image_view.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; @@ -58,11 +57,9 @@ class TileImageManager { return true; } - // For all of the tile coordinates: - // * A TileImage is created if missing (current = true in new TileImages) - // * If it exists current is set to true - // * Of these tiles, those which have not started loading yet are returned. - List setCurrentAndReturnNotLoadedTiles( + /// Creates and returns [TileImage]s which do not already exist with the given + /// [tileCoordinates]. + List createMissingTilesIn( Iterable tileCoordinates, { required TileCreator createTile, }) { @@ -74,7 +71,6 @@ class TileImageManager { () => createTile(coordinates), ); - tile.current = true; if (tile.loadStarted == null) notLoaded.add(tile); } @@ -100,7 +96,10 @@ class TileImageManager { } } - void _removeWithDefaultEviction(String key, EvictErrorTileStrategy strategy) { + void _removeWithEvictionStrategy( + String key, + EvictErrorTileStrategy strategy, + ) { _remove( key, evictImageFromCache: (tileImage) => @@ -112,7 +111,7 @@ class TileImageManager { final toRemove = Map.from(_tiles); for (final key in toRemove.keys) { - _removeWithDefaultEviction(key, evictStrategy); + _removeWithEvictionStrategy(key, evictStrategy); } } @@ -137,127 +136,62 @@ class TileImageManager { } } - void markAsNoLongerCurrentOutside( - int currentTileZoom, DiscreteTileRange noPruneRange) { - for (final entry in _tiles.entries) { - final tile = entry.value; - final c = tile.coordinates; + void evictAndPrune({ + required DiscreteTileRange visibleRange, + required int pruneBuffer, + required EvictErrorTileStrategy evictStrategy, + }) { + final pruningState = TileImageView( + tileImages: _tiles, + visibleRange: visibleRange, + keepRange: visibleRange.expand(pruneBuffer), + ); - if (tile.current && - (c.z != currentTileZoom || !noPruneRange.contains(Point(c.x, c.y)))) { - tile.current = false; - } - } + _evictErrorTiles(pruningState, evictStrategy); + _prune(pruningState, evictStrategy); } - // Evicts error tiles depending on the [evictStrategy]. - void evictErrorTiles( - DiscreteTileRange tileRange, + void _evictErrorTiles( + TileImageView tileRemovalState, EvictErrorTileStrategy evictStrategy, ) { - if (evictStrategy == EvictErrorTileStrategy.notVisibleRespectMargin) { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (tile.loadError && !tile.current) { - toRemove.add(entry.key); + switch (evictStrategy) { + case EvictErrorTileStrategy.notVisibleRespectMargin: + for (final tileImage + in tileRemovalState.errorTilesOutsideOfKeepMargin()) { + _remove(tileImage.coordinatesKey, evictImageFromCache: (_) => true); } - } - - for (final key in toRemove) { - _remove(key, evictImageFromCache: (_) => true); - } - } else if (evictStrategy == EvictErrorTileStrategy.notVisible) { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - final c = tile.coordinates; - - if (tile.loadError && - (!tile.current || !tileRange.contains(Point(c.x, c.y)))) { - toRemove.add(entry.key); + case EvictErrorTileStrategy.notVisible: + for (final tileImage in tileRemovalState.errorTilesNotVisible()) { + _remove(tileImage.coordinatesKey, evictImageFromCache: (_) => true); } - } - - for (final key in toRemove) { - _remove(key, evictImageFromCache: (_) => true); - } + case EvictErrorTileStrategy.dispose: + case EvictErrorTileStrategy.none: + return; } } - void prune(EvictErrorTileStrategy evictStrategy) { - for (final tile in _tiles.values) { - tile.retain = tile.current; - } - - for (final tile in _tiles.values) { - if (tile.current && !tile.active) { - final coords = tile.coordinates; - if (!_retainAncestor(coords.x, coords.y, coords.z, coords.z - 5)) { - _retainChildren(coords.x, coords.y, coords.z, coords.z + 2); - } - } - } - - final toRemove = []; - for (final entry in _tiles.entries) { - if (!entry.value.retain) toRemove.add(entry.key); - } - - for (final key in toRemove) { - _removeWithDefaultEviction(key, evictStrategy); - } - } - - // Recurses through the descendants of the Tile at the given coordinates - // setting their [Tile.retain] to true if they are active or loaded. Returns - /// true if any of the descendant tiles were retained. - void _retainChildren(int x, int y, int z, int maxZoom) { - for (var i = 2 * x; i < 2 * x + 2; i++) { - for (var j = 2 * y; j < 2 * y + 2; j++) { - final coords = TileCoordinates(i, j, z + 1); - - final tile = _tiles[coords.key]; - if (tile != null) { - if (tile.active) { - tile.retain = true; - continue; - } else if (tile.loadFinishedAt != null) { - tile.retain = true; - } - } - - if (z + 1 < maxZoom) { - _retainChildren(i, j, z + 1, maxZoom); - } - } - } + void prune({ + required DiscreteTileRange visibleRange, + required int pruneBuffer, + required EvictErrorTileStrategy evictStrategy, + }) { + _prune( + TileImageView( + tileImages: _tiles, + visibleRange: visibleRange, + keepRange: visibleRange.expand(pruneBuffer), + ), + evictStrategy, + ); } - // Recurses through the ancestors of the Tile at the given coordinates setting - // their [Tile.retain] to true if they are active or loaded. Returns true if - // any of the ancestor tiles were active. - bool _retainAncestor(int x, int y, int z, int minZoom) { - final x2 = (x / 2).floor(); - final y2 = (y / 2).floor(); - final z2 = z - 1; - final coords2 = TileCoordinates(x2, y2, z2); - - final tile = _tiles[coords2.key]; - if (tile != null) { - if (tile.active) { - tile.retain = true; - return true; - } else if (tile.loadFinishedAt != null) { - tile.retain = true; - } - } - - if (z2 > minZoom) { - return _retainAncestor(x2, y2, z2, minZoom); + void _prune( + TileImageView tileRemovalState, + EvictErrorTileStrategy evictStrategy, + ) { + for (final tileImage in tileRemovalState.staleTiles()) { + _removeWithEvictionStrategy(tileImage.coordinatesKey, evictStrategy); } - - return false; } } diff --git a/lib/src/layer/tile_layer/tile_image_view.dart b/lib/src/layer/tile_layer/tile_image_view.dart new file mode 100644 index 000000000..7aca827e2 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_image_view.dart @@ -0,0 +1,108 @@ +import 'dart:collection'; + +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; + +class TileImageView { + final Map _tileImages; + final DiscreteTileRange _visibleRange; + final DiscreteTileRange _keepRange; + + TileImageView({ + required Map tileImages, + required DiscreteTileRange visibleRange, + required DiscreteTileRange keepRange, + }) : _tileImages = UnmodifiableMapView(tileImages), + _visibleRange = visibleRange, + _keepRange = keepRange; + + List errorTilesOutsideOfKeepMargin() => _tileImages.values + .where((tileImage) => + tileImage.loadError && !_keepRange.contains(tileImage.coordinates)) + .toList(); + + List errorTilesNotVisible() => _tileImages.values + .where((tileImage) => + tileImage.loadError && !_visibleRange.contains(tileImage.coordinates)) + .toList(); + + List staleTiles() { + final tilesInKeepRange = _tileImages.values + .where((tileImage) => _keepRange.contains(tileImage.coordinates)); + final retain = Set.from(tilesInKeepRange); + + for (final tile in tilesInKeepRange) { + if (!tile.readyToDisplay) { + final coords = tile.coordinates; + if (!_retainAncestor( + retain, coords.x, coords.y, coords.z, coords.z - 5)) { + _retainChildren(retain, coords.x, coords.y, coords.z, coords.z + 2); + } + } + } + + return _tileImages.values + .where((tileImage) => !retain.contains(tileImage)) + .toList(); + } + + // Recurses through the ancestors of the Tile at the given coordinates adding + // them to [retain] if they are ready to display or loaded. Returns true if + // any of the ancestor tiles were ready to display. + bool _retainAncestor( + Set retain, + int x, + int y, + int z, + int minZoom, + ) { + final x2 = (x / 2).floor(); + final y2 = (y / 2).floor(); + final z2 = z - 1; + final coords2 = TileCoordinates(x2, y2, z2); + + final tile = _tileImages[coords2.key]; + if (tile != null) { + if (tile.readyToDisplay) { + retain.add(tile); + return true; + } else if (tile.loadFinishedAt != null) { + retain.add(tile); + } + } + + if (z2 > minZoom) { + return _retainAncestor(retain, x2, y2, z2, minZoom); + } + + return false; + } + + // Recurses through the descendants of the Tile at the given coordinates + // adding them to [retain] if they are ready to display or loaded. + void _retainChildren( + Set retain, + int x, + int y, + int z, + int maxZoom, + ) { + for (var i = 2 * x; i < 2 * x + 2; i++) { + for (var j = 2 * y; j < 2 * y + 2; j++) { + final coords = TileCoordinates(i, j, z + 1); + + final tile = _tileImages[coords.key]; + if (tile != null) { + if (tile.readyToDisplay || tile.loadFinishedAt != null) { + retain.add(tile); + } + } + + if (z + 1 < maxZoom) { + _retainChildren(retain, i, j, z + 1, maxZoom); + } + } + } + } +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 0d43ad2b9..892a7e98a 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -145,10 +145,11 @@ class TileLayer extends StatefulWidget { /// unloading them. final int keepBuffer; - /// When panning the map, extend the tilerange by this many tiles in each - /// direction. - /// Will cause extra tile loads, and impact performance. - /// Be careful increasing this beyond 0 or 1. + /// When loading tiles only visible tiles are loaded by default. This option + /// increases the loaded tiles by the given number on both axis which can help + /// prevent the user from seeing loading tiles whilst panning. Setting the + /// pan buffer too high can impact performance, typically this is set to zero + /// or one. final int panBuffer; /// Tile image to show in place of the tile that failed to load. @@ -462,13 +463,15 @@ class _TileLayerState extends State with TickerProviderStateMixin { // visible until the next build. Therefore, in case this build is executed // before the loading/updating, we must pre-create the missing TileImages // and add them to the widget tree so that when they are loaded they notify - // the Tile and become visible. + // the Tile and become visible. We don't need to prune here as any new tiles + // will be pruned when the map event triggers tile loading. _tileImageManager.createMissingTiles( visibleTileRange, tileBoundsAtZoom, - createTileImage: (coordinate) => _createTileImage( - coordinate, - tileBoundsAtZoom, + createTileImage: (coordinates) => _createTileImage( + coordinates: coordinates, + tileBoundsAtZoom: tileBoundsAtZoom, + pruneAfterLoad: false, ), ); @@ -511,10 +514,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { return color == null ? child : ColoredBox(color: color, child: child); } - TileImage _createTileImage( - TileCoordinates coordinates, - TileBoundsAtZoom tileBoundsAtZoom, - ) { + TileImage _createTileImage({ + required TileCoordinates coordinates, + required TileBoundsAtZoom tileBoundsAtZoom, + required bool pruneAfterLoad, + }) { return TileImage( vsync: this, coordinates: coordinates, @@ -523,7 +527,9 @@ class _TileLayerState extends State with TickerProviderStateMixin { widget, ), onLoadError: _onTileLoadError, - onLoadComplete: _onTileLoadComplete, + onLoadComplete: (coordinates) { + if (pruneAfterLoad) _pruneIfAllTilesLoaded(coordinates); + }, tileDisplay: widget.tileDisplay, errorImage: widget.errorImage, ); @@ -540,14 +546,16 @@ class _TileLayerState extends State with TickerProviderStateMixin { viewingZoom: event.zoom, ); - if (event.load) { - if (!_outsideZoomLimits(tileZoom)) _loadTiles(visibleTileRange); + if (event.load && !_outsideZoomLimits(tileZoom)) { + _loadTiles(visibleTileRange, pruneAfterLoad: event.prune); } if (event.prune) { - _tileImageManager.evictErrorTiles( - visibleTileRange, widget.evictErrorTileStrategy); - _tileImageManager.prune(widget.evictErrorTileStrategy); + _tileImageManager.evictAndPrune( + visibleRange: visibleTileRange, + pruneBuffer: widget.panBuffer + widget.keepBuffer, + evictStrategy: widget.evictErrorTileStrategy, + ); } } @@ -559,11 +567,18 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileZoom: tileZoom, ); - if (!_outsideZoomLimits(tileZoom)) _loadTiles(visibleTileRange); + if (!_outsideZoomLimits(tileZoom)) { + _loadTiles( + visibleTileRange, + pruneAfterLoad: true, + ); + } - _tileImageManager.evictErrorTiles( - visibleTileRange, widget.evictErrorTileStrategy); - _tileImageManager.prune(widget.evictErrorTileStrategy); + _tileImageManager.evictAndPrune( + visibleRange: visibleTileRange, + pruneBuffer: math.max(widget.panBuffer, widget.keepBuffer), + evictStrategy: widget.evictErrorTileStrategy, + ); } // For all valid TileCoordinates in the [tileLoadRange], expanded by the @@ -577,23 +592,24 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Additionally, any current TileImages outside of the [tileLoadRange], // expanded by the [TileLayer.panBuffer] + [TileLayer.keepBuffer], are marked // as not current. - void _loadTiles(DiscreteTileRange tileLoadRange) { + void _loadTiles( + DiscreteTileRange tileLoadRange, { + required bool pruneAfterLoad, + }) { final tileZoom = tileLoadRange.zoom; tileLoadRange = tileLoadRange.expand(widget.panBuffer); - // Mark tiles outside of the tile load range as no longer current. - _tileImageManager.markAsNoLongerCurrentOutside( - tileZoom, - tileLoadRange.expand(widget.keepBuffer), - ); - // Build the queue of tiles to load. Marks all tiles with valid coordinates // in the tileLoadRange as current. final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); - final tilesToLoad = _tileImageManager.setCurrentAndReturnNotLoadedTiles( - tileBoundsAtZoom.validCoordinatesIn(tileLoadRange), - createTile: (coordinates) => - _createTileImage(coordinates, tileBoundsAtZoom)); + final tilesToLoad = _tileImageManager.createMissingTilesIn( + tileBoundsAtZoom.validCoordinatesIn(tileLoadRange), + createTile: (coordinates) => _createTileImage( + coordinates: coordinates, + tileBoundsAtZoom: tileBoundsAtZoom, + pruneAfterLoad: pruneAfterLoad, + ), + ); // Re-order the tiles by their distance to the center of the range. final tileCenter = tileLoadRange.center; @@ -629,26 +645,40 @@ class _TileLayerState extends State with TickerProviderStateMixin { widget.errorTileCallback?.call(tile, error, stackTrace); } - // This is called whether the tile loads successfully or with an error. - void _onTileLoadComplete(TileCoordinates coordinates) { + void _pruneIfAllTilesLoaded(TileCoordinates coordinates) { if (!_tileImageManager.containsTileAt(coordinates) || !_tileImageManager.allLoaded) { return; } widget.tileDisplay.when(instantaneous: (_) { - _tileImageManager.prune(widget.evictErrorTileStrategy); + _pruneWithCurrentCamera(); }, fadeIn: (fadeIn) { // Wait a bit more than tileFadeInDuration to trigger a pruning so that // we don't see tile removal under a fading tile. _pruneLater?.cancel(); _pruneLater = Timer( fadeIn.duration + const Duration(milliseconds: 50), - () => _tileImageManager.prune(widget.evictErrorTileStrategy), + () => _pruneWithCurrentCamera(), ); }); } + void _pruneWithCurrentCamera() { + final camera = MapCamera.of(context); + final visibleTileRange = _tileRangeCalculator.calculate( + camera: camera, + tileZoom: _clampToNativeZoom(camera.zoom), + center: camera.center, + viewingZoom: camera.zoom, + ); + _tileImageManager.prune( + visibleRange: visibleTileRange, + pruneBuffer: math.max(widget.panBuffer, widget.keepBuffer), + evictStrategy: widget.evictErrorTileStrategy, + ); + } + bool _outsideZoomLimits(num zoom) => zoom < widget.minZoom || zoom > widget.maxZoom; } diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 1e65364e2..f04aec09b 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -4,15 +4,21 @@ typedef TemplateFunction = String Function( String str, Map data); enum EvictErrorTileStrategy { - // never evict error Tiles + /// Never evict images for tiles which failed to load. none, - // evict error Tiles during _pruneTiles / _abortLoading calls + + /// Evict images for tiles which failed to load when they are pruned. dispose, - // evict error Tiles which are not visible anymore but respect margin (see keepBuffer option) - // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) + + /// Evict images for tiles which failed to load and: + /// - do not belong to the current zoom level AND/OR + /// - are not visible, respecting the pruning buffer (the maximum of the + /// [keepBuffer] and [panBuffer]. notVisibleRespectMargin, - // evict error Tiles which are not visible anymore - // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) + + /// Evict images for tiles which failed to load and: + /// - do not belong to the current zoom level AND/OR + /// - are not visible notVisible, } diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart new file mode 100644 index 000000000..43e72b855 --- /dev/null +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -0,0 +1,208 @@ +import 'dart:math'; + +import 'package:flutter/src/scheduler/ticker.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image_view.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; +import 'package:test/test.dart'; + +import '../../test_utils/test_tile_image.dart'; + +void main() { + Map tileImagesMappingFrom(List tileImages) => { + for (final tileImage in tileImages) tileImage.coordinates.key: tileImage + }; + + Matcher containsTileImage( + Map tileImages, + TileCoordinates coordinates, + ) => + contains(tileImages[coordinates.key]!); + + Matcher doesNotContainTileImage( + Map tileImages, + TileCoordinates coordinates, + ) => + isNot(containsTileImage(tileImages, coordinates)); + + DiscreteTileRange discreteTileRange( + int x1, + int y1, + int x2, + int y2, { + required int zoom, + }) => + DiscreteTileRange( + zoom, + Bounds(Point(x1, y1), Point(x2, y2)), + ); + + group('staleTiles', () { + test('tiles outside of the keep range are stale', () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(1, 1, 1), + MockTileImage(2, 1, 1), + ]); + + final removalState = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + keepRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + ); + expect( + removalState.staleTiles(), + containsTileImage(tileImages, const TileCoordinates(1, 1, 1)), + ); + }); + + test('ancestor tile is not stale if a tile has not loaded yet', () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(0, 0, 0), + MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + ]); + final removalState = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + ); + expect( + removalState.staleTiles(), + doesNotContainTileImage(tileImages, const TileCoordinates(0, 0, 0)), + ); + }); + + test('descendant tile is not stale if there is no loaded tile obscuring it', + () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(0, 0, 0, loadFinished: false, readyToDisplay: false), + MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + MockTileImage(0, 0, 2), + ]); + final removalState = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + ); + expect( + removalState.staleTiles(), + doesNotContainTileImage(tileImages, const TileCoordinates(0, 0, 2)), + ); + }); + + test( + 'returned elements can be removed from the source collection in a for loop', + () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(1, 1, 1), + ]); + + final removalState = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + keepRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + ); + expect( + removalState.staleTiles(), + containsTileImage(tileImages, const TileCoordinates(1, 1, 1)), + ); + // If an iterator over the original collection is returned then when + // looping over that iterator and removing from the original collection + // a concurrent modification exception is thrown. This ensures that the + // returned collection is not an iterable over the original collection. + for (final staleTile in removalState.staleTiles()) { + tileImages.remove(staleTile.coordinatesKey)!; + } + }); + }); + + test('errorTilesOutsideOfKeepMargin', () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(1, 1, 1, loadError: true), + MockTileImage(2, 1, 1), + MockTileImage(1, 2, 1), + MockTileImage(2, 2, 1, loadError: true), + ]); + final tileImageView = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(1, 2, 1, 2, zoom: 1), + keepRange: discreteTileRange(1, 2, 2, 2, zoom: 1), + ); + expect( + tileImageView.errorTilesOutsideOfKeepMargin().map((e) => e.coordinates), + [const TileCoordinates(1, 1, 1)], + ); + + // If an iterator over the original collection is returned then when + // looping over that iterator and removing from the original collection + // a concurrent modification exception is thrown. This ensures that the + // returned collection is not an iterable over the original collection. + for (final tileImage in tileImageView.errorTilesOutsideOfKeepMargin()) { + tileImages.remove(tileImage.coordinatesKey)!; + } + }); + + test('errorTilesNotVisible', () { + final tileImages = tileImagesMappingFrom([ + MockTileImage(1, 1, 1, loadError: true), + MockTileImage(2, 1, 1), + MockTileImage(1, 2, 1), + MockTileImage(2, 2, 1, loadError: true), + ]); + final tileImageView = TileImageView( + tileImages: tileImages, + visibleRange: discreteTileRange(1, 2, 1, 2, zoom: 1), + keepRange: discreteTileRange(1, 2, 2, 2, zoom: 1), + ); + expect( + tileImageView.errorTilesNotVisible().map((e) => e.coordinates), + [const TileCoordinates(1, 1, 1), const TileCoordinates(2, 2, 1)], + ); + + // If an iterator over the original collection is returned then when + // looping over that iterator and removing from the original collection + // a concurrent modification exception is thrown. This ensures that the + // returned collection is not an iterable over the original collection. + for (final tileImage in tileImageView.errorTilesOutsideOfKeepMargin()) { + tileImages.remove(tileImage.coordinatesKey)!; + } + }); +} + +class MockTileImage extends TileImage { + @override + final bool readyToDisplay; + + MockTileImage( + int x, + int y, + int zoom, { + this.readyToDisplay = true, + bool loadFinished = true, + bool loadError = false, + void Function(TileCoordinates coordinates)? onLoadComplete, + void Function(TileImage tile, Object error, StackTrace? stackTrace)? + onLoadError, + TileDisplay? tileDisplay, + super.errorImage, + }) : super( + coordinates: TileCoordinates(x, y, zoom), + vsync: const MockTickerProvider(), + imageProvider: testWhiteTileImage, + onLoadComplete: onLoadComplete ?? (_) {}, + onLoadError: onLoadError ?? (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + ) { + loadFinishedAt = loadFinished ? DateTime.now() : null; + this.loadError = loadError; + } +} + +class MockTickerProvider extends TickerProvider { + const MockTickerProvider(); + + @override + Ticker createTicker(TickerCallback onTick) { + return Ticker((elapsed) {}); + } +} diff --git a/test/test_utils/test_app.dart b/test/test_utils/test_app.dart index ca0fc6684..9866d2add 100644 --- a/test/test_utils/test_app.dart +++ b/test/test_utils/test_app.dart @@ -1,18 +1,16 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter_map/src/layer/circle_layer.dart'; import 'package:flutter_map/src/layer/marker_layer.dart'; import 'package:flutter_map/src/layer/polygon_layer.dart'; import 'package:flutter_map/src/layer/polyline_layer.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/map/map_controller.dart'; import 'package:flutter_map/src/map/options.dart'; import 'package:flutter_map/src/map/widget.dart'; import 'package:latlong2/latlong.dart'; +import 'test_tile_provider.dart'; + class TestApp extends StatelessWidget { const TestApp({ super.key, @@ -61,14 +59,3 @@ class TestApp extends StatelessWidget { ); } } - -class TestTileProvider extends TileProvider { - // Base 64 encoded 256x256 white tile. - static const _whiteTile = - 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRF////p8QbyAAAAB9JREFUeJztwQENAAAAwqD3T20ON6AAAAAAAAAAAL4NIQAAAfFnIe4AAAAASUVORK5CYII='; - - @override - ImageProvider getImage( - TileCoordinates coordinates, TileLayer options) => - MemoryImage(base64Decode(_whiteTile)); -} diff --git a/test/test_utils/test_tile_image.dart b/test/test_utils/test_tile_image.dart new file mode 100644 index 000000000..086bee067 --- /dev/null +++ b/test/test_utils/test_tile_image.dart @@ -0,0 +1,8 @@ +import 'dart:convert'; + +import 'package:flutter/painting.dart'; + +// Base 64 encoded 256x256 white tile. +const _whiteTile = + 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRF////p8QbyAAAAB9JREFUeJztwQENAAAAwqD3T20ON6AAAAAAAAAAAL4NIQAAAfFnIe4AAAAASUVORK5CYII='; +final testWhiteTileImage = MemoryImage(base64Decode(_whiteTile)); diff --git a/test/test_utils/test_tile_provider.dart b/test/test_utils/test_tile_provider.dart new file mode 100644 index 000000000..d51f3d285 --- /dev/null +++ b/test/test_utils/test_tile_provider.dart @@ -0,0 +1,13 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; + +import 'test_tile_image.dart'; + +class TestTileProvider extends TileProvider { + @override + ImageProvider getImage( + TileCoordinates coordinates, TileLayer options) => + testWhiteTileImage; +}