From 2a041306243ccbc2b015a12684089ba6632866e1 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Mon, 20 Mar 2023 16:19:21 +0100 Subject: [PATCH 01/51] Remove broken updateInterval option In passing I have also reduced the amount of calls to _setView() during build. --- lib/src/layer/tile_layer/tile_layer.dart | 54 ++++-------------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 07bb4fddc..9aa8787a8 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -165,13 +165,6 @@ class TileLayer extends StatefulWidget { /// ``` final Map additionalOptions; - /// Tiles will not update more than once every `updateInterval` (default 200 - /// milliseconds) when panning. It can be null (but it will calculating for - /// loading tiles every frame when panning / zooming, flutter is fast) This - /// can save some fps and even bandwidth (ie. when fast panning / animating - /// between long distances in short time) - final Duration? updateInterval; - /// Tiles fade in duration in milliseconds (default 100). This can be null to /// avoid fade in. final Duration? tileFadeInDuration; @@ -267,13 +260,6 @@ class TileLayer extends StatefulWidget { this.tms = false, this.wmsOptions, this.opacity = 1.0, - - /// Tiles will not update more than once every `updateInterval` milliseconds - /// (default 200) when panning. It can be 0 (but it will calculating for - /// loading tiles every frame when panning / zooming, flutter is fast) This - /// can save some fps and even bandwidth (ie. when fast panning / animating - /// between long distances in short time) - Duration updateInterval = const Duration(milliseconds: 200), Duration tileFadeInDuration = const Duration(milliseconds: 100), this.tileFadeInStart = 0.0, this.tileFadeInStartWhenOverride = 0.0, @@ -288,9 +274,7 @@ class TileLayer extends StatefulWidget { this.reset, this.tileBounds, String userAgentPackageName = 'unknown', - }) : updateInterval = - updateInterval <= Duration.zero ? null : updateInterval, - tileFadeInDuration = + }) : tileFadeInDuration = tileFadeInDuration <= Duration.zero ? null : tileFadeInDuration, assert(tileFadeInStart >= 0.0 && tileFadeInStart <= 1.0), assert(tileFadeInStartWhenOverride >= 0.0 && @@ -334,7 +318,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { double? _tileZoom; StreamSubscription? _resetSub; - StreamController? _throttleUpdate; late CustomPoint _tileSize; late final TileManager _tileManager; @@ -370,10 +353,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { reloadTiles |= !_tileManager.allWithinZoom(widget.minZoom, widget.maxZoom); - if (oldWidget.updateInterval != widget.updateInterval) { - _throttleUpdate?.close(); - } - if (!reloadTiles) { final oldUrl = oldWidget.wmsOptions?._encodedBaseUrl ?? oldWidget.urlTemplate; @@ -404,7 +383,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { _resetSub?.cancel(); _pruneLater?.cancel(); widget.tileProvider.dispose(); - _throttleUpdate?.close(); super.dispose(); } @@ -413,27 +391,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { final map = FlutterMapState.maybeOf(context)!; - //Handle movement final tileZoom = _clampZoom(map.zoom.roundToDouble()); - - if (_tileZoom == null) { + if (_tileZoom == null && + tileZoom <= widget.maxZoom && + tileZoom >= widget.minZoom) { // if there is no _tileZoom available it means we are out within zoom level // we will restore fully via _setView call if we are back on trail - if ((tileZoom <= widget.maxZoom) && (tileZoom >= widget.minZoom)) { - _tileZoom = tileZoom; - _setView(map, map.center, tileZoom); - } - } else { - if ((tileZoom - _tileZoom!).abs() >= 1) { - // It was a zoom lvl change - _setView(map, map.center, tileZoom); - } else { - if (_throttleUpdate != null) { - _throttleUpdate!.add(null); - } - } + _tileZoom = tileZoom; } - _setView(map, map.center, map.zoom); final tilesToRender = _tileZoom == null @@ -537,7 +502,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { void _resetGrid(FlutterMapState map) { final crs = map.options.crs; - final tileSize = getTileSize(); + final tileSize = _tileSize; final tileZoom = _tileZoom; final bounds = map.getPixelWorldBounds(_tileZoom); @@ -769,14 +734,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { CustomPoint _getTilePos(FlutterMapState map, Coords coords) { final level = _transformationCalculator.getOrCreateLevel(coords.z as double, map); - return coords.scaleBy(getTileSize()) - level.origin; + return coords.scaleBy(_tileSize) - level.origin; } Bounds _pxBoundsToTileRange(Bounds bounds) { - final tileSize = getTileSize(); return Bounds( - bounds.min.unscaleBy(tileSize).floor(), - bounds.max.unscaleBy(tileSize).ceil() - const CustomPoint(1, 1), + bounds.min.unscaleBy(_tileSize).floor(), + bounds.max.unscaleBy(_tileSize).ceil() - const CustomPoint(1, 1), ); } } From f7ad9b1632874023765b678ae9a4be80fb930098 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 21 Mar 2023 09:19:02 +0100 Subject: [PATCH 02/51] Remove Level since it had become a wrapper for CustomPoint It was also storing zoom but the same zoom value was used to look up the Level in TransformationCalculator. --- lib/src/layer/tile_layer/level.dart | 11 ------- lib/src/layer/tile_layer/tile_layer.dart | 11 ++++--- lib/src/layer/tile_layer/tile_manager.dart | 5 +++- .../tile_layer/transformation_calculator.dart | 29 +++++++++---------- 4 files changed, 23 insertions(+), 33 deletions(-) delete mode 100644 lib/src/layer/tile_layer/level.dart diff --git a/lib/src/layer/tile_layer/level.dart b/lib/src/layer/tile_layer/level.dart deleted file mode 100644 index 6906ecc73..000000000 --- a/lib/src/layer/tile_layer/level.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter_map/flutter_map.dart'; - -class Level { - final CustomPoint origin; - final double zoom; - - Level({ - required this.origin, - required this.zoom, - }); -} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 9aa8787a8..819fece5a 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/bounds.dart'; import 'package:flutter_map/src/core/util.dart' as util; -import 'package:flutter_map/src/layer/tile_layer/level.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; @@ -447,7 +446,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { CustomPoint getTileSize() => _tileSize; - Level? _updateLevels(FlutterMapState map) { + void _updateLevels(FlutterMapState map) { final zoom = _tileZoom; if (zoom == null) return null; @@ -460,7 +459,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { _transformationCalculator.removeLevel(z); } - return _transformationCalculator.getOrCreateLevel(zoom, map); + _transformationCalculator.getOrCalculateOriginAt(zoom, map); } ///removes all loaded tiles and resets the view @@ -732,9 +731,9 @@ class _TileLayerState extends State with TickerProviderStateMixin { } CustomPoint _getTilePos(FlutterMapState map, Coords coords) { - final level = - _transformationCalculator.getOrCreateLevel(coords.z as double, map); - return coords.scaleBy(_tileSize) - level.origin; + final origin = _transformationCalculator.getOrCalculateOriginAt( + coords.z as double, map); + return coords.scaleBy(_tileSize) - origin; } Bounds _pxBoundsToTileRange(Bounds bounds) { diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart index 65606b9b9..d3f66a31c 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -1,5 +1,8 @@ -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/coords.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:tuple/tuple.dart'; class TileManager { diff --git a/lib/src/layer/tile_layer/transformation_calculator.dart b/lib/src/layer/tile_layer/transformation_calculator.dart index afab98b5b..105c3c7f8 100644 --- a/lib/src/layer/tile_layer/transformation_calculator.dart +++ b/lib/src/layer/tile_layer/transformation_calculator.dart @@ -1,25 +1,24 @@ -import 'package:flutter_map/src/layer/tile_layer/level.dart'; +import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; class TransformationCalculator { - final Map _levels = {}; + final Map _zoomToPixelOrigin = {}; - Level? levelAt(double zoom) => _levels[zoom]; - - Level getOrCreateLevel(double zoom, FlutterMapState map) { - final level = _levels[zoom]; + CustomPoint getOrCalculateOriginAt(double zoom, FlutterMapState map) { + final level = _zoomToPixelOrigin[zoom]; if (level != null) return level; - return _levels[zoom] = Level( - origin: map.project(map.unproject(map.pixelOrigin), zoom), - zoom: zoom, + final result = _zoomToPixelOrigin[zoom] = map.project( + map.unproject(map.pixelOrigin), + zoom, ); + return result; } List whereLevel(bool Function(double level) test) { final result = []; - for (final levelZoom in _levels.keys) { + for (final levelZoom in _zoomToPixelOrigin.keys) { if (test(levelZoom)) result.add(levelZoom); } @@ -27,14 +26,14 @@ class TransformationCalculator { } void removeLevel(double levelZoom) { - _levels.remove(levelZoom); + _zoomToPixelOrigin.remove(levelZoom); } - TileTransformation transformationFor(double levelZoom, FlutterMapState map) { - final level = _levels[levelZoom]!; - final scale = map.getZoomScale(map.zoom, level.zoom); + TileTransformation transformationFor(double zoom, FlutterMapState map) { + final origin = _zoomToPixelOrigin[zoom]!; + final scale = map.getZoomScale(map.zoom, zoom); final pixelOrigin = map.getNewPixelOrigin(map.center, map.zoom).round(); - final translate = level.origin.multiplyBy(scale) - pixelOrigin; + final translate = origin.multiplyBy(scale) - pixelOrigin; return TileTransformation(scale: scale, translate: translate); } } From 794a80011b71104ecb8539389cac2c0990fc867c Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 21 Mar 2023 18:33:20 +0100 Subject: [PATCH 03/51] Tile layer cleanup and simplification Simplified tile layer calculations. Tile layer is still very much a WIP. --- lib/src/layer/tile_layer/tile.dart | 5 + lib/src/layer/tile_layer/tile_layer.dart | 209 +++++++++--------- .../layer/tile_layer/tile_transformation.dart | 33 ++- lib/src/layer/tile_layer/tile_widget.dart | 45 ++-- .../tile_layer/transformation_calculator.dart | 39 ---- 5 files changed, 150 insertions(+), 181 deletions(-) delete mode 100644 lib/src/layer/tile_layer/transformation_calculator.dart diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 34cf299a4..9e064182c 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -5,8 +5,13 @@ typedef TileReady = void Function( Coords coords, dynamic error, Tile tile); class Tile { + /// The z of the coords is the tile's zoom level whilst the x and y indicate + /// the coordinate position of the tile at that zoom level. final Coords coords; + + /// The pixel position of this tile on the map at its zoom level. final CustomPoint tilePos; + ImageProvider imageProvider; bool current; diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 819fece5a..39b68a4ca 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -10,7 +10,6 @@ import 'package:flutter_map/src/core/util.dart' as util; import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; -import 'package:flutter_map/src/layer/tile_layer/transformation_calculator.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; import 'package:latlong2/latlong.dart'; import 'package:tuple/tuple.dart'; @@ -316,11 +315,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { Tuple2? _wrapY; double? _tileZoom; + StreamSubscription? _movementSubscription; StreamSubscription? _resetSub; late CustomPoint _tileSize; late final TileManager _tileManager; - late final TransformationCalculator _transformationCalculator; Timer? _pruneLater; @@ -328,11 +327,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { void initState() { super.initState(); _tileManager = TileManager(); - _transformationCalculator = TransformationCalculator(); _tileSize = CustomPoint(widget.tileSize, widget.tileSize); if (widget.reset != null) { - _resetSub = widget.reset?.listen((_) => _resetTiles()); + _resetSub = widget.reset?.listen( + (_) => _tileManager.removeAll( + widget.evictErrorTileStrategy, + ), + ); } } @@ -376,8 +378,20 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _movementSubscription?.cancel(); + final mapState = FlutterMapState.maybeOf(context)!; + + _movementSubscription = mapState.mapController.mapEventStream.listen( + (mapEvent) => _onMove(mapState, mapEvent), + ); + } + @override void dispose() { + _movementSubscription?.cancel(); _tileManager.removeAll(widget.evictErrorTileStrategy); _resetSub?.cancel(); _pruneLater?.cancel(); @@ -386,125 +400,101 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.dispose(); } + void _onMove(FlutterMapState mapState, MapEvent mapEvent) { + _tileZoom = _clampToNativeZoom(mapState.zoom.roundToDouble()); + if ((_tileZoom! > widget.maxZoom) || (_tileZoom! < widget.minZoom)) { + _tileZoom = null; + } + + _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); + + _resetGrid(mapState, _tileZoom); + + if (_tileZoom != null) _update(mapState, _tileZoom!); + + _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); + } + @override Widget build(BuildContext context) { final map = FlutterMapState.maybeOf(context)!; - - final tileZoom = _clampZoom(map.zoom.roundToDouble()); - if (_tileZoom == null && - tileZoom <= widget.maxZoom && - tileZoom >= widget.minZoom) { - // if there is no _tileZoom available it means we are out within zoom level - // we will restore fully via _setView call if we are back on trail - _tileZoom = tileZoom; + final mapZoom = map.zoom.roundToDouble(); + if (mapZoom < widget.minZoom || mapZoom > widget.maxZoom) { + return const SizedBox.shrink(); } - _setView(map, map.center, map.zoom); - final tilesToRender = _tileZoom == null - ? _tileManager.all() - : _tileManager.sortedByDistanceToZoomAscending( - widget.maxZoom, _tileZoom!); + final tileZoom = _clampToNativeZoom(map.zoom.roundToDouble()); + final tilesToRender = _tileManager.sortedByDistanceToZoomAscending( + widget.maxZoom, + tileZoom, + ); + final Map zoomToTransformation = {}; final tileWidgets = [ for (var tile in tilesToRender) AnimatedTile( + // TODO Not animated + key: ValueKey(tile.coordsKey), tile: tile, size: _tileSize, tileTransformation: zoomToTransformation[tile.coords.z] ?? (zoomToTransformation[tile.coords.z] = - _transformationCalculator.transformationFor( - tile.coords.z, - map, + TileTransformation.calculate( + map: map, + tileZoom: tile.coords.z, + tileSize: _tileSize, )), - errorImage: widget.errorImage, - tileBuilder: widget.tileBuilder, - key: ValueKey(tile.coordsKey), ) ]; - final tilesContainer = Stack( - children: tileWidgets, - ); - - final tilesLayer = widget.tilesContainerBuilder == null - ? tilesContainer - : widget.tilesContainerBuilder!( - context, - tilesContainer, - tilesToRender, - ); + for (final entry in zoomToTransformation.entries) { + debugPrint( + '${entry.key}: ${entry.value.scaledTileSize}, ${entry.value.transformation}'); + } return Opacity( opacity: widget.opacity, child: Container( color: widget.backgroundColor, - child: tilesLayer, + child: Stack( + children: tileWidgets, + ), ), ); } CustomPoint getTileSize() => _tileSize; - void _updateLevels(FlutterMapState map) { - final zoom = _tileZoom; + double _clampToNativeZoom(double zoom) => zoom.clamp( + widget.minNativeZoom ?? -double.infinity, + widget.maxNativeZoom ?? double.infinity); - if (zoom == null) return null; + //void _setView(FlutterMapState map, LatLng center, double zoom) { + // double? tileZoom = _clampToNativeZoom(zoom.roundToDouble()); + // if ((tileZoom > widget.maxZoom) || (tileZoom < widget.minZoom)) { + // tileZoom = null; + // } - final toRemove = _transformationCalculator.whereLevel((levelZoom) => - levelZoom != zoom && !_tileManager.anyWithZoomLevel(levelZoom)); + // _tileZoom = tileZoom; - for (final z in toRemove) { - _tileManager.removeAtZoom(z, widget.evictErrorTileStrategy); - _transformationCalculator.removeLevel(z); - } + // _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); - _transformationCalculator.getOrCalculateOriginAt(zoom, map); - } + // _updateLevels(map); + // _resetGrid(map); - ///removes all loaded tiles and resets the view - void _resetTiles() { - _tileManager.removeAll(widget.evictErrorTileStrategy); - } + // if (_tileZoom != null) { + // _update(map, center); + // } - double _clampZoom(double zoom) { - if (null != widget.minNativeZoom && zoom < widget.minNativeZoom!) { - return widget.minNativeZoom!; - } + // _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); + //} - if (null != widget.maxNativeZoom && widget.maxNativeZoom! < zoom) { - return widget.maxNativeZoom!; - } - - return zoom; - } - - void _setView(FlutterMapState map, LatLng center, double zoom) { - double? tileZoom = _clampZoom(zoom.roundToDouble()); - if ((tileZoom > widget.maxZoom) || (tileZoom < widget.minZoom)) { - tileZoom = null; - } - - _tileZoom = tileZoom; - - _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); - - _updateLevels(map); - _resetGrid(map); - - if (_tileZoom != null) { - _update(map, center); - } - - _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); - } - - void _resetGrid(FlutterMapState map) { + void _resetGrid(FlutterMapState map, double? tileZoom) { final crs = map.options.crs; final tileSize = _tileSize; - final tileZoom = _tileZoom; - final bounds = map.getPixelWorldBounds(_tileZoom); + final bounds = map.getPixelWorldBounds(tileZoom); if (bounds != null) { _globalTileRange = _pxBoundsToTileRange(bounds); } @@ -533,25 +523,12 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } - Bounds _getTiledPixelBounds(FlutterMapState map, LatLng center) { - final scale = map.getZoomScale(map.zoom, _tileZoom); - final pixelCenter = map.project(center, _tileZoom).floor(); - final halfSize = map.size / (scale * 2); - - return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); - } - // Private method to load tiles in the grid's active zoom level according to // map bounds - void _update(FlutterMapState map, LatLng? center) { - if (_tileZoom == null) { - return; - } + void _update(FlutterMapState map, double tileZoom) { + final zoom = _clampToNativeZoom(map.zoom); - final zoom = _clampZoom(map.zoom); - center ??= map.center; - - final pixelBounds = _getTiledPixelBounds(map, center); + final pixelBounds = _getTiledPixelBounds(map, tileZoom); Bounds tileRange = _pxBoundsToTileRange(pixelBounds); final panBuffer = widget.panBuffer; @@ -575,12 +552,12 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileRange.topRight + CustomPoint(margin, -margin), ); - _tileManager.markToPrune(_tileZoom, noPruneRange); + _tileManager.markToPrune(tileZoom, noPruneRange); // _update just loads more tiles. If the tile zoom level differs too much // from the map's, let _setView reset levels and prune old tiles. - if ((zoom - _tileZoom!).abs() > 1) { - _setView(map, center, zoom); + if ((zoom - tileZoom).abs() > 1) { + // TODO: _setView(map, center, zoom); return; } @@ -588,11 +565,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { for (var j = tileRange.min.y; j <= tileRange.max.y; j++) { for (var i = tileRange.min.x; i <= tileRange.max.x; i++) { final coords = Coords(i.toDouble(), j.toDouble()); - coords.z = _tileZoom!; + coords.z = tileZoom; if (widget.tileBounds != null) { final tilePxBounds = _pxBoundsToTileRange( - _latLngBoundsToPixelBounds(map, widget.tileBounds!, _tileZoom!)); + _latLngBoundsToPixelBounds(map, widget.tileBounds!, tileZoom)); if (!_areCoordsInsideTileBounds(coords, tilePxBounds)) { continue; } @@ -620,8 +597,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { coords: coords, tilePos: _getTilePos(map, coords), current: true, - imageProvider: - widget.tileProvider.getImage(coords.wrap(_wrapX, _wrapY), widget), + imageProvider: widget.tileProvider.getImage( + coords.wrap(_wrapX, _wrapY), + widget, + ), tileReady: _tileReady, vsync: this, duration: widget.tileFadeInDuration, @@ -635,6 +614,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } + Bounds _getTiledPixelBounds(FlutterMapState map, double tileZoom) { + final scale = map.getZoomScale(map.zoom, tileZoom); + final pixelCenter = map.project(map.center, tileZoom).floor(); + final halfSize = map.size / (scale * 2); + + return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); + } + bool _isValidTile(Crs crs, Coords coords) { if (!crs.infinite) { // don't load tile if it's out of bounds and not wrapped @@ -731,9 +718,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { } CustomPoint _getTilePos(FlutterMapState map, Coords coords) { - final origin = _transformationCalculator.getOrCalculateOriginAt( - coords.z as double, map); - return coords.scaleBy(_tileSize) - origin; + return coords.scaleBy(_tileSize); + + //final origin = _transformationCalculator.getOrCalculateOriginAt( + // coords.z as double, map); + //return coords.scaleBy(_tileSize) - origin; } Bounds _pxBoundsToTileRange(Bounds bounds) { diff --git a/lib/src/layer/tile_layer/tile_transformation.dart b/lib/src/layer/tile_layer/tile_transformation.dart index 8cb3fe217..fd4128827 100644 --- a/lib/src/layer/tile_layer/tile_transformation.dart +++ b/lib/src/layer/tile_layer/tile_transformation.dart @@ -1,13 +1,36 @@ -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/map/flutter_map_state.dart'; import 'package:meta/meta.dart'; +import 'package:vector_math/vector_math_64.dart'; @immutable class TileTransformation { - final double scale; - final CustomPoint translate; + final CustomPoint scaledTileSize; + final Matrix3 transformation; const TileTransformation({ - required this.scale, - required this.translate, + required this.scaledTileSize, + required this.transformation, }); + + factory TileTransformation.calculate({ + required FlutterMapState map, + required double tileZoom, + required CustomPoint tileSize, + }) { + final translate = map.project( + map.unproject(map.pixelOrigin), + tileZoom, + ); + final scale = map.getZoomScale(map.zoom, tileZoom); + + return TileTransformation( + scaledTileSize: tileSize * scale, + transformation: Matrix3( + 1, 0, 0, // + 0, 1, 0, // + -translate.x as double, -translate.y as double, 1, // + )..scale(scale), + ); + } } diff --git a/lib/src/layer/tile_layer/tile_widget.dart b/lib/src/layer/tile_layer/tile_widget.dart index 282040a4b..a712b3649 100644 --- a/lib/src/layer/tile_layer/tile_widget.dart +++ b/lib/src/layer/tile_layer/tile_widget.dart @@ -1,51 +1,42 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; +import 'package:vector_math/vector_math_64.dart'; class AnimatedTile extends StatelessWidget { + static final Vector3 _tileVectorStorage = Vector3.all(1); + static final Vector3 _transformedTileVectorStorage = Vector3.zero(); + final Tile tile; final CustomPoint size; final TileTransformation tileTransformation; - final ImageProvider? errorImage; - final TileBuilder? tileBuilder; const AnimatedTile({ required this.tile, required this.size, required this.tileTransformation, - required this.errorImage, - required this.tileBuilder, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { - final pos = tile.tilePos.multiplyBy(tileTransformation.scale) + - tileTransformation.translate; - final num width = size.x * tileTransformation.scale; - final num height = size.y * tileTransformation.scale; + _tileVectorStorage.x = tile.tilePos.x.toDouble(); + _tileVectorStorage.y = tile.tilePos.y.toDouble(); - Widget tileWidget; - if (tile.loadError && errorImage != null) { - tileWidget = Image(image: errorImage!); - } else if (tile.animationController == null) { - tileWidget = RawImage(image: tile.imageInfo?.image, fit: BoxFit.fill); - } else { - tileWidget = AnimatedBuilder( - animation: tile.animationController!, - builder: (context, child) => RawImage( - image: tile.imageInfo?.image, - fit: BoxFit.fill, - opacity: tile.animationController!), - ); - } + final transformedTilePos = tileTransformation.transformation.transformed( + _tileVectorStorage, + _transformedTileVectorStorage, + ); return Positioned( - left: pos.x.toDouble(), - top: pos.y.toDouble(), - width: width.toDouble(), - height: height.toDouble(), - child: tileBuilder?.call(context, tileWidget, tile) ?? tileWidget, + left: transformedTilePos.x, + top: transformedTilePos.y, + width: tileTransformation.scaledTileSize.x.toDouble(), + height: tileTransformation.scaledTileSize.y.toDouble(), + child: RawImage( + image: tile.imageInfo?.image, + fit: BoxFit.fill, + ), ); } } diff --git a/lib/src/layer/tile_layer/transformation_calculator.dart b/lib/src/layer/tile_layer/transformation_calculator.dart deleted file mode 100644 index 105c3c7f8..000000000 --- a/lib/src/layer/tile_layer/transformation_calculator.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; -import 'package:flutter_map/src/map/flutter_map_state.dart'; - -class TransformationCalculator { - final Map _zoomToPixelOrigin = {}; - - CustomPoint getOrCalculateOriginAt(double zoom, FlutterMapState map) { - final level = _zoomToPixelOrigin[zoom]; - if (level != null) return level; - - final result = _zoomToPixelOrigin[zoom] = map.project( - map.unproject(map.pixelOrigin), - zoom, - ); - return result; - } - - List whereLevel(bool Function(double level) test) { - final result = []; - for (final levelZoom in _zoomToPixelOrigin.keys) { - if (test(levelZoom)) result.add(levelZoom); - } - - return result; - } - - void removeLevel(double levelZoom) { - _zoomToPixelOrigin.remove(levelZoom); - } - - TileTransformation transformationFor(double zoom, FlutterMapState map) { - final origin = _zoomToPixelOrigin[zoom]!; - final scale = map.getZoomScale(map.zoom, zoom); - final pixelOrigin = map.getNewPixelOrigin(map.center, map.zoom).round(); - final translate = origin.multiplyBy(scale) - pixelOrigin; - return TileTransformation(scale: scale, translate: translate); - } -} From 306ab798fafadf37ebe1cfd0335e5c2a5c53e3c4 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Thu, 23 Mar 2023 20:09:35 +0100 Subject: [PATCH 04/51] TileRange and TileBounds abstractions implemented Still working on adding tests. --- example/lib/pages/tile_builder_example.dart | 2 +- lib/flutter_map.dart | 9 +- lib/src/core/bounds.dart | 23 +- lib/src/core/point.dart | 2 + lib/src/core/util.dart | 9 - lib/src/layer/tile_layer/coords.dart | 35 -- lib/src/layer/tile_layer/tile.dart | 26 +- lib/src/layer/tile_layer/tile_bounds.dart | 255 +++++++++++++ lib/src/layer/tile_layer/tile_builder.dart | 2 +- lib/src/layer/tile_layer/tile_coordinate.dart | 33 ++ lib/src/layer/tile_layer/tile_layer.dart | 334 ++++++------------ .../layer/tile_layer/tile_layer_options.dart | 2 +- lib/src/layer/tile_layer/tile_manager.dart | 95 +++-- .../tile_provider/asset_tile_provider.dart | 4 +- .../tile_provider/base_tile_provider.dart | 31 +- .../tile_provider/file_tile_provider_io.dart | 6 +- .../tile_provider/file_tile_provider_web.dart | 6 +- .../tile_provider/tile_provider_io.dart | 19 +- .../tile_provider/tile_provider_web.dart | 17 +- lib/src/layer/tile_layer/tile_range.dart | 81 +++++ .../layer/tile_layer/tile_transformation.dart | 7 +- test/layer/tile_layer/tile_range_test.dart | 287 +++++++++++++++ 22 files changed, 912 insertions(+), 373 deletions(-) delete mode 100644 lib/src/layer/tile_layer/coords.dart create mode 100644 lib/src/layer/tile_layer/tile_bounds.dart create mode 100644 lib/src/layer/tile_layer/tile_coordinate.dart create mode 100644 lib/src/layer/tile_layer/tile_range.dart create mode 100644 test/layer/tile_layer/tile_range_test.dart diff --git a/example/lib/pages/tile_builder_example.dart b/example/lib/pages/tile_builder_example.dart index 16a52e04d..2a0818054 100644 --- a/example/lib/pages/tile_builder_example.dart +++ b/example/lib/pages/tile_builder_example.dart @@ -21,7 +21,7 @@ class _TileBuilderPageState extends State { // mix of [coordinateDebugTileBuilder] and [loadingTimeDebugTileBuilder] from tile_builder.dart Widget tileBuilder(BuildContext context, Widget tileWidget, Tile tile) { - final coords = tile.coords; + final coords = tile.coordinate; return Container( decoration: BoxDecoration( diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 4d4885903..7dbea78b7 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -4,11 +4,9 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/core/positioned_tap_detector_2.dart'; -import 'package:latlong2/latlong.dart'; - import 'package:flutter_map/src/core/center_zoom.dart'; import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/core/positioned_tap_detector_2.dart'; import 'package:flutter_map/src/geo/crs/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/gestures/interactive_flag.dart'; @@ -16,6 +14,7 @@ import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/gestures/multi_finger_gesture.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; import 'package:flutter_map/src/map/map.dart'; +import 'package:latlong2/latlong.dart'; export 'package:flutter_map/src/core/center_zoom.dart'; export 'package:flutter_map/src/core/point.dart'; @@ -31,12 +30,12 @@ export 'package:flutter_map/src/layer/marker_layer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; -export 'package:flutter_map/src/layer/tile_layer/coords.dart'; export 'package:flutter_map/src/layer/tile_layer/tile.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart' if (dart.library.html) 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_io.dart' diff --git a/lib/src/core/bounds.dart b/lib/src/core/bounds.dart index f85dc67b6..26590ded4 100644 --- a/lib/src/core/bounds.dart +++ b/lib/src/core/bounds.dart @@ -54,13 +54,16 @@ class Bounds { return max - min; } - bool contains(CustomPoint point) { + // The area of these bounds + num get area => (max.x - min.x) * (max.y - min.y); + + bool contains(CustomPoint point) { final min = point; final max = point; return containsBounds(Bounds(min, max)); } - bool containsBounds(Bounds b) { + bool containsBounds(Bounds b) { return (b.min.x >= min.x) && (b.max.x <= max.x) && (b.min.y >= min.y) && @@ -74,6 +77,22 @@ class Bounds { (b.max.y >= min.y); } + // Calculates the intersection of two Bounds. The return value will be null + // if there is no intersection. The returned bounds may be zero size + // (bottomLeft == topRight). + Bounds? intersect(Bounds b) { + final leftX = math.max(min.x, b.min.x); + final rightX = math.min(max.x, b.max.x); + final topY = math.max(min.y, b.min.y); + final bottomY = math.min(max.y, b.max.y); + + if (leftX <= rightX && topY <= bottomY) { + return Bounds(CustomPoint(leftX, topY), CustomPoint(rightX, bottomY)); + } + + return null; + } + @override String toString() => 'Bounds($min, $max)'; } diff --git a/lib/src/core/point.dart b/lib/src/core/point.dart index 824d1525c..32823ba44 100644 --- a/lib/src/core/point.dart +++ b/lib/src/core/point.dart @@ -79,6 +79,8 @@ class CustomPoint extends math.Point { return this; } + CustomPoint cast() => CustomPoint(x as U, y as U); + @override String toString() => 'CustomPoint ($x, $y)'; } diff --git a/lib/src/core/util.dart b/lib/src/core/util.dart index 9a4824ed9..fa030c815 100644 --- a/lib/src/core/util.dart +++ b/lib/src/core/util.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:tuple/tuple.dart'; - var _templateRe = RegExp(r'\{ *([\w_-]+) *\}'); /// Replaces the templating placeholders with the provided data map. @@ -24,13 +22,6 @@ String template(String str, Map data) { }); } -double wrapNum(double x, Tuple2 range, [bool? includeMax]) { - final max = range.item2; - final min = range.item1; - final d = max - min; - return x == max && includeMax != null ? x : ((x - min) % d + d) % d + min; -} - StreamTransformer throttleStreamTransformerWithTrailingCall( Duration duration) { Timer? timer; diff --git a/lib/src/layer/tile_layer/coords.dart b/lib/src/layer/tile_layer/coords.dart deleted file mode 100644 index e91903622..000000000 --- a/lib/src/layer/tile_layer/coords.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/core/util.dart' as util; -import 'package:tuple/tuple.dart'; - -class Coords extends CustomPoint { - late T z; - - Coords(T x, T y) : super(x, y); - - Coords wrap( - Tuple2? wrapX, Tuple2? wrapY) { - final newCoords = Coords( - wrapX != null ? util.wrapNum(x.toDouble(), wrapX) : x.toDouble(), - wrapY != null ? util.wrapNum(y.toDouble(), wrapY) : y.toDouble(), - ); - newCoords.z = z.toDouble(); - return newCoords; - } - - String get key => '$x:$y:$z'; - - @override - String toString() => 'Coords($x, $y, $z)'; - - @override - bool operator ==(Object other) { - if (other is Coords) { - return x == other.x && y == other.y && z == other.z; - } - return false; - } - - @override - int get hashCode => Object.hash(x.hashCode, y.hashCode, z.hashCode); -} diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 9e064182c..459fbf7d5 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,19 +1,21 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; typedef TileReady = void Function( - Coords coords, dynamic error, Tile tile); + TileCoordinate coords, dynamic error, Tile tile); class Tile { /// The z of the coords is the tile's zoom level whilst the x and y indicate /// the coordinate position of the tile at that zoom level. - final Coords coords; + final TileCoordinate coordinate; /// The pixel position of this tile on the map at its zoom level. final CustomPoint tilePos; ImageProvider imageProvider; + // If false the tile should be pruned bool current; bool retain; bool active; @@ -35,7 +37,7 @@ class Tile { late ImageStreamListener _listener; Tile({ - required this.coords, + required this.coordinate, required this.tilePos, required this.imageProvider, required final TickerProvider vsync, @@ -104,27 +106,27 @@ class Tile { void _tileOnLoad(ImageInfo imageInfo, bool synchronousCall) { if (tileReady != null) { this.imageInfo = imageInfo; - tileReady!(coords, null, this); + tileReady!(coordinate, null, this); } } void _tileOnError(dynamic exception, StackTrace? stackTrace) { if (tileReady != null) { - tileReady!( - coords, exception ?? 'Unknown exception during loadTileImage', this); + tileReady!(coordinate, + exception ?? 'Unknown exception during loadTileImage', this); } } - String get coordsKey => coords.key; + String get coordsKey => coordinate.key; - double zIndex(double maxZoom, double currentZoom) => - maxZoom - (currentZoom - coords.z).abs(); + double zIndex(double maxZoom, int currentZoom) => + maxZoom - (currentZoom - coordinate.z).abs(); @override - int get hashCode => coords.hashCode; + int get hashCode => coordinate.hashCode; @override bool operator ==(Object other) { - return other is Tile && coords == other.coords; + return other is Tile && coordinate == other.coordinate; } } diff --git a/lib/src/layer/tile_layer/tile_bounds.dart b/lib/src/layer/tile_layer/tile_bounds.dart new file mode 100644 index 000000000..a25420078 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_bounds.dart @@ -0,0 +1,255 @@ +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/geo/crs/crs.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tuple/tuple.dart'; + +abstract class TileBounds { + final Crs crs; + final CustomPoint _tileSize; + final LatLngBounds? _latLngBounds; + + const TileBounds._( + this.crs, + this._tileSize, + this._latLngBounds, + ); + + factory TileBounds({ + required Crs crs, + required CustomPoint tileSize, + required LatLngBounds? latLngBounds, + }) { + if (crs.infinite && latLngBounds == null) { + return InfiniteTileBounds._(crs, tileSize, latLngBounds); + } else if (crs.wrapLat == null && crs.wrapLng == null) { + return DiscreteTileBounds._(crs, tileSize, latLngBounds); + } else { + return WrappedTileBounds._(crs, tileSize, latLngBounds); + } + } + + TileBoundsAtZoom atZoom(int zoom); + + // Returns [true] if these bounds may no longer be valid for the given [crs] + // and [tileSize]. + bool shouldReplace( + Crs crs, + CustomPoint tileSize, + LatLngBounds? latLngBounds, + ) => + (crs != this.crs || + tileSize.x != _tileSize.x || + tileSize.y != _tileSize.y || + latLngBounds != _latLngBounds); +} + +class InfiniteTileBounds extends TileBounds { + const InfiniteTileBounds._( + Crs crs, + CustomPoint tileSize, + LatLngBounds? latLngBounds, + ) : super._(crs, tileSize, latLngBounds); + + @override + TileBoundsAtZoom atZoom(int zoom) => const InfiniteTileBoundsAtZoom._(); +} + +class DiscreteTileBounds extends TileBounds { + final Map _tileBoundsAtZoomCache = {}; + + DiscreteTileBounds._( + Crs crs, + CustomPoint tileSize, + LatLngBounds? latLngBounds, + ) : super._(crs, tileSize, latLngBounds); + + @override + TileBoundsAtZoom atZoom(int zoom) { + return _tileBoundsAtZoomCache.putIfAbsent( + zoom, () => _tileBoundsAtZoomImpl(zoom)); + } + + TileBoundsAtZoom _tileBoundsAtZoomImpl(int zoom) { + final zoomDouble = zoom.toDouble(); + + final Bounds pixelBounds; + if (_latLngBounds == null) { + pixelBounds = crs.getProjectedBounds(zoomDouble)!; + } else { + pixelBounds = Bounds( + crs.latLngToPoint(_latLngBounds!.southWest, zoomDouble).floor(), + crs.latLngToPoint(_latLngBounds!.northEast, zoomDouble).ceil(), + ); + } + + return DiscreteTileBoundsAtZoom._( + DiscreteTileRange.fromPixelBounds( + zoom: zoom, + tileSize: _tileSize, + pixelBounds: pixelBounds, + ), + ); + } +} + +class WrappedTileBounds extends TileBounds { + final Map _tileBoundsAtZoomCache = {}; + + WrappedTileBounds._( + Crs crs, + CustomPoint tileSize, + LatLngBounds? latLngBounds, + ) : super._(crs, tileSize, latLngBounds); + + @override + WrappedTileBoundsAtZoom atZoom(int zoom) { + return _tileBoundsAtZoomCache.putIfAbsent( + zoom, () => _tileBoundsAtZoomImpl(zoom)); + } + + WrappedTileBoundsAtZoom _tileBoundsAtZoomImpl(int zoom) { + final zoomDouble = zoom.toDouble(); + + final Bounds pixelBounds; + if (_latLngBounds == null) { + pixelBounds = crs.getProjectedBounds(zoomDouble)!; + } else { + pixelBounds = Bounds( + crs.latLngToPoint(_latLngBounds!.southWest, zoomDouble).floor(), + crs.latLngToPoint(_latLngBounds!.northEast, zoomDouble).ceil(), + ); + } + + final tzDouble = zoom.toDouble(); + + Tuple2? wrapX; + if (crs.wrapLng != null) { + final wrapXMin = + (crs.latLngToPoint(LatLng(0, crs.wrapLng!.item1), tzDouble).x / + _tileSize.x) + .floor(); + final wrapXMax = + (crs.latLngToPoint(LatLng(0, crs.wrapLng!.item2), tzDouble).x / + _tileSize.y) + .ceil(); + wrapX = Tuple2(wrapXMin, wrapXMax); + } + + Tuple2? wrapY; + if (crs.wrapLat != null) { + final wrapYMin = + (crs.latLngToPoint(LatLng(crs.wrapLat!.item1, 0), tzDouble).y / + _tileSize.x) + .floor(); + final wrapYMax = + (crs.latLngToPoint(LatLng(crs.wrapLat!.item2, 0), tzDouble).y / + _tileSize.y) + .ceil(); + wrapY = Tuple2(wrapYMin, wrapYMax); + } + + return WrappedTileBoundsAtZoom._( + DiscreteTileRange.fromPixelBounds( + zoom: zoom, + tileSize: _tileSize, + pixelBounds: pixelBounds, + ), + wrapX, + wrapY, + ); + } +} + +abstract class TileBoundsAtZoom { + const TileBoundsAtZoom._(); + + TileCoordinate wrap(TileCoordinate coordinate); + + Iterable validCoordinatesIn(DiscreteTileRange tileRange); +} + +class InfiniteTileBoundsAtZoom extends TileBoundsAtZoom { + const InfiniteTileBoundsAtZoom._() : super._(); + + @override + TileCoordinate wrap(TileCoordinate coordinate) => coordinate; + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) => + tileRange.coordinates; +} + +class DiscreteTileBoundsAtZoom extends TileBoundsAtZoom { + final DiscreteTileRange _tileRange; + + const DiscreteTileBoundsAtZoom._(this._tileRange) : super._(); + + @override + TileCoordinate wrap(TileCoordinate coordinate) { + assert(coordinate.z == _tileRange.zoom); + return coordinate; + } + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) { + assert(_tileRange.zoom == tileRange.zoom); + return _tileRange.intersect(tileRange).coordinates; + } +} + +class WrappedTileBoundsAtZoom extends TileBoundsAtZoom { + final DiscreteTileRange _discreteTileBoundsAtZoom; + final Tuple2? _wrapX; + final Tuple2? _wrapY; + + const WrappedTileBoundsAtZoom._( + this._discreteTileBoundsAtZoom, + this._wrapX, + this._wrapY, + ) : super._(); + + @override + TileCoordinate wrap(TileCoordinate coordinate) { + final newCoords = TileCoordinate( + _wrapX != null ? _wrapInt(coordinate.x, _wrapX!) : coordinate.x, + _wrapY != null ? _wrapInt(coordinate.y, _wrapY!) : coordinate.y, + coordinate.z, + ); + return newCoords; + } + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) => + _discreteTileBoundsAtZoom + .intersect(tileRange) + .coordinates + .where(_contains); + + bool _contains(TileCoordinate coordinate) { + if (_wrapX == null && + (coordinate.x <= _discreteTileBoundsAtZoom.min.x || + coordinate.x >= _discreteTileBoundsAtZoom.max.x)) { + return false; + } + + if (_wrapY == null && + (coordinate.y <= _discreteTileBoundsAtZoom.min.y || + coordinate.y >= _discreteTileBoundsAtZoom.max.y)) { + return false; + } + + return true; + } + + // TODO check this is valid against old impl in util + int _wrapInt(int x, Tuple2 range) { + final max = range.item2; + final min = range.item1; + final d = max - min; + return ((x - min) % d + d) % d + min; + } +} diff --git a/lib/src/layer/tile_layer/tile_builder.dart b/lib/src/layer/tile_layer/tile_builder.dart index f6209b1cc..45fd31da3 100644 --- a/lib/src/layer/tile_layer/tile_builder.dart +++ b/lib/src/layer/tile_layer/tile_builder.dart @@ -80,7 +80,7 @@ Widget coordinateDebugTileBuilder( Widget tileWidget, Tile tile, ) { - final coords = tile.coords; + final coords = tile.coordinate; final readableKey = '${coords.x.floor()} : ${coords.y.floor()} : ${coords.z.floor()}'; diff --git a/lib/src/layer/tile_layer/tile_coordinate.dart b/lib/src/layer/tile_layer/tile_coordinate.dart new file mode 100644 index 000000000..6815042c9 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_coordinate.dart @@ -0,0 +1,33 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart'; + +class TileCoordinate extends CustomPoint { + final int z; + + const TileCoordinate(int x, int y, this.z) : super(x, y); + + String get key => '$x:$y:$z'; + + @override + String toString() => 'TileCoordinate($x, $y, $z)'; + + @override + bool operator ==(Object other) { + if (other is! TileCoordinate) return false; + + return x == other.x && y == other.y && z == other.z; + } + + // Overriden because Point's distanceTo does not allow comparing with a point + // of a different type. + @override + double distanceTo(Point other) { + final dx = x - other.x; + final dy = y - other.y; + return sqrt(dx * dx + dy * dy); + } + + @override + int get hashCode => Object.hash(x.hashCode, y.hashCode, z.hashCode); +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 39b68a4ca..78d4bc823 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -4,15 +4,23 @@ import 'dart:math' as math; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/core/util.dart' as util; +import 'package:flutter_map/src/geo/crs/crs.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:tuple/tuple.dart'; part 'tile_layer_options.dart'; @@ -63,12 +71,12 @@ class TileLayer extends StatefulWidget { /// Minimum zoom number the tile source has available. If it is specified, the /// tiles on all zoom levels lower than minNativeZoom will be loaded from /// minNativeZoom level and auto-scaled. - final double? minNativeZoom; + final int? minNativeZoom; /// Maximum zoom number the tile source has available. If it is specified, the /// tiles on all zoom levels higher than maxNativeZoom will be loaded from /// maxNativeZoom level and auto-scaled. - final double? maxNativeZoom; + final int? maxNativeZoom; /// If set to true, the zoom number used in tile URLs will be reversed /// (`maxZoom - zoom` instead of `zoom`) @@ -310,10 +318,11 @@ class TileLayer extends StatefulWidget { } class _TileLayerState extends State with TickerProviderStateMixin { - late Bounds _globalTileRange; - Tuple2? _wrapX; - Tuple2? _wrapY; - double? _tileZoom; + bool _boundsInitialized = false; + + late TileBounds _tileBounds; + + int? _tileZoom; StreamSubscription? _movementSubscription; StreamSubscription? _resetSub; @@ -338,6 +347,28 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _movementSubscription?.cancel(); + final mapState = FlutterMapState.maybeOf(context)!; + + _movementSubscription = mapState.mapController.mapEventStream.listen( + (mapEvent) => _onMove(mapState, mapEvent), + ); + + if (!_boundsInitialized || + _tileBounds.shouldReplace( + mapState.options.crs, _tileSize, widget.tileBounds)) { + _tileBounds = TileBounds( + crs: mapState.options.crs, + tileSize: _tileSize, + latLngBounds: widget.tileBounds, + ); + _boundsInitialized = true; + } + } + @override void didUpdateWidget(TileLayer oldWidget) { super.didUpdateWidget(oldWidget); @@ -348,6 +379,15 @@ class _TileLayerState extends State with TickerProviderStateMixin { reloadTiles = true; } + if (_tileBounds.shouldReplace( + _tileBounds.crs, _tileSize, widget.tileBounds)) { + _tileBounds = TileBounds( + crs: _tileBounds.crs, + tileSize: _tileSize, + latLngBounds: widget.tileBounds, + ); + } + if (oldWidget.retinaMode != widget.retinaMode) { reloadTiles = true; } @@ -366,7 +406,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { !(const MapEquality()) .equals(oldOptions, newOptions)) { if (widget.overrideTilesWhenUrlChanges) { - _tileManager.reloadImages(widget, _wrapX, _wrapY); + _tileManager.reloadImages(widget, _tileBounds); } else { reloadTiles = true; } @@ -378,17 +418,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _movementSubscription?.cancel(); - final mapState = FlutterMapState.maybeOf(context)!; - - _movementSubscription = mapState.mapController.mapEventStream.listen( - (mapEvent) => _onMove(mapState, mapEvent), - ); - } - @override void dispose() { _movementSubscription?.cancel(); @@ -401,18 +430,16 @@ class _TileLayerState extends State with TickerProviderStateMixin { } void _onMove(FlutterMapState mapState, MapEvent mapEvent) { - _tileZoom = _clampToNativeZoom(mapState.zoom.roundToDouble()); + _tileZoom = _clampToNativeZoom(mapState.zoom.round()); if ((_tileZoom! > widget.maxZoom) || (_tileZoom! < widget.minZoom)) { _tileZoom = null; } _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); - _resetGrid(mapState, _tileZoom); - if (_tileZoom != null) _update(mapState, _tileZoom!); - _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); + _pruneTiles(); } @override @@ -423,13 +450,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { return const SizedBox.shrink(); } - final tileZoom = _clampToNativeZoom(map.zoom.roundToDouble()); + final tileZoom = _clampToNativeZoom(map.zoom.round()); final tilesToRender = _tileManager.sortedByDistanceToZoomAscending( widget.maxZoom, tileZoom, ); - final Map zoomToTransformation = {}; + final Map zoomToTransformation = {}; final tileWidgets = [ for (var tile in tilesToRender) @@ -438,21 +465,16 @@ class _TileLayerState extends State with TickerProviderStateMixin { key: ValueKey(tile.coordsKey), tile: tile, size: _tileSize, - tileTransformation: zoomToTransformation[tile.coords.z] ?? - (zoomToTransformation[tile.coords.z] = + tileTransformation: zoomToTransformation[tile.coordinate.z] ?? + (zoomToTransformation[tile.coordinate.z] = TileTransformation.calculate( map: map, - tileZoom: tile.coords.z, + tileZoom: tile.coordinate.z, tileSize: _tileSize, )), ) ]; - for (final entry in zoomToTransformation.entries) { - debugPrint( - '${entry.key}: ${entry.value.scaledTileSize}, ${entry.value.transformation}'); - } - return Opacity( opacity: widget.opacity, child: Container( @@ -466,195 +488,80 @@ class _TileLayerState extends State with TickerProviderStateMixin { CustomPoint getTileSize() => _tileSize; - double _clampToNativeZoom(double zoom) => zoom.clamp( - widget.minNativeZoom ?? -double.infinity, - widget.maxNativeZoom ?? double.infinity); - - //void _setView(FlutterMapState map, LatLng center, double zoom) { - // double? tileZoom = _clampToNativeZoom(zoom.roundToDouble()); - // if ((tileZoom > widget.maxZoom) || (tileZoom < widget.minZoom)) { - // tileZoom = null; - // } - - // _tileZoom = tileZoom; - - // _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); - - // _updateLevels(map); - // _resetGrid(map); - - // if (_tileZoom != null) { - // _update(map, center); - // } - - // _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); - //} - - void _resetGrid(FlutterMapState map, double? tileZoom) { - final crs = map.options.crs; - final tileSize = _tileSize; - - final bounds = map.getPixelWorldBounds(tileZoom); - if (bounds != null) { - _globalTileRange = _pxBoundsToTileRange(bounds); + int _clampToNativeZoom(int zoom) { + if (widget.minNativeZoom != null) { + zoom = math.max(zoom, widget.minNativeZoom!); } - - // wrapping - _wrapX = crs.wrapLng; - if (_wrapX != null) { - final first = - (map.project(LatLng(0, crs.wrapLng!.item1), tileZoom).x / tileSize.x) - .floorToDouble(); - final second = - (map.project(LatLng(0, crs.wrapLng!.item2), tileZoom).x / tileSize.y) - .ceilToDouble(); - _wrapX = Tuple2(first, second); + if (widget.maxNativeZoom != null) { + zoom = math.min(zoom, widget.maxNativeZoom!); } - _wrapY = crs.wrapLat; - if (_wrapY != null) { - final first = - (map.project(LatLng(crs.wrapLat!.item1, 0), tileZoom).y / tileSize.x) - .floorToDouble(); - final second = - (map.project(LatLng(crs.wrapLat!.item2, 0), tileZoom).y / tileSize.y) - .ceilToDouble(); - _wrapY = Tuple2(first, second); - } + return zoom; } - // Private method to load tiles in the grid's active zoom level according to - // map bounds - void _update(FlutterMapState map, double tileZoom) { - final zoom = _clampToNativeZoom(map.zoom); - - final pixelBounds = _getTiledPixelBounds(map, tileZoom); - Bounds tileRange = _pxBoundsToTileRange(pixelBounds); - - final panBuffer = widget.panBuffer; - - // Increase the tilerange if we have panBuffer set, but make sure we - // don't use values outside valid tiles, eg (0,-1). - if (panBuffer != 0) { - tileRange = tileRange.extend(CustomPoint( - math.max(_globalTileRange.min.x, tileRange.min.x - panBuffer), - math.max(_globalTileRange.min.y, tileRange.min.y - panBuffer))); - tileRange = tileRange.extend(CustomPoint( - math.min(_globalTileRange.max.x, tileRange.max.x + panBuffer), - math.min(_globalTileRange.max.y, tileRange.max.y + panBuffer))); - } + // Load tiles in the grid's active zoom level according to map bounds + void _update(FlutterMapState map, int tileZoom) { + final tileLoadRange = DiscreteTileRange.fromPixelBounds( + zoom: tileZoom, + tileSize: _tileSize, + pixelBounds: _visiblePixelBoundsAtZoom(map, tileZoom.toDouble()), + )..expand(widget.panBuffer); - final tileCenter = tileRange.center; - final queue = >[]; - final margin = widget.keepBuffer; - final noPruneRange = Bounds( - tileRange.bottomLeft - CustomPoint(margin, -margin), - tileRange.topRight + CustomPoint(margin, -margin), + // Mark tiles for pruning. + _tileManager.markToPrune( + tileZoom, + tileLoadRange.expand(widget.keepBuffer), ); - _tileManager.markToPrune(tileZoom, noPruneRange); - - // _update just loads more tiles. If the tile zoom level differs too much - // from the map's, let _setView reset levels and prune old tiles. - if ((zoom - tileZoom).abs() > 1) { - // TODO: _setView(map, center, zoom); - return; - } - - // create a queue of coordinates to load tiles from - for (var j = tileRange.min.y; j <= tileRange.max.y; j++) { - for (var i = tileRange.min.x; i <= tileRange.max.x; i++) { - final coords = Coords(i.toDouble(), j.toDouble()); - coords.z = tileZoom; - - if (widget.tileBounds != null) { - final tilePxBounds = _pxBoundsToTileRange( - _latLngBoundsToPixelBounds(map, widget.tileBounds!, tileZoom)); - if (!_areCoordsInsideTileBounds(coords, tilePxBounds)) { - continue; - } - } - - if (!_isValidTile(map.options.crs, coords)) { - continue; - } - - if (!_tileManager.markTileWithCoordsAsCurrent(coords)) { - queue.add(coords); - } - } - } + // Build the queue of tiles to load. Unmarks queued tiles for pruning. + final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); + final queue = tileBoundsAtZoom + .validCoordinatesIn(tileLoadRange) + .where((coord) => !_tileManager.markTileWithCoordsAsCurrent(coord)) + .toList(); + // Evict tiles which have been marked for pruning. _tileManager.evictErrorTilesBasedOnStrategy( - tileRange, widget.evictErrorTileStrategy); + tileLoadRange, + widget.evictErrorTileStrategy, + ); - // sort tile queue to load tiles in order of their distance to center - queue.sort((a, b) => - (a.distanceTo(tileCenter) - b.distanceTo(tileCenter)).toInt()); + // Sort the queued tiles by their distance to the center. + final tileCenter = tileLoadRange.center; + queue.sort( + (a, b) => a.distanceTo(tileCenter).compareTo(b.distanceTo(tileCenter)), + ); + // Create the new Tiles. for (final coords in queue) { - final newTile = Tile( - coords: coords, - tilePos: _getTilePos(map, coords), - current: true, - imageProvider: widget.tileProvider.getImage( - coords.wrap(_wrapX, _wrapY), - widget, + _tileManager.add( + coords, + Tile( + coordinate: coords, + tilePos: coords.scaleBy(_tileSize), + current: true, + imageProvider: widget.tileProvider.getImage( + tileBoundsAtZoom.wrap(coords), + widget, + ), + tileReady: _tileReady, + vsync: this, + duration: widget.tileFadeInDuration, ), - tileReady: _tileReady, - vsync: this, - duration: widget.tileFadeInDuration, ); - - _tileManager.add(coords, newTile); - // If we do this before adding the Tile to the TileManager the _tileReady - // callback may be fired very fast and we won't find the Tile in the - // TileManager since it's not added yet. - newTile.loadTileImage(); } } - Bounds _getTiledPixelBounds(FlutterMapState map, double tileZoom) { - final scale = map.getZoomScale(map.zoom, tileZoom); - final pixelCenter = map.project(map.center, tileZoom).floor(); + /// Returns the bounds of the visible pixels at the target [zoom]. + Bounds _visiblePixelBoundsAtZoom(FlutterMapState map, double zoom) { + final scale = map.getZoomScale(map.zoom, zoom); + final pixelCenter = map.project(map.center, zoom).floor(); final halfSize = map.size / (scale * 2); return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); } - bool _isValidTile(Crs crs, Coords coords) { - if (!crs.infinite) { - // don't load tile if it's out of bounds and not wrapped - final bounds = _globalTileRange; - if ((crs.wrapLng == null && - (coords.x < bounds.min.x || coords.x > bounds.max.x)) || - (crs.wrapLat == null && - (coords.y < bounds.min.y || coords.y > bounds.max.y))) { - return false; - } - } - - return true; - } - - bool _areCoordsInsideTileBounds(Coords coords, Bounds? tileBounds) { - final bounds = tileBounds ?? _globalTileRange; - if ((coords.x < bounds.min.x || coords.x > bounds.max.x) || - (coords.y < bounds.min.y || coords.y > bounds.max.y)) { - return false; - } - return true; - } - - Bounds _latLngBoundsToPixelBounds( - FlutterMapState map, LatLngBounds bounds, double thisZoom) { - final swPixel = map.project(bounds.southWest, thisZoom).floor(); - final nePixel = map.project(bounds.northEast, thisZoom).ceil(); - final pxBounds = Bounds(swPixel, nePixel); - return pxBounds; - } - - void _tileReady(Coords coords, dynamic error, Tile? tile) { + void _tileReady(TileCoordinate tileCoords, dynamic error, Tile? tile) { if (null != error) { debugPrint(error.toString()); @@ -667,7 +574,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tile!.loadError = false; } - tile = _tileManager.tileAt(tile.coords); + tile = _tileManager.tileAt(tile.coordinate); if (tile == null) return; if (widget.fastReplace && mounted) { @@ -676,7 +583,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (_tileManager.allLoaded) { // We're not waiting for anything, prune the tiles immediately. - _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); + _pruneTiles(); } }); return; @@ -709,7 +616,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { () { if (mounted) { setState(() { - _tileManager.prune(_tileZoom, widget.evictErrorTileStrategy); + _pruneTiles(); }); } }, @@ -717,18 +624,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } - CustomPoint _getTilePos(FlutterMapState map, Coords coords) { - return coords.scaleBy(_tileSize); - - //final origin = _transformationCalculator.getOrCalculateOriginAt( - // coords.z as double, map); - //return coords.scaleBy(_tileSize) - origin; - } - - Bounds _pxBoundsToTileRange(Bounds bounds) { - return Bounds( - bounds.min.unscaleBy(_tileSize).floor(), - bounds.max.unscaleBy(_tileSize).ceil() - const CustomPoint(1, 1), - ); + void _pruneTiles() { + if (_tileZoom == null) { + _tileManager.removeAll(widget.evictErrorTileStrategy); + } else { + _tileManager.prune(widget.evictErrorTileStrategy); + } } } diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 443e584e5..0f8a3e8b5 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -85,7 +85,7 @@ class WMSTileLayerOptions { return buffer.toString(); } - String getUrl(Coords coords, int tileSize, bool retinaMode) { + String getUrl(TileCoordinate coords, int tileSize, bool retinaMode) { final tileSizePoint = CustomPoint(tileSize, tileSize); final nvPoint = coords.scaleBy(tileSizePoint); final sePoint = nvPoint + tileSizePoint; diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart index d3f66a31c..9d8cfa5bf 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -1,17 +1,16 @@ -import 'package:flutter_map/src/core/bounds.dart'; import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/layer/tile_layer/coords.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; -import 'package:tuple/tuple.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; class TileManager { final Map _tiles = {}; List all() => _tiles.values.toList(); - List sortedByDistanceToZoomAscending( - double maxZoom, double currentZoom) { + List sortedByDistanceToZoomAscending(double maxZoom, int currentZoom) { return [..._tiles.values]..sort((a, b) => a .zIndex(maxZoom, currentZoom) .compareTo(b.zIndex(maxZoom, currentZoom))); @@ -19,7 +18,7 @@ class TileManager { bool anyWithZoomLevel(double zoomLevel) { for (final tile in _tiles.values) { - if (tile.coords.z == zoomLevel) { + if (tile.coordinate.z == zoomLevel) { return true; } } @@ -27,7 +26,7 @@ class TileManager { return false; } - Tile? tileAt(Coords coords) => _tiles[coords.key]; + Tile? tileAt(TileCoordinate coords) => _tiles[coords.key]; bool get allLoaded { for (final entry in _tiles.entries) { @@ -40,14 +39,14 @@ class TileManager { bool allWithinZoom(double minZoom, double maxZoom) { for (final tile in _tiles.values) { - if (tile.coords.z > (maxZoom) || tile.coords.z < (minZoom)) { + if (tile.coordinate.z > (maxZoom) || tile.coordinate.z < (minZoom)) { return false; } } return true; } - bool markTileWithCoordsAsCurrent(Coords coords) { + bool markTileWithCoordsAsCurrent(TileCoordinate coords) { final tile = _tiles[coords.key]; if (tile != null) { tile.current = true; @@ -57,8 +56,13 @@ class TileManager { } } - void add(Coords coords, Tile tile) { + void add(TileCoordinate coords, Tile tile) { _tiles[coords.key] = tile; + + // This must be done after storing the Tile in the TileManager otherwise + // the callbacks for image load success/fail will not find this Tile in + // the TileManager. + tile.loadTileImage(); } void remove(String key, EvictErrorTileStrategy evictStrategy) { @@ -84,7 +88,7 @@ class TileManager { void removeAtZoom(double zoom, EvictErrorTileStrategy evictStrategy) { final toRemove = []; for (final entry in _tiles.entries) { - if (entry.value.coords.z != zoom) { + if (entry.value.coordinate.z != zoom) { continue; } toRemove.add(entry.key); @@ -97,22 +101,23 @@ class TileManager { void reloadImages( TileLayer layer, - Tuple2? wrapX, - Tuple2? wrapY, + TileBounds tileBounds, ) { for (final tile in _tiles.values) { - tile.imageProvider = - layer.tileProvider.getImage(tile.coords.wrap(wrapX, wrapY), layer); + tile.imageProvider = layer.tileProvider.getImage( + tileBounds.atZoom(tile.coordinate.z).wrap(tile.coordinate), + layer, + ); tile.loadTileImage(); } } - void abortLoading(double? tileZoom, EvictErrorTileStrategy evictionStrategy) { + void abortLoading(int? tileZoom, EvictErrorTileStrategy evictionStrategy) { final toRemove = []; for (final entry in _tiles.entries) { final tile = entry.value; - if (tile.coords.z != tileZoom && tile.loaded == null) { + if (tile.coordinate.z != tileZoom && tile.loaded == null) { toRemove.add(entry.key); } } @@ -127,13 +132,13 @@ class TileManager { } } - void markToPrune(double? currentZoom, Bounds noPruneRange) { + void markToPrune(int currentTileZoom, DiscreteTileRange noPruneRange) { for (final entry in _tiles.entries) { final tile = entry.value; - final c = tile.coords; + final c = tile.coordinate; if (tile.current && - (c.z != currentZoom || + (c.z != currentTileZoom || !noPruneRange.contains(CustomPoint(c.x, c.y)))) { tile.current = false; } @@ -141,7 +146,7 @@ class TileManager { } void evictErrorTilesBasedOnStrategy( - Bounds tileRange, EvictErrorTileStrategy evictStrategy) { + DiscreteTileRange tileRange, EvictErrorTileStrategy evictStrategy) { if (evictStrategy == EvictErrorTileStrategy.notVisibleRespectMargin) { final toRemove = []; for (final entry in _tiles.entries) { @@ -160,7 +165,7 @@ class TileManager { final toRemove = []; for (final entry in _tiles.entries) { final tile = entry.value; - final c = tile.coords; + final c = tile.coordinate; if (tile.loadError && (!tile.current || !tileRange.contains(CustomPoint(c.x, c.y)))) { @@ -175,23 +180,15 @@ class TileManager { } } - void prune(double? zoom, EvictErrorTileStrategy evictStrategy) { - if (zoom == null) { - removeAll(evictStrategy); - return; - } - - for (final entry in _tiles.entries) { - final tile = entry.value; + void prune(EvictErrorTileStrategy evictStrategy) { + for (final tile in _tiles.values) { tile.retain = tile.current; } - for (final entry in _tiles.entries) { - final tile = entry.value; - + for (final tile in _tiles.values) { if (tile.current && !tile.active) { - final coords = tile.coords; - if (!_retainParent(coords.x, coords.y, coords.z, coords.z - 5)) { + final coords = tile.coordinate; + if (!_retainAncestor(coords.x, coords.y, coords.z, coords.z - 5)) { _retainChildren(coords.x, coords.y, coords.z, coords.z + 2); } } @@ -199,11 +196,7 @@ class TileManager { final toRemove = []; for (final entry in _tiles.entries) { - final tile = entry.value; - - if (!tile.retain) { - toRemove.add(entry.key); - } + if (!entry.value.retain) toRemove.add(entry.key); } for (final key in toRemove) { @@ -211,11 +204,13 @@ class TileManager { } } - void _retainChildren(double x, double y, double z, double maxZoom) { + // 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 = Coords(i, j); - coords.z = z + 1; + final coords = TileCoordinate(i, j, z + 1); final tile = _tiles[coords.key]; if (tile != null) { @@ -234,12 +229,14 @@ class TileManager { } } - bool _retainParent(double x, double y, double z, double minZoom) { - final x2 = (x / 2).floorToDouble(); - final y2 = (y / 2).floorToDouble(); + // 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 = Coords(x2, y2); - coords2.z = z2; + final coords2 = TileCoordinate(x2, y2, z2); final tile = _tiles[coords2.key]; if (tile != null) { @@ -252,7 +249,7 @@ class TileManager { } if (z2 > minZoom) { - return _retainParent(x2, y2, z2, minZoom); + return _retainAncestor(x2, y2, z2, minZoom); } return false; diff --git a/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart index 4d8532474..db81f991a 100644 --- a/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart @@ -2,13 +2,13 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_map/src/layer/tile_layer/coords.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; class AssetTileProvider extends TileProvider { @override - AssetImage getImage(Coords coords, TileLayer options) { + AssetImage getImage(TileCoordinate coords, TileLayer options) { return AssetImage( getTileUrl(coords, options), bundle: _FlutterMapAssetBundle( diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 50a22b89e..7e4ad8ebf 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; - -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; /// The base tile provider implementation, extended by other classes such as [NetworkTileProvider] /// @@ -17,23 +17,24 @@ abstract class TileProvider { }); /// Retrieve a tile as an image, based on it's coordinates and the current [TileLayerOptions] - ImageProvider getImage(Coords coords, TileLayer options); + ImageProvider getImage(TileCoordinate coords, TileLayer options); /// Called when the [TileLayerWidget] is disposed void dispose() {} - String _getTileUrl(String urlTemplate, Coords coords, TileLayer options) { + String _getTileUrl( + String urlTemplate, TileCoordinate coords, TileLayer options) { final z = _getZoomForUrl(coords, options); final data = { - 'x': coords.x.round().toString(), - 'y': coords.y.round().toString(), - 'z': z.round().toString(), + 'x': coords.x.toString(), + 'y': coords.y.toString(), + 'z': z.toString(), 's': getSubdomain(coords, options), 'r': '@2x', }; if (options.tms) { - data['y'] = invertY(coords.y.round(), z.round()).toString(); + data['y'] = invertY(coords.y, z).toString(); } final allOpts = Map.from(data) ..addAll(options.additionalOptions); @@ -42,7 +43,7 @@ abstract class TileProvider { /// Generate a valid URL for a tile, based on it's coordinates and the current /// [TileLayerOptions] - String getTileUrl(Coords coords, TileLayer options) { + String getTileUrl(TileCoordinate coords, TileLayer options) { final urlTemplate = (options.wmsOptions != null) ? options.wmsOptions! .getUrl(coords, options.tileSize.toInt(), options.retinaMode) @@ -52,20 +53,20 @@ abstract class TileProvider { } /// Generates a valid URL for the [fallbackUrl]. - String? getTileFallbackUrl(Coords coords, TileLayer options) { + String? getTileFallbackUrl(TileCoordinate coords, TileLayer options) { final urlTemplate = options.fallbackUrl; if (urlTemplate == null) return null; return _getTileUrl(urlTemplate, coords, options); } - double _getZoomForUrl(Coords coords, TileLayer options) { - var zoom = coords.z; + int _getZoomForUrl(TileCoordinate coords, TileLayer options) { + var zoom = coords.z.toDouble(); if (options.zoomReverse) { zoom = options.maxZoom - zoom; } - return zoom += options.zoomOffset; + return (zoom += options.zoomOffset).round(); } int invertY(int y, int z) { @@ -73,11 +74,11 @@ abstract class TileProvider { } /// Get a subdomain value for a tile, based on it's coordinates and the current [TileLayerOptions] - String getSubdomain(Coords coords, TileLayer options) { + String getSubdomain(TileCoordinate coords, TileLayer options) { if (options.subdomains.isEmpty) { return ''; } - final index = (coords.x + coords.y).round() % options.subdomains.length; + final index = (coords.x + coords.y) % options.subdomains.length; return options.subdomains[index]; } } diff --git a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart index 46dc14ea9..00b7df2bd 100644 --- a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart @@ -1,14 +1,16 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; /// [TileProvider] that uses [FileImage] internally on platforms other than web class FileTileProvider extends TileProvider { FileTileProvider(); @override - ImageProvider getImage(Coords coords, TileLayer options) { + ImageProvider getImage(TileCoordinate coords, TileLayer options) { return FileImage(File(getTileUrl(coords, options))); } } diff --git a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart index 2a6264e1f..9794b5daa 100644 --- a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; /// [TileProvider] that uses [NetworkImage] internally on the web /// @@ -9,7 +11,7 @@ class FileTileProvider extends TileProvider { FileTileProvider(); @override - ImageProvider getImage(Coords coords, TileLayer options) { + ImageProvider getImage(TileCoordinate coords, TileLayer options) { return NetworkImage(getTileUrl(coords, options)); } } diff --git a/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart index fdd4b8a46..55a3044b9 100644 --- a/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart +++ b/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart @@ -1,12 +1,13 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:http/http.dart' as http; -import 'package:http/retry.dart'; - -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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/layer/tile_layer/tile_provider/network_image_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_no_retry_image_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:http/retry.dart'; /// [TileProvider] that uses [FMNetworkImageProvider] internally /// @@ -29,7 +30,7 @@ class NetworkTileProvider extends TileProvider { final http.Client httpClient; @override - ImageProvider getImage(Coords coords, TileLayer options) => + ImageProvider getImage(TileCoordinate coords, TileLayer options) => HttpOverrides.runZoned( () => FMNetworkImageProvider( getTileUrl(coords, options), @@ -62,7 +63,7 @@ class NetworkNoRetryTileProvider extends TileProvider { late final HttpClient httpClient; @override - ImageProvider getImage(Coords coords, TileLayer options) => + ImageProvider getImage(TileCoordinate coords, TileLayer options) => FMNetworkNoRetryImageProvider( getTileUrl(coords, options), fallbackUrl: getTileFallbackUrl(coords, options), @@ -78,17 +79,17 @@ class NetworkNoRetryTileProvider extends TileProvider { /// [TileProvider]s. Instead, visit the online documentation at /// https://docs.fleaflet.dev/plugins/making-a-plugin/creating-new-tile-providers. class CustomTileProvider extends TileProvider { - final String Function(Coords coors, TileLayer options) customTileUrl; + final String Function(TileCoordinate coors, TileLayer options) customTileUrl; CustomTileProvider({required this.customTileUrl}); @override - String getTileUrl(Coords coords, TileLayer options) { + String getTileUrl(TileCoordinate coords, TileLayer options) { return customTileUrl(coords, options); } @override - ImageProvider getImage(Coords coords, TileLayer options) { + ImageProvider getImage(TileCoordinate coords, TileLayer options) { return AssetImage(getTileUrl(coords, options)); } } diff --git a/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart b/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart index a2de281e4..145f66c10 100644 --- a/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart +++ b/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart @@ -1,8 +1,9 @@ import 'package:flutter/widgets.dart'; -import 'package:http/http.dart' as http; - -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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/layer/tile_layer/tile_provider/network_image_provider.dart'; +import 'package:http/http.dart' as http; /// [TileProvider] that uses [FMNetworkImageProvider] internally /// @@ -19,7 +20,7 @@ class NetworkTileProvider extends TileProvider { } @override - ImageProvider getImage(Coords coords, TileLayer options) => + ImageProvider getImage(TileCoordinate coords, TileLayer options) => FMNetworkImageProvider( getTileUrl(coords, options), fallbackUrl: getTileFallbackUrl(coords, options), @@ -40,7 +41,7 @@ class NetworkNoRetryTileProvider extends TileProvider { } @override - ImageProvider getImage(Coords coords, TileLayer options) => + ImageProvider getImage(TileCoordinate coords, TileLayer options) => FMNetworkImageProvider( getTileUrl(coords, options), fallbackUrl: getTileFallbackUrl(coords, options), @@ -53,17 +54,17 @@ class NetworkNoRetryTileProvider extends TileProvider { /// /// Using this method is not recommended any more, except for very simple custom [TileProvider]s. Instead, visit the online documentation at https://docs.fleaflet.dev/plugins/making-a-plugin/creating-new-tile-providers. class CustomTileProvider extends TileProvider { - final String Function(Coords coors, TileLayer options) customTileUrl; + final String Function(TileCoordinate coors, TileLayer options) customTileUrl; CustomTileProvider({required this.customTileUrl}); @override - String getTileUrl(Coords coords, TileLayer options) { + String getTileUrl(TileCoordinate coords, TileLayer options) { return customTileUrl(coords, options); } @override - ImageProvider getImage(Coords coords, TileLayer options) { + ImageProvider getImage(TileCoordinate coords, TileLayer options) { return AssetImage(getTileUrl(coords, options)); } } diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart new file mode 100644 index 000000000..beaca4f26 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -0,0 +1,81 @@ +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; + +abstract class TileRange { + final int zoom; + + const TileRange._(this.zoom); + + Iterable get coordinates; +} + +class EmptyTileRange extends TileRange { + const EmptyTileRange._(int zoom) : super._(zoom); + + @override + Iterable get coordinates => + const Iterable.empty(); +} + +class DiscreteTileRange extends TileRange { + // Bounds are inclusive + final Bounds _bounds; + + const DiscreteTileRange._(int zoom, this._bounds) : super._(zoom); + + factory DiscreteTileRange.fromPixelBounds({ + required int zoom, + required CustomPoint tileSize, + required Bounds pixelBounds, + }) { + final bounds = Bounds( + pixelBounds.min.unscaleBy(tileSize).floor().cast(), + pixelBounds.max.unscaleBy(tileSize).floor().cast(), + ); + + return DiscreteTileRange._(zoom, bounds); + } + + DiscreteTileRange expand(int count) { + if (count == 0) return this; + + return DiscreteTileRange._( + zoom, + _bounds + .extend( + CustomPoint(_bounds.min.x - count, _bounds.min.y - count), + ) + .extend( + CustomPoint(_bounds.max.x + count, _bounds.max.y + count), + ), + ); + } + + TileRange intersect(DiscreteTileRange other) { + final boundsIntersection = _bounds.intersect(other._bounds); + + if (boundsIntersection == null) return EmptyTileRange._(zoom); + + return DiscreteTileRange._(zoom, boundsIntersection); + } + + bool contains(CustomPoint point) { + return _bounds.contains(point); + } + + CustomPoint get min => _bounds.min; + + CustomPoint get max => _bounds.max; + + CustomPoint get center => _bounds.center; + + @override + Iterable get coordinates sync* { + for (var j = _bounds.min.y; j <= _bounds.max.y; j++) { + for (var i = _bounds.min.x; i <= _bounds.max.x; i++) { + yield TileCoordinate(i, j, zoom); + } + } + } +} diff --git a/lib/src/layer/tile_layer/tile_transformation.dart b/lib/src/layer/tile_layer/tile_transformation.dart index fd4128827..345f2ad5c 100644 --- a/lib/src/layer/tile_layer/tile_transformation.dart +++ b/lib/src/layer/tile_layer/tile_transformation.dart @@ -15,14 +15,15 @@ class TileTransformation { factory TileTransformation.calculate({ required FlutterMapState map, - required double tileZoom, + required int tileZoom, required CustomPoint tileSize, }) { + final tzDouble = tileZoom.toDouble(); final translate = map.project( map.unproject(map.pixelOrigin), - tileZoom, + tzDouble, ); - final scale = map.getZoomScale(map.zoom, tileZoom); + final scale = map.getZoomScale(map.zoom, tzDouble); return TileTransformation( scaledTileSize: tileSize * scale, diff --git a/test/layer/tile_layer/tile_range_test.dart b/test/layer/tile_layer/tile_range_test.dart new file mode 100644 index 000000000..a524c4dcf --- /dev/null +++ b/test/layer/tile_layer/tile_range_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:test/test.dart'; + +void main() { + group('TileRange', () { + group('EmptyTileRange', () { + test('behaves as an empty range', () { + final tileRange1 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(1, 1), + pixelBounds: Bounds(const CustomPoint(1, 1), const CustomPoint(2, 2)), + ); + final tileRange2 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(1, 1), + pixelBounds: Bounds(const CustomPoint(3, 3), const CustomPoint(4, 4)), + ); + final emptyTileRange = tileRange1.intersect(tileRange2); + + expect( + emptyTileRange, + isA() + .having((e) => e.coordinates, 'coordinates', isEmpty), + ); + }); + }); + + group('DiscreteTileRange', () { + group('fromPixelBounds', () { + test('single tile', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(25.0, 25.0), + ), + ); + + expect( + tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); + }); + + test('lower tile edge', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(0.0, 0.0), + const CustomPoint(0.0, 0.0), + ), + ); + + expect( + tileRange.coordinates.toList(), [const TileCoordinate(0, 0, 0)]); + }); + + test('upper tile edge', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(0.0, 0.0), + const CustomPoint(9.99, 9.99), + ), + ); + + expect( + tileRange.coordinates.toList(), [const TileCoordinate(0, 0, 0)]); + }); + + test('both tile edges', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(19.99, 19.99), + const CustomPoint(30.0, 30.0), + ), + ); + + expect(tileRange.coordinates.toList(), [ + const TileCoordinate(1, 1, 0), + const TileCoordinate(2, 1, 0), + const TileCoordinate(3, 1, 0), + const TileCoordinate(1, 2, 0), + const TileCoordinate(2, 2, 0), + const TileCoordinate(3, 2, 0), + const TileCoordinate(1, 3, 0), + const TileCoordinate(2, 3, 0), + const TileCoordinate(3, 3, 0), + ]); + }); + }); + + group('expand', () { + test('expand', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(25.0, 25.0), + ), + ); + + expect( + tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); + final expandedTileRange = tileRange.expand(1); + + expect(expandedTileRange.coordinates.toList(), [ + const TileCoordinate(1, 1, 0), + const TileCoordinate(2, 1, 0), + const TileCoordinate(3, 1, 0), + const TileCoordinate(1, 2, 0), + const TileCoordinate(2, 2, 0), + const TileCoordinate(3, 2, 0), + const TileCoordinate(1, 3, 0), + const TileCoordinate(2, 3, 0), + const TileCoordinate(3, 3, 0), + ]); + }); + }); + + group('intersect', () { + test('no intersection', () { + final tileRange1 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(25.0, 25.0), + ), + ); + + final tileRange2 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(35.0, 35.0), + ), + ); + + final intersectionA = tileRange1.intersect(tileRange2); + final intersectionB = tileRange1.intersect(tileRange2); + + expect(intersectionA, isA()); + expect(intersectionB, isA()); + }); + }); + + test('intersects', () { + final tileRange1 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(35.0, 35.0), + ), + ); + + final tileRange2 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(45.0, 45.0), + ), + ); + + final intersectionA = + tileRange1.intersect(tileRange2).coordinates.toList(); + final intersectionB = + tileRange1.intersect(tileRange2).coordinates.toList(); + + expect(intersectionA, [const TileCoordinate(3, 3, 0)]); + expect(intersectionB, [const TileCoordinate(3, 3, 0)]); + }); + + test('range within other range', () { + final tileRange1 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(35.0, 35.0), + ), + ); + + final tileRange2 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(15.0, 15.0), + const CustomPoint(45.0, 45.0), + ), + ); + + final intersectionA = + tileRange1.intersect(tileRange2).coordinates.toList(); + final intersectionB = + tileRange1.intersect(tileRange2).coordinates.toList(); + + expect(intersectionA, tileRange1.coordinates.toList()); + expect(intersectionB, tileRange1.coordinates.toList()); + }); + }); + + test('min/max', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(45.0, 45.0), + ), + ); + + expect(tileRange.min, (const CustomPoint(3, 3))); + expect(tileRange.max, (const CustomPoint(4, 4))); + }); + + group('center', () { + test('one tile', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(35.0, 35.0), + ), + ); + + expect(tileRange.center, const CustomPoint(3.0, 3.0)); + }); + + test('multiple tiles, even number of tiles', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(45.0, 45.0), + ), + ); + + expect(tileRange.center, const CustomPoint(3.5, 3.5)); + }); + + test('multiple tiles, odd number of tiles', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(55.0, 55.0), + ), + ); + + expect(tileRange.center, const CustomPoint(4.0, 4.0)); + }); + }); + + test('contains', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: const CustomPoint(10, 10), + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(35.0, 35.0), + ), + ); + + expect(tileRange.contains(const CustomPoint(2, 2)), isFalse); + expect(tileRange.contains(const CustomPoint(3, 2)), isFalse); + expect(tileRange.contains(const CustomPoint(4, 2)), isFalse); + expect(tileRange.contains(const CustomPoint(2, 3)), isFalse); + expect(tileRange.contains(const CustomPoint(3, 3)), isTrue); + expect(tileRange.contains(const CustomPoint(4, 3)), isFalse); + expect(tileRange.contains(const CustomPoint(2, 4)), isFalse); + expect(tileRange.contains(const CustomPoint(3, 4)), isFalse); + expect(tileRange.contains(const CustomPoint(4, 4)), isFalse); + }); + }); +} From f9d46b73bcbea7730655800d888601850a719fa8 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 28 Mar 2023 11:16:01 +0200 Subject: [PATCH 05/51] Working with all example app CRSes --- lib/src/layer/tile_layer/tile.dart | 5 - .../{ => tile_bounds}/tile_bounds.dart | 139 ++--------- .../tile_bounds/tile_bounds_at_zoom.dart | 131 ++++++++++ lib/src/layer/tile_layer/tile_layer.dart | 104 ++++---- .../layer/tile_layer/tile_layer_options.dart | 12 +- lib/src/layer/tile_layer/tile_manager.dart | 2 +- lib/src/layer/tile_layer/tile_range.dart | 64 ++++- .../tile_layer/tile_scale_calculator.dart | 38 +++ .../layer/tile_layer/tile_transformation.dart | 37 --- lib/src/layer/tile_layer/tile_widget.dart | 56 +++-- .../tile_layer/tile_bounds/crs_fakes.dart | 30 +++ .../tile_bounds/tile_bounds_at_zoom_test.dart | 169 +++++++++++++ .../tile_bounds/tile_bounds_test.dart | 227 ++++++++++++++++++ test/layer/tile_layer/tile_range_test.dart | 127 +++++----- 14 files changed, 833 insertions(+), 308 deletions(-) rename lib/src/layer/tile_layer/{ => tile_bounds}/tile_bounds.dart (53%) create mode 100644 lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart create mode 100644 lib/src/layer/tile_layer/tile_scale_calculator.dart delete mode 100644 lib/src/layer/tile_layer/tile_transformation.dart create mode 100644 test/layer/tile_layer/tile_bounds/crs_fakes.dart create mode 100644 test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart create mode 100644 test/layer/tile_layer/tile_bounds/tile_bounds_test.dart diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 459fbf7d5..8e11fe7a2 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; typedef TileReady = void Function( @@ -10,9 +9,6 @@ class Tile { /// the coordinate position of the tile at that zoom level. final TileCoordinate coordinate; - /// The pixel position of this tile on the map at its zoom level. - final CustomPoint tilePos; - ImageProvider imageProvider; // If false the tile should be pruned @@ -38,7 +34,6 @@ class Tile { Tile({ required this.coordinate, - required this.tilePos, required this.imageProvider, required final TickerProvider vsync, this.tileReady, diff --git a/lib/src/layer/tile_layer/tile_bounds.dart b/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart similarity index 53% rename from lib/src/layer/tile_layer/tile_bounds.dart rename to lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart index a25420078..f7c93412a 100644 --- a/lib/src/layer/tile_layer/tile_bounds.dart +++ b/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart @@ -1,15 +1,14 @@ import 'package:flutter_map/src/core/bounds.dart'; -import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/geo/crs/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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_range.dart'; import 'package:latlong2/latlong.dart'; import 'package:tuple/tuple.dart'; abstract class TileBounds { final Crs crs; - final CustomPoint _tileSize; + final double _tileSize; final LatLngBounds? _latLngBounds; const TileBounds._( @@ -20,8 +19,8 @@ abstract class TileBounds { factory TileBounds({ required Crs crs, - required CustomPoint tileSize, - required LatLngBounds? latLngBounds, + required double tileSize, + LatLngBounds? latLngBounds, }) { if (crs.infinite && latLngBounds == null) { return InfiniteTileBounds._(crs, tileSize, latLngBounds); @@ -34,28 +33,27 @@ abstract class TileBounds { TileBoundsAtZoom atZoom(int zoom); - // Returns [true] if these bounds may no longer be valid for the given [crs] - // and [tileSize]. + // Returns true if these bounds may no longer be valid for the given + // parameters. bool shouldReplace( Crs crs, - CustomPoint tileSize, + double tileSize, LatLngBounds? latLngBounds, ) => (crs != this.crs || - tileSize.x != _tileSize.x || - tileSize.y != _tileSize.y || + tileSize != _tileSize || latLngBounds != _latLngBounds); } class InfiniteTileBounds extends TileBounds { const InfiniteTileBounds._( Crs crs, - CustomPoint tileSize, + double tileSize, LatLngBounds? latLngBounds, ) : super._(crs, tileSize, latLngBounds); @override - TileBoundsAtZoom atZoom(int zoom) => const InfiniteTileBoundsAtZoom._(); + TileBoundsAtZoom atZoom(int zoom) => const InfiniteTileBoundsAtZoom(); } class DiscreteTileBounds extends TileBounds { @@ -63,7 +61,7 @@ class DiscreteTileBounds extends TileBounds { DiscreteTileBounds._( Crs crs, - CustomPoint tileSize, + double tileSize, LatLngBounds? latLngBounds, ) : super._(crs, tileSize, latLngBounds); @@ -86,7 +84,7 @@ class DiscreteTileBounds extends TileBounds { ); } - return DiscreteTileBoundsAtZoom._( + return DiscreteTileBoundsAtZoom( DiscreteTileRange.fromPixelBounds( zoom: zoom, tileSize: _tileSize, @@ -101,7 +99,7 @@ class WrappedTileBounds extends TileBounds { WrappedTileBounds._( Crs crs, - CustomPoint tileSize, + double tileSize, LatLngBounds? latLngBounds, ) : super._(crs, tileSize, latLngBounds); @@ -130,126 +128,37 @@ class WrappedTileBounds extends TileBounds { if (crs.wrapLng != null) { final wrapXMin = (crs.latLngToPoint(LatLng(0, crs.wrapLng!.item1), tzDouble).x / - _tileSize.x) + _tileSize) .floor(); final wrapXMax = (crs.latLngToPoint(LatLng(0, crs.wrapLng!.item2), tzDouble).x / - _tileSize.y) + _tileSize) .ceil(); - wrapX = Tuple2(wrapXMin, wrapXMax); + wrapX = Tuple2(wrapXMin, wrapXMax - 1); } Tuple2? wrapY; if (crs.wrapLat != null) { final wrapYMin = (crs.latLngToPoint(LatLng(crs.wrapLat!.item1, 0), tzDouble).y / - _tileSize.x) + _tileSize) .floor(); final wrapYMax = (crs.latLngToPoint(LatLng(crs.wrapLat!.item2, 0), tzDouble).y / - _tileSize.y) + _tileSize) .ceil(); - wrapY = Tuple2(wrapYMin, wrapYMax); + wrapY = Tuple2(wrapYMin, wrapYMax - 1); } - return WrappedTileBoundsAtZoom._( - DiscreteTileRange.fromPixelBounds( + return WrappedTileBoundsAtZoom( + tileRange: DiscreteTileRange.fromPixelBounds( zoom: zoom, tileSize: _tileSize, pixelBounds: pixelBounds, ), - wrapX, - wrapY, + wrappedAxisIsAlwaysInBounds: _latLngBounds == null, + wrapX: wrapX, + wrapY: wrapY, ); } } - -abstract class TileBoundsAtZoom { - const TileBoundsAtZoom._(); - - TileCoordinate wrap(TileCoordinate coordinate); - - Iterable validCoordinatesIn(DiscreteTileRange tileRange); -} - -class InfiniteTileBoundsAtZoom extends TileBoundsAtZoom { - const InfiniteTileBoundsAtZoom._() : super._(); - - @override - TileCoordinate wrap(TileCoordinate coordinate) => coordinate; - - @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) => - tileRange.coordinates; -} - -class DiscreteTileBoundsAtZoom extends TileBoundsAtZoom { - final DiscreteTileRange _tileRange; - - const DiscreteTileBoundsAtZoom._(this._tileRange) : super._(); - - @override - TileCoordinate wrap(TileCoordinate coordinate) { - assert(coordinate.z == _tileRange.zoom); - return coordinate; - } - - @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) { - assert(_tileRange.zoom == tileRange.zoom); - return _tileRange.intersect(tileRange).coordinates; - } -} - -class WrappedTileBoundsAtZoom extends TileBoundsAtZoom { - final DiscreteTileRange _discreteTileBoundsAtZoom; - final Tuple2? _wrapX; - final Tuple2? _wrapY; - - const WrappedTileBoundsAtZoom._( - this._discreteTileBoundsAtZoom, - this._wrapX, - this._wrapY, - ) : super._(); - - @override - TileCoordinate wrap(TileCoordinate coordinate) { - final newCoords = TileCoordinate( - _wrapX != null ? _wrapInt(coordinate.x, _wrapX!) : coordinate.x, - _wrapY != null ? _wrapInt(coordinate.y, _wrapY!) : coordinate.y, - coordinate.z, - ); - return newCoords; - } - - @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) => - _discreteTileBoundsAtZoom - .intersect(tileRange) - .coordinates - .where(_contains); - - bool _contains(TileCoordinate coordinate) { - if (_wrapX == null && - (coordinate.x <= _discreteTileBoundsAtZoom.min.x || - coordinate.x >= _discreteTileBoundsAtZoom.max.x)) { - return false; - } - - if (_wrapY == null && - (coordinate.y <= _discreteTileBoundsAtZoom.min.y || - coordinate.y >= _discreteTileBoundsAtZoom.max.y)) { - return false; - } - - return true; - } - - // TODO check this is valid against old impl in util - int _wrapInt(int x, Tuple2 range) { - final max = range.item2; - final min = range.item1; - final d = max - min; - return ((x - min) % d + d) % d + min; - } -} diff --git a/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart new file mode 100644 index 000000000..3efcfaadc --- /dev/null +++ b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart @@ -0,0 +1,131 @@ +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:tuple/tuple.dart'; + +abstract class TileBoundsAtZoom { + const TileBoundsAtZoom(); + + TileCoordinate wrap(TileCoordinate coordinate); + + Iterable validCoordinatesIn(DiscreteTileRange tileRange); +} + +class InfiniteTileBoundsAtZoom extends TileBoundsAtZoom { + const InfiniteTileBoundsAtZoom(); + + @override + TileCoordinate wrap(TileCoordinate coordinate) => coordinate; + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) => + tileRange.coordinates; + + @override + String toString() => 'InfiniteTileBoundsAtZoom()'; +} + +class DiscreteTileBoundsAtZoom extends TileBoundsAtZoom { + final DiscreteTileRange tileRange; + + const DiscreteTileBoundsAtZoom(this.tileRange); + + @override + TileCoordinate wrap(TileCoordinate coordinate) => coordinate; + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) { + assert(this.tileRange.zoom == tileRange.zoom); + return this.tileRange.intersect(tileRange).coordinates; + } + + @override + String toString() => 'DiscreteTileBoundsAtZoom($tileRange)'; +} + +class WrappedTileBoundsAtZoom extends TileBoundsAtZoom { + final DiscreteTileRange tileRange; + final bool wrappedAxisIsAlwaysInBounds; + final Tuple2? wrapX; + final Tuple2? wrapY; + + const WrappedTileBoundsAtZoom({ + required this.tileRange, + // If true the wrapped axis will not be checked when calling + // validCoordinatesIn. This makes sense if the [tileRange] is from the crs + // since with wrapping enabled all tiles on that axis should be valid. For + // a user defined [tileRange] this should be false as some tiles may fall + // outside of the range. + required this.wrappedAxisIsAlwaysInBounds, + // Inclusive range to which x coordinates will be wrapped. + required this.wrapX, + // Inclusive range to which y coordinates will be wrapped. + required this.wrapY, + }) : assert(!(wrapX == null && wrapY == null)); + + @override + TileCoordinate wrap(TileCoordinate coordinate) => TileCoordinate( + wrapX != null ? _wrapInt(coordinate.x, wrapX!) : coordinate.x, + wrapY != null ? _wrapInt(coordinate.y, wrapY!) : coordinate.y, + coordinate.z, + ); + + @override + Iterable validCoordinatesIn(DiscreteTileRange tileRange) { + if (wrapX != null && wrapY != null) { + if (wrappedAxisIsAlwaysInBounds) return tileRange.coordinates; + + // We need to wrap and check each coordinate. + return tileRange.coordinates.where(_wrappedBothContains); + } else if (wrapX != null) { + // wrapY is null otherwise this would be a discrete bounds + // We can intersect the y coordinate since its not wrapped + final intersectedRange = tileRange.intersectY( + this.tileRange.min.y, + this.tileRange.max.y, + ); + if (wrappedAxisIsAlwaysInBounds) return intersectedRange.coordinates; + return intersectedRange.coordinates.where(_wrappedXInRange); + } else if (wrapY != null) { + // wrapX is null otherwise this would be a discrete bounds + // We can intersect the x coordinate since its not wrapped + final intersectedRange = tileRange.intersectX( + this.tileRange.min.x, + this.tileRange.max.x, + ); + if (wrappedAxisIsAlwaysInBounds) return intersectedRange.coordinates; + return intersectedRange.coordinates.where(_wrappedYInRange); + } else { + throw "Wrapped bounds must wrap on at least one axis"; + } + } + + bool _wrappedBothContains(TileCoordinate coordinate) { + return tileRange.contains( + CustomPoint( + _wrapInt(coordinate.x, wrapX!), + _wrapInt(coordinate.y, wrapY!), + ), + ); + } + + bool _wrappedXInRange(TileCoordinate coordinate) { + final wrappedX = _wrapInt(coordinate.x, wrapX!); + return wrappedX >= tileRange.min.x && wrappedX <= tileRange.max.y; + } + + bool _wrappedYInRange(TileCoordinate coordinate) { + final wrappedY = _wrapInt(coordinate.y, wrapY!); + return wrappedY >= tileRange.min.y && wrappedY <= tileRange.max.y; + } + + /// Wrap [x] to be within [range] inclusive. + int _wrapInt(int x, Tuple2 range) { + final d = range.item2 + 1 - range.item1; + return ((x - range.item1) % d + d) % d + range.item1; + } + + @override + String toString() => + 'WrappedTileBoundsAtZoom($tileRange, $wrappedAxisIsAlwaysInBounds, $wrapX, $wrapY)'; +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 78d4bc823..5a0cc47f3 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -11,14 +11,14 @@ import 'package:flutter_map/src/geo/crs/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; @@ -318,15 +318,15 @@ class TileLayer extends StatefulWidget { } class _TileLayerState extends State with TickerProviderStateMixin { - bool _boundsInitialized = false; + bool _initializedFromMapState = false; late TileBounds _tileBounds; + late TileScaleCalculator _tileScaleCalculator; int? _tileZoom; StreamSubscription? _movementSubscription; StreamSubscription? _resetSub; - late CustomPoint _tileSize; late final TileManager _tileManager; @@ -336,7 +336,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { void initState() { super.initState(); _tileManager = TileManager(); - _tileSize = CustomPoint(widget.tileSize, widget.tileSize); if (widget.reset != null) { _resetSub = widget.reset?.listen( @@ -350,23 +349,41 @@ class _TileLayerState extends State with TickerProviderStateMixin { @override void didChangeDependencies() { super.didChangeDependencies(); - _movementSubscription?.cancel(); + final mapState = FlutterMapState.maybeOf(context)!; + _movementSubscription?.cancel(); _movementSubscription = mapState.mapController.mapEventStream.listen( - (mapEvent) => _onMove(mapState, mapEvent), + (mapEvent) => _loadAndPruneTiles(mapState), ); - if (!_boundsInitialized || + bool reloadTiles = false; + if (!_initializedFromMapState || _tileBounds.shouldReplace( - mapState.options.crs, _tileSize, widget.tileBounds)) { + mapState.options.crs, widget.tileSize, widget.tileBounds)) { + reloadTiles = true; _tileBounds = TileBounds( crs: mapState.options.crs, - tileSize: _tileSize, + tileSize: widget.tileSize, latLngBounds: widget.tileBounds, ); - _boundsInitialized = true; } + + if (!_initializedFromMapState || + _tileScaleCalculator.shouldReplace( + mapState.options.crs, widget.tileSize)) { + reloadTiles = true; + _tileScaleCalculator = TileScaleCalculator( + crs: mapState.options.crs, + tileSize: widget.tileSize, + ); + } + + if (reloadTiles) { + _loadAndPruneTiles(mapState); + } + + _initializedFromMapState = true; } @override @@ -374,18 +391,22 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); var reloadTiles = false; - if (oldWidget.tileSize != widget.tileSize) { - _tileSize = CustomPoint(widget.tileSize, widget.tileSize); - reloadTiles = true; - } - if (_tileBounds.shouldReplace( - _tileBounds.crs, _tileSize, widget.tileBounds)) { + _tileBounds.crs, widget.tileSize, widget.tileBounds)) { _tileBounds = TileBounds( crs: _tileBounds.crs, - tileSize: _tileSize, + tileSize: widget.tileSize, latLngBounds: widget.tileBounds, ); + reloadTiles = true; + } + + if (_tileScaleCalculator.shouldReplace( + _tileScaleCalculator.crs, widget.tileSize)) { + _tileScaleCalculator = TileScaleCalculator( + crs: _tileScaleCalculator.crs, + tileSize: widget.tileSize, + ); } if (oldWidget.retinaMode != widget.retinaMode) { @@ -415,6 +436,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileManager.removeAll(widget.evictErrorTileStrategy); + _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); } } @@ -429,49 +451,45 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.dispose(); } - void _onMove(FlutterMapState mapState, MapEvent mapEvent) { + void _loadAndPruneTiles(FlutterMapState mapState) { _tileZoom = _clampToNativeZoom(mapState.zoom.round()); - if ((_tileZoom! > widget.maxZoom) || (_tileZoom! < widget.minZoom)) { + + if (_outsideZoomLimits(_tileZoom!)) { _tileZoom = null; + } else { + _update(mapState, _tileZoom!); } - _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); - - if (_tileZoom != null) _update(mapState, _tileZoom!); - _pruneTiles(); } @override Widget build(BuildContext context) { final map = FlutterMapState.maybeOf(context)!; - final mapZoom = map.zoom.roundToDouble(); - if (mapZoom < widget.minZoom || mapZoom > widget.maxZoom) { + final roundedMapZoom = map.zoom.round(); + if (_outsideZoomLimits(roundedMapZoom)) { return const SizedBox.shrink(); } - final tileZoom = _clampToNativeZoom(map.zoom.round()); + final tileZoom = _clampToNativeZoom(roundedMapZoom); final tilesToRender = _tileManager.sortedByDistanceToZoomAscending( widget.maxZoom, tileZoom, ); - final Map zoomToTransformation = {}; - + _tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom); final tileWidgets = [ for (var tile in tilesToRender) AnimatedTile( - // TODO Not animated key: ValueKey(tile.coordsKey), tile: tile, - size: _tileSize, - tileTransformation: zoomToTransformation[tile.coordinate.z] ?? - (zoomToTransformation[tile.coordinate.z] = - TileTransformation.calculate( - map: map, - tileZoom: tile.coordinate.z, - tileSize: _tileSize, - )), + currentPixelOrigin: map.pixelOrigin, + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tile.coordinate.z, + ), + errorImage: widget.errorImage, + tileBuilder: widget.tileBuilder, ) ]; @@ -486,8 +504,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { ); } - CustomPoint getTileSize() => _tileSize; - int _clampToNativeZoom(int zoom) { if (widget.minNativeZoom != null) { zoom = math.max(zoom, widget.minNativeZoom!); @@ -501,9 +517,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Load tiles in the grid's active zoom level according to map bounds void _update(FlutterMapState map, int tileZoom) { + _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); + final tileLoadRange = DiscreteTileRange.fromPixelBounds( zoom: tileZoom, - tileSize: _tileSize, + tileSize: widget.tileSize, pixelBounds: _visiblePixelBoundsAtZoom(map, tileZoom.toDouble()), )..expand(widget.panBuffer); @@ -538,7 +556,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { coords, Tile( coordinate: coords, - tilePos: coords.scaleBy(_tileSize), current: true, imageProvider: widget.tileProvider.getImage( tileBoundsAtZoom.wrap(coords), @@ -631,4 +648,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileManager.prune(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 0f8a3e8b5..37350aaf9 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -87,13 +87,13 @@ class WMSTileLayerOptions { String getUrl(TileCoordinate coords, int tileSize, bool retinaMode) { final tileSizePoint = CustomPoint(tileSize, tileSize); - final nvPoint = coords.scaleBy(tileSizePoint); - final sePoint = nvPoint + tileSizePoint; - final nvCoords = crs.pointToLatLng(nvPoint, coords.z as double)!; - final seCoords = crs.pointToLatLng(sePoint, coords.z as double)!; - final nv = crs.projection.project(nvCoords); + final nwPoint = coords.scaleBy(tileSizePoint); + final sePoint = nwPoint + tileSizePoint; + final nwCoords = crs.pointToLatLng(nwPoint, coords.z.toDouble())!; + final seCoords = crs.pointToLatLng(sePoint, coords.z.toDouble())!; + final nw = crs.projection.project(nwCoords); final se = crs.projection.project(seCoords); - final bounds = Bounds(nv, se); + final bounds = Bounds(nw, se); final bbox = (_versionNumber >= 1.3 && crs is Epsg4326) ? [bounds.min.y, bounds.min.x, bounds.max.y, bounds.max.x] : [bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y]; diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart index 9d8cfa5bf..b6a34f4af 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -1,6 +1,6 @@ import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart index beaca4f26..671f6d7a9 100644 --- a/lib/src/layer/tile_layer/tile_range.dart +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter_map/src/core/bounds.dart'; import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; @@ -5,13 +7,13 @@ import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; abstract class TileRange { final int zoom; - const TileRange._(this.zoom); + const TileRange(this.zoom); Iterable get coordinates; } class EmptyTileRange extends TileRange { - const EmptyTileRange._(int zoom) : super._(zoom); + const EmptyTileRange._(super.zoom); @override Iterable get coordinates => @@ -22,25 +24,32 @@ class DiscreteTileRange extends TileRange { // Bounds are inclusive final Bounds _bounds; - const DiscreteTileRange._(int zoom, this._bounds) : super._(zoom); + const DiscreteTileRange(super.zoom, this._bounds); factory DiscreteTileRange.fromPixelBounds({ required int zoom, - required CustomPoint tileSize, + required double tileSize, required Bounds pixelBounds, }) { - final bounds = Bounds( - pixelBounds.min.unscaleBy(tileSize).floor().cast(), - pixelBounds.max.unscaleBy(tileSize).floor().cast(), - ); + final Bounds bounds; + if (pixelBounds.min == pixelBounds.max) { + final minAndMax = (pixelBounds.min / tileSize).floor().cast(); + bounds = Bounds(minAndMax, minAndMax); + } else { + bounds = Bounds( + (pixelBounds.min / tileSize).floor().cast(), + (pixelBounds.max / tileSize).ceil().cast() - + const CustomPoint(1, 1), + ); + } - return DiscreteTileRange._(zoom, bounds); + return DiscreteTileRange(zoom, bounds); } DiscreteTileRange expand(int count) { if (count == 0) return this; - return DiscreteTileRange._( + return DiscreteTileRange( zoom, _bounds .extend( @@ -57,7 +66,37 @@ class DiscreteTileRange extends TileRange { if (boundsIntersection == null) return EmptyTileRange._(zoom); - return DiscreteTileRange._(zoom, boundsIntersection); + return DiscreteTileRange(zoom, boundsIntersection); + } + + // Inclusive + TileRange intersectX(int minX, int maxX) { + if (_bounds.min.x > maxX || _bounds.max.x < minX) { + return EmptyTileRange._(zoom); + } + + return DiscreteTileRange( + zoom, + Bounds( + CustomPoint(math.max(min.x, minX), min.y), + CustomPoint(math.min(max.x, maxX), max.y), + ), + ); + } + + // Inclusive + TileRange intersectY(int minY, int maxY) { + if (_bounds.min.y > maxY || _bounds.max.y < minY) { + return EmptyTileRange._(zoom); + } + + return DiscreteTileRange( + zoom, + Bounds( + CustomPoint(min.x, math.max(min.y, minY)), + CustomPoint(max.x, math.min(max.y, maxY)), + ), + ); } bool contains(CustomPoint point) { @@ -78,4 +117,7 @@ class DiscreteTileRange extends TileRange { } } } + + @override + String toString() => 'DiscreteTileRange($min, $max)'; } diff --git a/lib/src/layer/tile_layer/tile_scale_calculator.dart b/lib/src/layer/tile_layer/tile_scale_calculator.dart new file mode 100644 index 000000000..8cb17210f --- /dev/null +++ b/lib/src/layer/tile_layer/tile_scale_calculator.dart @@ -0,0 +1,38 @@ +import 'package:flutter_map/src/geo/crs/crs.dart'; + +/// Calculate a scale value to transform the Tile's coordinate to its position. +class TileScaleCalculator { + final Crs crs; + final double tileSize; + + double? _cachedCurrentZoom; + final Map _cache = {}; + + TileScaleCalculator({ + required this.crs, + required this.tileSize, + }); + + /// If [true] indicates that the TileSizeCache should be replaced. + bool shouldReplace(Crs crs, double tileSize) => + this.crs != crs || this.tileSize != tileSize; + + /// Clears the cache if the zoom level does not match the current cached one + /// and sets [currentZoom] as the new zoom to cache for. Must be called + /// before calling scaledTileSize with a [currentZoom] different than the + /// last time scaledTileSize was called. + void clearCacheUnlessZoomMatches(double currentZoom) { + if (_cachedCurrentZoom != currentZoom) _cache.clear(); + _cachedCurrentZoom = currentZoom; + } + + /// Returns a scale value to transform a Tile coordainte to a Tile position. + double scaledTileSize(double currentZoom, int tileZoom) { + assert(_cachedCurrentZoom == currentZoom); + return _scaledTileSizeImpl(currentZoom, tileZoom); + } + + double _scaledTileSizeImpl(double currentZoom, int tileZoom) { + return tileSize * (crs.scale(currentZoom) / crs.scale(tileZoom.toDouble())); + } +} diff --git a/lib/src/layer/tile_layer/tile_transformation.dart b/lib/src/layer/tile_layer/tile_transformation.dart deleted file mode 100644 index 345f2ad5c..000000000 --- a/lib/src/layer/tile_layer/tile_transformation.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/map/flutter_map_state.dart'; -import 'package:meta/meta.dart'; -import 'package:vector_math/vector_math_64.dart'; - -@immutable -class TileTransformation { - final CustomPoint scaledTileSize; - final Matrix3 transformation; - - const TileTransformation({ - required this.scaledTileSize, - required this.transformation, - }); - - factory TileTransformation.calculate({ - required FlutterMapState map, - required int tileZoom, - required CustomPoint tileSize, - }) { - final tzDouble = tileZoom.toDouble(); - final translate = map.project( - map.unproject(map.pixelOrigin), - tzDouble, - ); - final scale = map.getZoomScale(map.zoom, tzDouble); - - return TileTransformation( - scaledTileSize: tileSize * scale, - transformation: Matrix3( - 1, 0, 0, // - 0, 1, 0, // - -translate.x as double, -translate.y as double, 1, // - )..scale(scale), - ); - } -} diff --git a/lib/src/layer/tile_layer/tile_widget.dart b/lib/src/layer/tile_layer/tile_widget.dart index a712b3649..6ff4ff549 100644 --- a/lib/src/layer/tile_layer/tile_widget.dart +++ b/lib/src/layer/tile_layer/tile_widget.dart @@ -1,42 +1,48 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; -import 'package:vector_math/vector_math_64.dart'; class AnimatedTile extends StatelessWidget { - static final Vector3 _tileVectorStorage = Vector3.all(1); - static final Vector3 _transformedTileVectorStorage = Vector3.zero(); - final Tile tile; - final CustomPoint size; - final TileTransformation tileTransformation; + final CustomPoint currentPixelOrigin; + final double scaledTileSize; + final ImageProvider? errorImage; + final TileBuilder? tileBuilder; const AnimatedTile({ + super.key, required this.tile, - required this.size, - required this.tileTransformation, - Key? key, - }) : super(key: key); + required this.currentPixelOrigin, + required this.scaledTileSize, + required this.errorImage, + required this.tileBuilder, + }); @override Widget build(BuildContext context) { - _tileVectorStorage.x = tile.tilePos.x.toDouble(); - _tileVectorStorage.y = tile.tilePos.y.toDouble(); + final pos = tile.coordinate.multiplyBy(scaledTileSize) - currentPixelOrigin; - final transformedTilePos = tileTransformation.transformation.transformed( - _tileVectorStorage, - _transformedTileVectorStorage, - ); + Widget tileWidget; + if (tile.loadError && errorImage != null) { + tileWidget = Image(image: errorImage!); + } else if (tile.animationController == null) { + tileWidget = RawImage(image: tile.imageInfo?.image, fit: BoxFit.fill); + } else { + tileWidget = AnimatedBuilder( + animation: tile.animationController!, + builder: (context, child) => RawImage( + image: tile.imageInfo?.image, + fit: BoxFit.fill, + opacity: tile.animationController!, + ), + ); + } return Positioned( - left: transformedTilePos.x, - top: transformedTilePos.y, - width: tileTransformation.scaledTileSize.x.toDouble(), - height: tileTransformation.scaledTileSize.y.toDouble(), - child: RawImage( - image: tile.imageInfo?.image, - fit: BoxFit.fill, - ), + left: pos.x.toDouble(), + top: pos.y.toDouble(), + width: scaledTileSize, + height: scaledTileSize, + child: tileBuilder?.call(context, tileWidget, tile) ?? tileWidget, ); } } diff --git a/test/layer/tile_layer/tile_bounds/crs_fakes.dart b/test/layer/tile_layer/tile_bounds/crs_fakes.dart new file mode 100644 index 000000000..6ad87a20a --- /dev/null +++ b/test/layer/tile_layer/tile_bounds/crs_fakes.dart @@ -0,0 +1,30 @@ +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/geo/crs/crs.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tuple/tuple.dart'; + +class FakeInfiniteCrs extends Crs { + @override + String get code => throw UnimplementedError(); + + @override + bool get infinite => true; + + @override + Projection get projection => throw UnimplementedError(); + + @override + Transformation get transformation => throw UnimplementedError(); + + @override + Tuple2? get wrapLat => null; + + @override + Tuple2? get wrapLng => null; + + /// Any projection just to get non-zero coordiantes. + @override + CustomPoint latLngToPoint(LatLng latlng, double zoom) { + return const Epsg3857().latLngToPoint(latlng, zoom); + } +} diff --git a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart new file mode 100644 index 000000000..4d36d9309 --- /dev/null +++ b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_map/plugin_api.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_range.dart'; +import 'package:test/test.dart'; +import 'package:tuple/tuple.dart'; + +void main() { + group('TileBoundsAtZoom', () { + const hugeCoordinate = TileCoordinate(999999999, 999999999, 0); + final tileRangeWithHugeCoordinate = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: 1, + pixelBounds: Bounds(hugeCoordinate, hugeCoordinate), + ); + + DiscreteTileRange discreteTileRange( + int zoom, int minX, int minY, int maxX, int maxY) => + DiscreteTileRange( + zoom, + Bounds(CustomPoint(minX, minY), CustomPoint(maxX, maxY)), + ); + + test('InfiniteTileBoundsAtZoom', () { + const tileBoundsAtZoom = InfiniteTileBoundsAtZoom(); + + // Does not wrap + expect(tileBoundsAtZoom.wrap(hugeCoordinate), hugeCoordinate); + + // Does not filter out coordinates + expect( + tileBoundsAtZoom.validCoordinatesIn(tileRangeWithHugeCoordinate), + [hugeCoordinate], + ); + }); + + test('DiscreteTileBoundsAtZoom', () { + final tileRange = discreteTileRange(0, 0, 0, 10, 10); + final tileBoundsAtZoom = DiscreteTileBoundsAtZoom(tileRange); + + // Does not wrap + expect(tileBoundsAtZoom.wrap(hugeCoordinate), hugeCoordinate); + + // Filters out invalid coordinates + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, 11, 11, 12, 12), + ), + isEmpty, + ); + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, -10, -10, -1, -1), + ), + isEmpty, + ); + + // Does not filter out valid coordinates + final resultingCoordinates = + tileBoundsAtZoom.validCoordinatesIn(tileRange); + expect(resultingCoordinates, tileRange.coordinates); + }); + + test('WrappedTileBoundsAtZoom, wrappedTilesAlwaysValid = false', () { + final tileRange = discreteTileRange(0, 2, 2, 10, 10); + final tileBoundsAtZoom = WrappedTileBoundsAtZoom( + tileRange: tileRange, + wrappedAxisIsAlwaysInBounds: false, + wrapX: const Tuple2(0, 12), + wrapY: null, + ); + + // Only wraps x, x is larger than range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(13, 13, 0)), + const TileCoordinate(0, 13, 0), + ); + // Only wraps x, x is smaller than range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(-1, -1, 0)), + const TileCoordinate(12, -1, 0), + ); + // No wrap, x is within range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(12, 12, 0)), + const TileCoordinate(12, 12, 0), + ); + + // Filters out invalid coordinates + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, 11, 11, 12, 12), + ), + isEmpty, + ); + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, -10, -10, 1, 1), + ), + isEmpty, + ); + + // Keeps coordinates which are in the tile range. + expect( + tileBoundsAtZoom.validCoordinatesIn(discreteTileRange(0, 0, 0, 12, 12)), + discreteTileRange(0, 2, 2, 10, 10).coordinates, + ); + + // Keeps coordinates which are in the tile range when wrapped. + expect( + tileBoundsAtZoom + .validCoordinatesIn(discreteTileRange(0, 13, 0, 25, 12)), + discreteTileRange(0, 15, 2, 23, 10).coordinates, + ); + }); + + test('WrappedTileBoundsAtZoom, wrappedTilesAlwaysValid = true', () { + final tileRange = discreteTileRange(0, 2, 2, 10, 10); + final tileBoundsAtZoom = WrappedTileBoundsAtZoom( + tileRange: tileRange, + wrappedAxisIsAlwaysInBounds: true, + wrapX: const Tuple2(0, 12), + wrapY: null, + ); + + // Only wraps x, x is larger than range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(13, 13, 0)), + const TileCoordinate(0, 13, 0), + ); + // Only wraps x, x is smaller than range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(-1, -1, 0)), + const TileCoordinate(12, -1, 0), + ); + // No wrap, x is within range + expect( + tileBoundsAtZoom.wrap(const TileCoordinate(12, 12, 0)), + const TileCoordinate(12, 12, 0), + ); + + // Filters out invalid coordinates + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, 11, 11, 12, 12), + ), + isEmpty, + ); + expect( + tileBoundsAtZoom.validCoordinatesIn( + discreteTileRange(0, -10, -10, 1, 1), + ), + isEmpty, + ); + + // Keeps all wrapped coordinates, only non-wrapped coordinates in range. + expect( + tileBoundsAtZoom.validCoordinatesIn(discreteTileRange(0, 0, 0, 12, 12)), + discreteTileRange(0, 0, 2, 12, 10).coordinates, + ); + + // Keeps all wrapped coordiantes, only non-wraped coordaintes in range. + expect( + tileBoundsAtZoom + .validCoordinatesIn(discreteTileRange(0, 13, 0, 25, 12)), + discreteTileRange(0, 13, 2, 25, 10).coordinates, + ); + }); + }); +} diff --git a/test/layer/tile_layer/tile_bounds/tile_bounds_test.dart b/test/layer/tile_layer/tile_bounds/tile_bounds_test.dart new file mode 100644 index 000000000..60e8efa49 --- /dev/null +++ b/test/layer/tile_layer/tile_bounds/tile_bounds_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter_map/plugin_api.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_range.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:test/test.dart'; +import 'package:tuple/tuple.dart'; + +import 'crs_fakes.dart'; + +void main() { + group('TileBounds', () { + test('crs is infinite, latLngBounds null', () { + final tileBounds = TileBounds( + crs: FakeInfiniteCrs(), + tileSize: 256, + ); + + expect(tileBounds, isA()); + expect(tileBounds.atZoom(5), isA()); + }); + + test('crs is infinite, latLngBounds provided', () { + final tileBounds = TileBounds( + crs: FakeInfiniteCrs(), + tileSize: 256, + latLngBounds: LatLngBounds.fromPoints( + [LatLng(-44, -55), LatLng(44, 55)], + ), + ); + + expect(tileBounds, isA()); + expect( + tileBounds.atZoom(5), + isA().having( + (e) => e.tileRange, + 'tileRange', + isA() + .having((e) => e.min, 'min', const CustomPoint(11, 11)) + .having((e) => e.max, 'max', const CustomPoint(20, 20)), + ), + ); + }); + + test('crs is finite non-wrapping', () { + final tileBounds = TileBounds( + crs: CrsSimple(), + tileSize: 256, + ); + + expect(tileBounds, isA()); + expect( + tileBounds.atZoom(0), + isA().having( + (e) => e.tileRange, + 'tileRange', + isA() + .having((e) => e.min, 'min', const CustomPoint(-180, -90)) + .having((e) => e.max, 'max', const CustomPoint(179, 89)), + ), + ); + }); + + test('crs is finite wrapping', () { + final tileBounds = TileBounds( + crs: const Epsg3857(), + tileSize: 256, + ); + + expect(tileBounds, isA()); + expect( + tileBounds.atZoom(5), + isA() + .having( + (e) => e.tileRange, + 'tileRange', + isA() + .having((e) => e.min, 'min', const CustomPoint(0, 0)) + .having((e) => e.max, 'max', const CustomPoint(31, 31)), + ) + .having((e) => e.wrappedAxisIsAlwaysInBounds, + 'wrappedAxisIsAlwaysInBounds', isTrue) + .having((e) => e.wrapX, 'wrapX', const Tuple2(0, 31)) + .having((e) => e.wrapY, 'wrapY', isNull), + ); + }); + + test('crs is finite wrapping, latLngBounds provided', () { + const crs = Epsg3857(); + final tileBounds = TileBounds( + crs: crs, + tileSize: 256, + latLngBounds: LatLngBounds( + LatLng(0, 0), + crs.pointToLatLng(crs.getProjectedBounds(0)!.max, 0)!, + ), + ); + + expect(tileBounds, isA()); + expect( + tileBounds.atZoom(5), + isA() + .having( + (e) => e.tileRange, + 'tileRange', + isA() + .having((e) => e.min, 'min', const CustomPoint(16, 16)) + .having((e) => e.max, 'max', const CustomPoint(31, 31)), + ) + .having((e) => e.wrappedAxisIsAlwaysInBounds, + 'wrappedAxisIsAlwaysInBounds', isFalse) + .having((e) => e.wrapX, 'wrapX', const Tuple2(0, 31)) + .having((e) => e.wrapY, 'wrapY', isNull), + ); + }); + + test('Has correct tile counts for Epsg3857 crs', () { + // Taken from: + // https://wiki.openstreetmap.org/wiki/Zoom_levels + final expectedTileCounts = { + 0: 1, + 1: 4, + 2: 16, + 3: 64, + 4: 256, + 5: 1024, + 6: 4096, + 7: 16384, + 8: 65536, + 9: 262144, + 10: 1048576, + 11: 4194304, + 12: 16777216, + 13: 67108864, + 14: 268435456, + 15: 1073741824, + 16: 4294967296, + 17: 17179869184, + 18: 68719476736, + 19: 274877906944, + 20: 1099511627776 + }; + + final tileBounds = TileBounds( + crs: const Epsg3857(), + tileSize: 256, + ); + + for (final entry in expectedTileCounts.entries) { + final zoom = entry.key; + final tileBoundsAtZoom = + tileBounds.atZoom(zoom) as WrappedTileBoundsAtZoom; + final minCoord = tileBoundsAtZoom.tileRange.min; + final maxCoord = tileBoundsAtZoom.tileRange.max; + + final tileCount = + (maxCoord.x - minCoord.x + 1) * (maxCoord.y - minCoord.y + 1); + + expect(tileCount, entry.value); + } + }); + + test('Has correct tile ranges for Epsg3857 crs', () { + // Inferred from (ranges are inclusive starting at 0): + // https://wiki.openstreetmap.org/wiki/Zoom_levels + final expectedTileRanges = { + 0: const Tuple4(0, 0, 0, 0), + 1: const Tuple4(0, 0, 1, 1), + 2: const Tuple4(0, 0, 3, 3), + 3: const Tuple4(0, 0, 7, 7), + 4: const Tuple4(0, 0, 15, 15), + 5: const Tuple4(0, 0, 31, 31), + 6: const Tuple4(0, 0, 63, 63), + }; + + final tileBounds = TileBounds( + crs: const Epsg3857(), + tileSize: 256, + ); + + for (final entry in expectedTileRanges.entries) { + final zoom = entry.key; + final tileBoundsAtZoom = + tileBounds.atZoom(zoom) as WrappedTileBoundsAtZoom; + + final coords = tileBoundsAtZoom.tileRange.coordinates; + final firstCoord = coords.first; + final lastCoord = coords.last; + + expect( + Tuple4(firstCoord.x, firstCoord.y, lastCoord.x, lastCoord.y), + entry.value, + ); + } + }); + + test('Has correct tile waps for Epsg3857 crs', () { + // Inferred from (ranges are inclusive starting at 0): + // https://wiki.openstreetmap.org/wiki/Zoom_levels + final expectedTileRanges = { + 0: const Tuple2(0, 0), + 1: const Tuple2(0, 1), + 2: const Tuple2(0, 3), + 3: const Tuple2(0, 7), + 4: const Tuple2(0, 15), + 5: const Tuple2(0, 31), + 6: const Tuple2(0, 63), + }; + + final tileBounds = TileBounds( + crs: const Epsg3857(), + tileSize: 256, + ); + + for (final entry in expectedTileRanges.entries) { + final zoom = entry.key; + final tileBoundsAtZoom = + tileBounds.atZoom(zoom) as WrappedTileBoundsAtZoom; + + expect( + tileBoundsAtZoom.wrapX, + entry.value, + ); + } + }); + }); +} diff --git a/test/layer/tile_layer/tile_range_test.dart b/test/layer/tile_layer/tile_range_test.dart index a524c4dcf..ee180625d 100644 --- a/test/layer/tile_layer/tile_range_test.dart +++ b/test/layer/tile_layer/tile_range_test.dart @@ -8,12 +8,12 @@ void main() { test('behaves as an empty range', () { final tileRange1 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(1, 1), + tileSize: 1, pixelBounds: Bounds(const CustomPoint(1, 1), const CustomPoint(2, 2)), ); final tileRange2 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(1, 1), + tileSize: 1, pixelBounds: Bounds(const CustomPoint(3, 3), const CustomPoint(4, 4)), ); final emptyTileRange = tileRange1.intersect(tileRange2); @@ -31,7 +31,7 @@ void main() { test('single tile', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(25.0, 25.0), const CustomPoint(25.0, 25.0), @@ -45,10 +45,10 @@ void main() { test('lower tile edge', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(0.0, 0.0), - const CustomPoint(0.0, 0.0), + const CustomPoint(0.1, 0.1), ), ); @@ -59,7 +59,7 @@ void main() { test('upper tile edge', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(0.0, 0.0), const CustomPoint(9.99, 9.99), @@ -73,10 +73,10 @@ void main() { test('both tile edges', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(19.99, 19.99), - const CustomPoint(30.0, 30.0), + const CustomPoint(30.1, 30.1), ), ); @@ -94,67 +94,62 @@ void main() { }); }); - group('expand', () { - test('expand', () { - final tileRange = DiscreteTileRange.fromPixelBounds( - zoom: 0, - tileSize: const CustomPoint(10, 10), - pixelBounds: Bounds( - const CustomPoint(25.0, 25.0), - const CustomPoint(25.0, 25.0), - ), - ); - - expect( - tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); - final expandedTileRange = tileRange.expand(1); + test('expand', () { + final tileRange = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: 10, + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(25.0, 25.0), + ), + ); - expect(expandedTileRange.coordinates.toList(), [ - const TileCoordinate(1, 1, 0), - const TileCoordinate(2, 1, 0), - const TileCoordinate(3, 1, 0), - const TileCoordinate(1, 2, 0), - const TileCoordinate(2, 2, 0), - const TileCoordinate(3, 2, 0), - const TileCoordinate(1, 3, 0), - const TileCoordinate(2, 3, 0), - const TileCoordinate(3, 3, 0), - ]); - }); + expect(tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); + final expandedTileRange = tileRange.expand(1); + + expect(expandedTileRange.coordinates.toList(), [ + const TileCoordinate(1, 1, 0), + const TileCoordinate(2, 1, 0), + const TileCoordinate(3, 1, 0), + const TileCoordinate(1, 2, 0), + const TileCoordinate(2, 2, 0), + const TileCoordinate(3, 2, 0), + const TileCoordinate(1, 3, 0), + const TileCoordinate(2, 3, 0), + const TileCoordinate(3, 3, 0), + ]); }); - group('intersect', () { - test('no intersection', () { - final tileRange1 = DiscreteTileRange.fromPixelBounds( - zoom: 0, - tileSize: const CustomPoint(10, 10), - pixelBounds: Bounds( - const CustomPoint(25.0, 25.0), - const CustomPoint(25.0, 25.0), - ), - ); + test('no intersection', () { + final tileRange1 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: 10, + pixelBounds: Bounds( + const CustomPoint(25.0, 25.0), + const CustomPoint(25.0, 25.0), + ), + ); - final tileRange2 = DiscreteTileRange.fromPixelBounds( - zoom: 0, - tileSize: const CustomPoint(10, 10), - pixelBounds: Bounds( - const CustomPoint(35.0, 35.0), - const CustomPoint(35.0, 35.0), - ), - ); + final tileRange2 = DiscreteTileRange.fromPixelBounds( + zoom: 0, + tileSize: 10, + pixelBounds: Bounds( + const CustomPoint(35.0, 35.0), + const CustomPoint(35.0, 35.0), + ), + ); - final intersectionA = tileRange1.intersect(tileRange2); - final intersectionB = tileRange1.intersect(tileRange2); + final intersectionA = tileRange1.intersect(tileRange2); + final intersectionB = tileRange1.intersect(tileRange2); - expect(intersectionA, isA()); - expect(intersectionB, isA()); - }); + expect(intersectionA, isA()); + expect(intersectionB, isA()); }); test('intersects', () { final tileRange1 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(25.0, 25.0), const CustomPoint(35.0, 35.0), @@ -163,7 +158,7 @@ void main() { final tileRange2 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(45.0, 45.0), @@ -182,7 +177,7 @@ void main() { test('range within other range', () { final tileRange1 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(25.0, 25.0), const CustomPoint(35.0, 35.0), @@ -191,7 +186,7 @@ void main() { final tileRange2 = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(15.0, 15.0), const CustomPoint(45.0, 45.0), @@ -211,7 +206,7 @@ void main() { test('min/max', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(45.0, 45.0), @@ -226,7 +221,7 @@ void main() { test('one tile', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(35.0, 35.0), @@ -239,7 +234,7 @@ void main() { test('multiple tiles, even number of tiles', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(45.0, 45.0), @@ -252,7 +247,7 @@ void main() { test('multiple tiles, odd number of tiles', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(55.0, 55.0), @@ -266,7 +261,7 @@ void main() { test('contains', () { final tileRange = DiscreteTileRange.fromPixelBounds( zoom: 0, - tileSize: const CustomPoint(10, 10), + tileSize: 10, pixelBounds: Bounds( const CustomPoint(35.0, 35.0), const CustomPoint(35.0, 35.0), From 5207fe9e0397c6c15937191752bb89c1201bcb79 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 28 Mar 2023 11:27:53 +0200 Subject: [PATCH 06/51] Remove unnecessary _tileZoom and clean up some variables --- lib/src/layer/tile_layer/tile.dart | 12 +++--- lib/src/layer/tile_layer/tile_layer.dart | 45 ++++++---------------- lib/src/layer/tile_layer/tile_manager.dart | 2 +- 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 8e11fe7a2..56e15c26b 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -27,7 +27,7 @@ class Tile { // callback when tile is ready / error occurred // it maybe be null for instance when download aborted - TileReady? tileReady; + TileReady? onTileReady; ImageInfo? imageInfo; ImageStream? _imageStream; late ImageStreamListener _listener; @@ -36,7 +36,7 @@ class Tile { required this.coordinate, required this.imageProvider, required final TickerProvider vsync, - this.tileReady, + this.onTileReady, this.current = false, this.active = false, this.retain = false, @@ -99,15 +99,15 @@ class Tile { } void _tileOnLoad(ImageInfo imageInfo, bool synchronousCall) { - if (tileReady != null) { + if (onTileReady != null) { this.imageInfo = imageInfo; - tileReady!(coordinate, null, this); + onTileReady!(coordinate, null, this); } } void _tileOnError(dynamic exception, StackTrace? stackTrace) { - if (tileReady != null) { - tileReady!(coordinate, + if (onTileReady != null) { + onTileReady!(coordinate, exception ?? 'Unknown exception during loadTileImage', this); } } diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 5a0cc47f3..519e45642 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -320,22 +320,17 @@ class TileLayer extends StatefulWidget { class _TileLayerState extends State with TickerProviderStateMixin { bool _initializedFromMapState = false; + final TileManager _tileManager = TileManager(); late TileBounds _tileBounds; late TileScaleCalculator _tileScaleCalculator; - int? _tileZoom; - StreamSubscription? _movementSubscription; StreamSubscription? _resetSub; - - late final TileManager _tileManager; - Timer? _pruneLater; @override void initState() { super.initState(); - _tileManager = TileManager(); if (widget.reset != null) { _resetSub = widget.reset?.listen( @@ -452,15 +447,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { } void _loadAndPruneTiles(FlutterMapState mapState) { - _tileZoom = _clampToNativeZoom(mapState.zoom.round()); + final tileZoom = _clampToNativeZoom(mapState.zoom.round()); - if (_outsideZoomLimits(_tileZoom!)) { - _tileZoom = null; - } else { - _update(mapState, _tileZoom!); - } + if (!_outsideZoomLimits(tileZoom)) _update(mapState, tileZoom); - _pruneTiles(); + _tileManager.prune(widget.evictErrorTileStrategy); } @override @@ -517,7 +508,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Load tiles in the grid's active zoom level according to map bounds void _update(FlutterMapState map, int tileZoom) { - _tileManager.abortLoading(_tileZoom, widget.evictErrorTileStrategy); + _tileManager.abortLoading(tileZoom, widget.evictErrorTileStrategy); final tileLoadRange = DiscreteTileRange.fromPixelBounds( zoom: tileZoom, @@ -561,7 +552,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileBoundsAtZoom.wrap(coords), widget, ), - tileReady: _tileReady, + onTileReady: _onTileReady, vsync: this, duration: widget.tileFadeInDuration, ), @@ -578,7 +569,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); } - void _tileReady(TileCoordinate tileCoords, dynamic error, Tile? tile) { + void _onTileReady(TileCoordinate tileCoords, dynamic error, Tile? tile) { if (null != error) { debugPrint(error.toString()); @@ -600,7 +591,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (_tileManager.allLoaded) { // We're not waiting for anything, prune the tiles immediately. - _pruneTiles(); + _tileManager.prune(widget.evictErrorTileStrategy); } }); return; @@ -623,32 +614,18 @@ class _TileLayerState extends State with TickerProviderStateMixin { } if (_tileManager.allLoaded) { - // Wait a bit more than tileFadeInDuration (the duration of the tile - // fade-in) to trigger a pruning. + // 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( widget.tileFadeInDuration != null ? widget.tileFadeInDuration! + const Duration(milliseconds: 50) : const Duration(milliseconds: 50), - () { - if (mounted) { - setState(() { - _pruneTiles(); - }); - } - }, + () => _tileManager.prune(widget.evictErrorTileStrategy), ); } } - void _pruneTiles() { - if (_tileZoom == null) { - _tileManager.removeAll(widget.evictErrorTileStrategy); - } else { - _tileManager.prune(widget.evictErrorTileStrategy); - } - } - bool _outsideZoomLimits(num zoom) => zoom < widget.minZoom || zoom > widget.maxZoom; } diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart index b6a34f4af..5c5927429 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -125,7 +125,7 @@ class TileManager { for (final key in toRemove) { final tile = _tiles[key]!; - tile.tileReady = null; + tile.onTileReady = null; tile.dispose( tile.loadError && evictionStrategy != EvictErrorTileStrategy.none); _tiles.remove(key); From a40d5ed936ca9f9f91596c8934c05413b1e8c045 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 28 Mar 2023 17:31:43 +0200 Subject: [PATCH 07/51] Make TileImage a ChangeNotifier This avoids the need for TileLayer to rebuild the whole layer with setState when a tile finishes loading. In fact TileLayer no longer calls setState at all. --- example/lib/pages/custom_crs/custom_crs.dart | 22 ++- example/lib/pages/epsg3413_crs.dart | 20 +- example/lib/pages/tile_builder_example.dart | 6 +- .../lib/pages/tile_loading_error_handle.dart | 2 +- lib/flutter_map.dart | 2 +- lib/src/layer/tile_layer/tile.dart | 173 ++++++------------ lib/src/layer/tile_layer/tile_builder.dart | 18 +- lib/src/layer/tile_layer/tile_image.dart | 173 ++++++++++++++++++ lib/src/layer/tile_layer/tile_layer.dart | 130 +++++-------- .../layer/tile_layer/tile_layer_options.dart | 28 ++- lib/src/layer/tile_layer/tile_manager.dart | 79 ++++---- lib/src/layer/tile_layer/tile_widget.dart | 48 ----- 12 files changed, 373 insertions(+), 328 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_image.dart delete mode 100644 lib/src/layer/tile_layer/tile_widget.dart diff --git a/example/lib/pages/custom_crs/custom_crs.dart b/example/lib/pages/custom_crs/custom_crs.dart index f190f9e00..bad592bb2 100644 --- a/example/lib/pages/custom_crs/custom_crs.dart +++ b/example/lib/pages/custom_crs/custom_crs.dart @@ -138,17 +138,19 @@ class _CustomCrsPageState extends State { }), ), children: [ - TileLayer( + Opacity( opacity: 1, - backgroundColor: Colors.transparent, - wmsOptions: WMSTileLayerOptions( - // Set the WMS layer's CRS - crs: epsg3413CRS, - transparent: true, - format: 'image/jpeg', - baseUrl: - 'https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?', - layers: ['gebco_north_polar_view'], + child: TileLayer( + backgroundColor: Colors.transparent, + wmsOptions: WMSTileLayerOptions( + // Set the WMS layer's CRS + crs: epsg3413CRS, + transparent: true, + format: 'image/jpeg', + baseUrl: + 'https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?', + layers: ['gebco_north_polar_view'], + ), ), ), ], diff --git a/example/lib/pages/epsg3413_crs.dart b/example/lib/pages/epsg3413_crs.dart index 0874fc1c3..86984d55a 100644 --- a/example/lib/pages/epsg3413_crs.dart +++ b/example/lib/pages/epsg3413_crs.dart @@ -134,16 +134,18 @@ class _EPSG3413PageState extends State { maxZoom: maxZoom, ), children: [ - TileLayer( + Opacity( opacity: 1, - backgroundColor: Colors.transparent, - wmsOptions: WMSTileLayerOptions( - crs: epsg3413CRS, - transparent: true, - format: 'image/jpeg', - baseUrl: - 'https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?', - layers: ['gebco_north_polar_view'], + child: TileLayer( + backgroundColor: Colors.transparent, + wmsOptions: WMSTileLayerOptions( + crs: epsg3413CRS, + transparent: true, + format: 'image/jpeg', + baseUrl: + 'https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?', + layers: ['gebco_north_polar_view'], + ), ), ), OverlayImageLayer( diff --git a/example/lib/pages/tile_builder_example.dart b/example/lib/pages/tile_builder_example.dart index 2a0818054..d55bf803a 100644 --- a/example/lib/pages/tile_builder_example.dart +++ b/example/lib/pages/tile_builder_example.dart @@ -20,7 +20,7 @@ class _TileBuilderPageState extends State { int panBuffer = 0; // mix of [coordinateDebugTileBuilder] and [loadingTimeDebugTileBuilder] from tile_builder.dart - Widget tileBuilder(BuildContext context, Widget tileWidget, Tile tile) { + Widget tileBuilder(BuildContext context, Widget tileWidget, TileImage tile) { final coords = tile.coordinate; return Container( @@ -42,10 +42,10 @@ class _TileBuilderPageState extends State { ), if (loadingTime) Text( - tile.loaded == null + tile.loadFinishedAt == null ? 'Loading' // sometimes result is negative which shouldn't happen, abs() corrects it - : '${(tile.loaded!.millisecond - tile.loadStarted.millisecond).abs()} ms', + : '${(tile.loadFinishedAt!.millisecond - tile.loadStarted!.millisecond).abs()} ms', style: Theme.of(context).textTheme.headlineSmall, ), ], diff --git a/example/lib/pages/tile_loading_error_handle.dart b/example/lib/pages/tile_loading_error_handle.dart index 5aa3da8d0..502d12ff8 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_loading_error_handle.dart @@ -47,7 +47,7 @@ class _TileLoadingErrorHandleState extends State { // TileProvider with a caching and retry strategy, like // NetworkTileProvider or CachedNetworkTileProvider userAgentPackageName: 'dev.fleaflet.flutter_map.example', - errorTileCallback: (Tile tile, error) { + errorTileCallback: (tile, error, stackTrace) { if (needLoadingError) { WidgetsBinding.instance.addPostFrameCallback((_) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 7dbea78b7..7f31580b1 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -30,9 +30,9 @@ export 'package:flutter_map/src/layer/marker_layer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 56e15c26b..ae0e48955 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,127 +1,74 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; + +class Tile extends StatefulWidget { + final TileImage tileImage; + final CustomPoint currentPixelOrigin; + final double scaledTileSize; + final TileBuilder? tileBuilder; + + const Tile({ + super.key, + required this.tileImage, + required this.currentPixelOrigin, + required this.scaledTileSize, + required this.tileBuilder, + }); -typedef TileReady = void Function( - TileCoordinate coords, dynamic error, Tile tile); - -class Tile { - /// The z of the coords is the tile's zoom level whilst the x and y indicate - /// the coordinate position of the tile at that zoom level. - final TileCoordinate coordinate; - - ImageProvider imageProvider; - - // If false the tile should be pruned - bool current; - bool retain; - bool active; - bool loadError; - DateTime? loaded; - late DateTime loadStarted; - - final AnimationController? animationController; - - double get opacity => animationController == null - ? (active ? 1.0 : 0.0) - : animationController!.value; - - // callback when tile is ready / error occurred - // it maybe be null for instance when download aborted - TileReady? onTileReady; - ImageInfo? imageInfo; - ImageStream? _imageStream; - late ImageStreamListener _listener; - - Tile({ - required this.coordinate, - required this.imageProvider, - required final TickerProvider vsync, - this.onTileReady, - this.current = false, - this.active = false, - this.retain = false, - this.loadError = false, - final Duration? duration, - }) : animationController = duration != null - ? AnimationController(duration: duration, vsync: vsync) - : null { - animationController?.addStatusListener(_onAnimateEnd); - } - - void loadTileImage() { - loadStarted = DateTime.now(); - - try { - final oldImageStream = _imageStream; - _imageStream = imageProvider.resolve(ImageConfiguration.empty); - - if (_imageStream!.key != oldImageStream?.key) { - oldImageStream?.removeListener(_listener); - - _listener = ImageStreamListener(_tileOnLoad, onError: _tileOnError); - _imageStream!.addListener(_listener); - } - } catch (e, s) { - // make sure all exception is handled - #444 / #536 - _tileOnError(e, s); - } - } - - // call this before GC! - void dispose([bool evict = false]) { - if (evict) { - try { - imageProvider.evict().catchError((Object e) { - debugPrint(e.toString()); - return false; - }); - } catch (e) { - // this may be never called because catchError will handle errors, however - // we want to avoid random crashes like in #444 / #536 - debugPrint(e.toString()); - } - } + @override + State createState() => _TileState(); +} - animationController?.removeStatusListener(_onAnimateEnd); - animationController?.dispose(); - _imageStream?.removeListener(_listener); +class _TileState extends State { + @override + void initState() { + super.initState(); + widget.tileImage.addListener(_onTileImageChange); } - void startFadeInAnimation({double? from}) { - animationController?.reset(); - animationController?.forward(from: from); + @override + void dispose() { + widget.tileImage.removeListener(_onTileImageChange); + super.dispose(); } - void _onAnimateEnd(AnimationStatus status) { - if (status == AnimationStatus.completed) { - active = true; - } + void _onTileImageChange() { + if (mounted) setState(() {}); } - void _tileOnLoad(ImageInfo imageInfo, bool synchronousCall) { - if (onTileReady != null) { - this.imageInfo = imageInfo; - onTileReady!(coordinate, null, this); - } + @override + Widget build(BuildContext context) { + return Positioned( + left: widget.tileImage.coordinate.x * widget.scaledTileSize - + widget.currentPixelOrigin.x, + top: widget.tileImage.coordinate.y * widget.scaledTileSize - + widget.currentPixelOrigin.y, + width: widget.scaledTileSize, + height: widget.scaledTileSize, + child: widget.tileBuilder?.call(context, _tileImage, widget.tileImage) ?? + _tileImage, + ); } - void _tileOnError(dynamic exception, StackTrace? stackTrace) { - if (onTileReady != null) { - onTileReady!(coordinate, - exception ?? 'Unknown exception during loadTileImage', this); + Widget get _tileImage { + if (widget.tileImage.loadError && widget.tileImage.errorImage != null) { + return Image(image: widget.tileImage.errorImage!); + } else if (widget.tileImage.animationController == null) { + return RawImage( + image: widget.tileImage.imageInfo?.image, + fit: BoxFit.fill, + ); + } else { + return AnimatedBuilder( + animation: widget.tileImage.animationController!, + builder: (context, child) => RawImage( + image: widget.tileImage.imageInfo?.image, + fit: BoxFit.fill, + opacity: widget.tileImage.animationController!, + ), + ); } } - - String get coordsKey => coordinate.key; - - double zIndex(double maxZoom, int currentZoom) => - maxZoom - (currentZoom - coordinate.z).abs(); - - @override - int get hashCode => coordinate.hashCode; - - @override - bool operator ==(Object other) { - return other is Tile && coordinate == other.coordinate; - } } diff --git a/lib/src/layer/tile_layer/tile_builder.dart b/lib/src/layer/tile_layer/tile_builder.dart index 45fd31da3..f47e9e9bb 100644 --- a/lib/src/layer/tile_layer/tile_builder.dart +++ b/lib/src/layer/tile_layer/tile_builder.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; typedef TileBuilder = Widget Function( - BuildContext context, Widget tileWidget, Tile tile); + BuildContext context, Widget tileWidget, TileImage tile); typedef TilesContainerBuilder = Widget Function( - BuildContext context, Widget tilesContainer, List tiles); + BuildContext context, Widget tilesContainer, List tiles); /// Applies inversion color matrix on Tiles container which may simulate Dark mode. Widget darkModeTilesContainerBuilder( BuildContext context, Widget tilesContainer, - List tiles, + List tiles, ) { return ColorFiltered( colorFilter: const ColorFilter.matrix([ @@ -45,7 +45,7 @@ Widget darkModeTilesContainerBuilder( Widget darkModeTileBuilder( BuildContext context, Widget tileWidget, - Tile tile, + TileImage tile, ) { return ColorFiltered( colorFilter: const ColorFilter.matrix([ @@ -78,7 +78,7 @@ Widget darkModeTileBuilder( Widget coordinateDebugTileBuilder( BuildContext context, Widget tileWidget, - Tile tile, + TileImage tile, ) { final coords = tile.coordinate; final readableKey = @@ -107,14 +107,14 @@ Widget coordinateDebugTileBuilder( Widget loadingTimeDebugTileBuilder( BuildContext context, Widget tileWidget, - Tile tile, + TileImage tile, ) { final loadStarted = tile.loadStarted; - final loaded = tile.loaded; + final loaded = tile.loadFinishedAt; final time = loaded == null ? 'Loading' - : '${(loaded.millisecond - loadStarted.millisecond).abs()} ms'; + : '${(loaded.millisecond - loadStarted!.millisecond).abs()} ms'; return Container( decoration: BoxDecoration( diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart new file mode 100644 index 000000000..3a7f04fa7 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -0,0 +1,173 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; + +class TileImage extends ChangeNotifier { + /// The z of the coordinate is the TileImage's zoom level whilst the x and y + /// indicate the position of the tile at that zoom level. + final TileCoordinate coordinate; + + final 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(TileCoordinate coordinate) onLoadComplete; + + /// Callback fired when an error occurs whilst loading the tile image. + /// [onLoadComplete] will be called immediately afterwards. This callback is + /// not triggered after this TileImage is disposed. + final void Function(TileImage tile, Object error, StackTrace? stackTrace) + onLoadError; + + /// Options for controlling whether tile fade in. + final TileFadeIn? fadeIn; + + /// An optional image to show when a loading error occurs. + final ImageProvider? errorImage; + + bool _disposed = false; + + ImageProvider imageProvider; + + /// If false this TileImage will be pruned when the next prune is run. + bool current = true; + + bool retain = false; + + /// Whether the tile is displayable with full opacity. 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. + bool loadError = false; + + /// When loading started. + DateTime? loadStarted; + + /// When loading finished. + DateTime? loadFinishedAt; + + ImageInfo? imageInfo; + ImageStream? _imageStream; + late ImageStreamListener _listener; + + TileImage({ + required final TickerProvider vsync, + required this.coordinate, + required this.imageProvider, + required this.onLoadComplete, + required this.onLoadError, + required this.fadeIn, + required this.errorImage, + }) : animationController = fadeIn == null + ? null + : AnimationController(duration: fadeIn.duration, vsync: vsync); + + double get opacity => animationController == null + ? (_active ? 1.0 : 0.0) + : animationController!.value; + + String get coordsKey => coordinate.key; + + bool get active => _active; + + double zIndex(double maxZoom, int currentZoom) => + maxZoom - (currentZoom - coordinate.z).abs(); + + void loadTileImage() { + loadStarted = DateTime.now(); + + try { + final oldImageStream = _imageStream; + _imageStream = imageProvider.resolve(ImageConfiguration.empty); + + if (_imageStream!.key != oldImageStream?.key) { + oldImageStream?.removeListener(_listener); + + _listener = ImageStreamListener(_onImageLoadSuccess, + onError: _onImageLoadError); + _imageStream!.addListener(_listener); + } + } catch (e, s) { + // Make sure all exceptions are handled - #444 / #536 + _onImageLoadError(e, s); + } + } + + void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { + loadError = false; + this.imageInfo = imageInfo; + + if (!_disposed) { + _activate(); + onLoadComplete(coordinate); + } + } + + void _onImageLoadError(Object exception, StackTrace? stackTrace) { + loadError = true; + + if (!_disposed) { + _activate(); + onLoadError(this, exception, stackTrace); + onLoadComplete(coordinate); + } + } + + void _activate() { + final previouslyLoaded = loadFinishedAt != null; + loadFinishedAt = DateTime.now(); + + if (fadeIn == null || (loadError && errorImage != null)) { + _active = true; + notifyListeners(); + return; + } + + final fadeStartOpacity = + previouslyLoaded ? fadeIn!.reloadStartOpacity : fadeIn!.startOpacity; + + if (fadeStartOpacity == 1.0) { + _active = true; + notifyListeners(); + return; + } + + animationController!.reset(); + animationController!.forward(from: fadeStartOpacity).then((_) { + _active = true; + notifyListeners(); + }); + } + + @override + void dispose({bool evictImageFromCache = false}) { + _disposed = true; + if (evictImageFromCache) { + try { + imageProvider.evict().catchError((Object e) { + debugPrint(e.toString()); + return false; + }); + } catch (e) { + // This may be never called because catchError will handle errors, however + // we want to avoid random crashes like in #444 / #536 + debugPrint(e.toString()); + } + } + + animationController?.dispose(); + _imageStream?.removeListener(_listener); + super.dispose(); + } + + @override + int get hashCode => coordinate.hashCode; + + @override + bool operator ==(Object other) { + return other is TileImage && coordinate == other.coordinate; + } +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 519e45642..c70a3767e 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -14,12 +14,12 @@ import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; part 'tile_layer_options.dart'; @@ -105,9 +105,6 @@ class TileLayer extends StatefulWidget { /// Color shown behind the tiles final Color backgroundColor; - /// Opacity of the rendered tile - final double opacity; - /// Provider with which to load map tiles /// /// The default is [NetworkNoRetryTileProvider]. Alternatively, use @@ -171,18 +168,9 @@ class TileLayer extends StatefulWidget { /// ``` final Map additionalOptions; - /// Tiles fade in duration in milliseconds (default 100). This can be null to - /// avoid fade in. - final Duration? tileFadeInDuration; - - /// Opacity start value when Tile starts fade in (0.0 - 1.0) Takes effect if - /// `tileFadeInDuration` is not null - final double tileFadeInStart; - - /// Opacity start value when an exists Tile starts fade in with different Url - /// (0.0 - 1.0) Takes effect when `tileFadeInDuration` is not null and if - /// `overrideTilesWhenUrlChanges` if true - final double tileFadeInStartWhenOverride; + /// Options for fading in tiles when they are loaded. If this is set to null + /// no fade is performed. + final TileFadeIn? tileFadeIn; /// `false`: current Tiles will be first dropped and then reload via new url /// (default) `true`: current Tiles will be visible until new ones aren't @@ -265,10 +253,8 @@ class TileLayer extends StatefulWidget { TileProvider? tileProvider, this.tms = false, this.wmsOptions, - this.opacity = 1.0, Duration tileFadeInDuration = const Duration(milliseconds: 100), - this.tileFadeInStart = 0.0, - this.tileFadeInStartWhenOverride = 0.0, + this.tileFadeIn = const TileFadeIn(), this.overrideTilesWhenUrlChanges = false, this.retinaMode = false, this.errorTileCallback, @@ -280,11 +266,7 @@ class TileLayer extends StatefulWidget { this.reset, this.tileBounds, String userAgentPackageName = 'unknown', - }) : tileFadeInDuration = - tileFadeInDuration <= Duration.zero ? null : tileFadeInDuration, - assert(tileFadeInStart >= 0.0 && tileFadeInStart <= 1.0), - assert(tileFadeInStartWhenOverride >= 0.0 && - tileFadeInStartWhenOverride <= 1.0), + }) : assert(tileFadeIn == null || tileFadeIn.duration > Duration.zero), maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -423,6 +405,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { .equals(oldOptions, newOptions)) { if (widget.overrideTilesWhenUrlChanges) { _tileManager.reloadImages(widget, _tileBounds); + _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); } else { reloadTiles = true; } @@ -463,34 +446,35 @@ class _TileLayerState extends State with TickerProviderStateMixin { } final tileZoom = _clampToNativeZoom(roundedMapZoom); - final tilesToRender = _tileManager.sortedByDistanceToZoomAscending( + final tileImagesToRender = _tileManager.sortedByDistanceToZoomAscending( widget.maxZoom, tileZoom, ); + final currentPixelOrigin = CustomPoint( + map.pixelOrigin.x.toDouble(), + map.pixelOrigin.y.toDouble(), + ); + _tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom); final tileWidgets = [ - for (var tile in tilesToRender) - AnimatedTile( - key: ValueKey(tile.coordsKey), - tile: tile, - currentPixelOrigin: map.pixelOrigin, + for (var tileImage in tileImagesToRender) + Tile( + key: ValueKey(tileImage.coordsKey), + tileImage: tileImage, + currentPixelOrigin: currentPixelOrigin, scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, - tile.coordinate.z, + tileImage.coordinate.z, ), - errorImage: widget.errorImage, tileBuilder: widget.tileBuilder, ) ]; - return Opacity( - opacity: widget.opacity, - child: Container( - color: widget.backgroundColor, - child: Stack( - children: tileWidgets, - ), + return Container( + color: widget.backgroundColor, + child: Stack( + children: tileWidgets, ), ); } @@ -545,16 +529,17 @@ class _TileLayerState extends State with TickerProviderStateMixin { for (final coords in queue) { _tileManager.add( coords, - Tile( + TileImage( + vsync: this, coordinate: coords, - current: true, imageProvider: widget.tileProvider.getImage( tileBoundsAtZoom.wrap(coords), widget, ), - onTileReady: _onTileReady, - vsync: this, - duration: widget.tileFadeInDuration, + onLoadError: _onTileLoadError, + onLoadComplete: _onTileLoadComplete, + fadeIn: widget.fastReplace ? null : widget.tileFadeIn, + errorImage: widget.errorImage, ), ); } @@ -569,58 +554,27 @@ class _TileLayerState extends State with TickerProviderStateMixin { return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); } - void _onTileReady(TileCoordinate tileCoords, dynamic error, Tile? tile) { - if (null != error) { - debugPrint(error.toString()); - - tile!.loadError = true; - - if (widget.errorTileCallback != null) { - widget.errorTileCallback!(tile, error); - } - } else { - tile!.loadError = false; - } + void _onTileLoadError(TileImage tile, Object error, StackTrace? stackTrace) { + debugPrint(error.toString()); + widget.errorTileCallback?.call(tile, error, stackTrace); + } - tile = _tileManager.tileAt(tile.coordinate); + void _onTileLoadComplete(TileCoordinate coordinate) { + final tile = _tileManager.tileAt(coordinate); if (tile == null) return; + if (!_tileManager.allLoaded) return; - if (widget.fastReplace && mounted) { - setState(() { - tile!.active = true; - - if (_tileManager.allLoaded) { - // We're not waiting for anything, prune the tiles immediately. - _tileManager.prune(widget.evictErrorTileStrategy); - } - }); - return; - } - - final fadeInStart = tile.loaded == null - ? widget.tileFadeInStart - : widget.tileFadeInStartWhenOverride; - tile.loaded = DateTime.now(); - if (widget.tileFadeInDuration == null || - fadeInStart == 1.0 || - (tile.loadError && null == widget.errorImage)) { - tile.active = true; + if (widget.fastReplace) { + // We're not waiting for anything, prune the tiles immediately. + _tileManager.prune(widget.evictErrorTileStrategy); } else { - tile.startFadeInAnimation(from: fadeInStart); - } - - if (mounted) { - setState(() {}); - } - - if (_tileManager.allLoaded) { // 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( - widget.tileFadeInDuration != null - ? widget.tileFadeInDuration! + const Duration(milliseconds: 50) - : const Duration(milliseconds: 50), + widget.tileFadeIn == null + ? const Duration(milliseconds: 50) + : widget.tileFadeIn!.duration + const Duration(milliseconds: 50), () => _tileManager.prune(widget.evictErrorTileStrategy), ); } diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 37350aaf9..119614dd2 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -16,7 +16,11 @@ enum EvictErrorTileStrategy { notVisible, } -typedef ErrorTileCallBack = void Function(Tile tile, dynamic error); +typedef ErrorTileCallBack = void Function( + TileImage tile, + Object error, + StackTrace? stackTrace, +); class WMSTileLayerOptions { final service = 'WMS'; @@ -105,3 +109,25 @@ class WMSTileLayerOptions { return buffer.toString(); } } + +class TileFadeIn { + final Duration duration; + final double startOpacity; + final double reloadStartOpacity; + + /// Options for fading in tiles when they are loaded. + const TileFadeIn({ + /// Duration of the fade. + this.duration = const Duration(milliseconds: 100), + + /// Opacity start value when a tile is faded in. Valid range is (0.0 - 0.1). + this.startOpacity = 0.0, + + /// Opacity start value when a tile is reloaded. A tile reload will occur + /// when the provider tile url changes and + /// [TileLayer.overrideTilesWhenUrlChanges] is true. + /// provider url. Valid range is (0.0 - 0.1). + this.reloadStartOpacity = 0.0, + }) : assert(startOpacity >= 0.0 && startOpacity <= 1.0), + assert(reloadStartOpacity >= 0.0 && reloadStartOpacity <= 1.0); +} diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart index 5c5927429..bd4fb8b9b 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -1,16 +1,17 @@ import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; class TileManager { - final Map _tiles = {}; + final Map _tiles = {}; - List all() => _tiles.values.toList(); + List all() => _tiles.values.toList(); - List sortedByDistanceToZoomAscending(double maxZoom, int currentZoom) { + List sortedByDistanceToZoomAscending( + double maxZoom, int currentZoom) { return [..._tiles.values]..sort((a, b) => a .zIndex(maxZoom, currentZoom) .compareTo(b.zIndex(maxZoom, currentZoom))); @@ -26,11 +27,11 @@ class TileManager { return false; } - Tile? tileAt(TileCoordinate coords) => _tiles[coords.key]; + TileImage? tileAt(TileCoordinate coords) => _tiles[coords.key]; bool get allLoaded { for (final entry in _tiles.entries) { - if (entry.value.loaded == null) { + if (entry.value.loadFinishedAt == null) { return false; } } @@ -56,7 +57,7 @@ class TileManager { } } - void add(TileCoordinate coords, Tile tile) { + void add(TileCoordinate coords, TileImage tile) { _tiles[coords.key] = tile; // This must be done after storing the Tile in the TileManager otherwise @@ -65,37 +66,32 @@ class TileManager { tile.loadTileImage(); } - void remove(String key, EvictErrorTileStrategy evictStrategy) { - final tile = _tiles[key]; - if (tile == null) { - return; - } + /// All removals should be performed by calling this method to ensure that + // disposal is performed correctly. + void _remove( + String key, { + required bool Function(TileImage tileImage) evictImageFromCache, + }) { + final removed = _tiles.remove(key); - tile.dispose( - tile.loadError && evictStrategy != EvictErrorTileStrategy.none); + if (removed != null) { + removed.dispose(evictImageFromCache: evictImageFromCache(removed)); + } + } - _tiles.remove(key); + void _removeWithDefaultEviction(String key, EvictErrorTileStrategy strategy) { + _remove( + key, + evictImageFromCache: (tileImage) => + tileImage.loadError && strategy != EvictErrorTileStrategy.none, + ); } void removeAll(EvictErrorTileStrategy evictStrategy) { - final toRemove = Map.from(_tiles); + final toRemove = Map.from(_tiles); for (final key in toRemove.keys) { - remove(key, evictStrategy); - } - } - - void removeAtZoom(double zoom, EvictErrorTileStrategy evictStrategy) { - final toRemove = []; - for (final entry in _tiles.entries) { - if (entry.value.coordinate.z != zoom) { - continue; - } - toRemove.add(entry.key); - } - - for (final key in toRemove) { - remove(key, evictStrategy); + _removeWithDefaultEviction(key, evictStrategy); } } @@ -117,18 +113,13 @@ class TileManager { for (final entry in _tiles.entries) { final tile = entry.value; - if (tile.coordinate.z != tileZoom && tile.loaded == null) { + if (tile.coordinate.z != tileZoom && tile.loadFinishedAt == null) { toRemove.add(entry.key); } } for (final key in toRemove) { - final tile = _tiles[key]!; - - tile.onTileReady = null; - tile.dispose( - tile.loadError && evictionStrategy != EvictErrorTileStrategy.none); - _tiles.remove(key); + _removeWithDefaultEviction(key, evictionStrategy); } } @@ -158,8 +149,7 @@ class TileManager { } for (final key in toRemove) { - _tiles[key]!.dispose(true); - _tiles.remove(key); + _remove(key, evictImageFromCache: (_) => true); } } else if (evictStrategy == EvictErrorTileStrategy.notVisible) { final toRemove = []; @@ -174,8 +164,7 @@ class TileManager { } for (final key in toRemove) { - _tiles[key]!.dispose(true); - _tiles.remove(key); + _remove(key, evictImageFromCache: (_) => true); } } } @@ -200,7 +189,7 @@ class TileManager { } for (final key in toRemove) { - remove(key, evictStrategy); + _removeWithDefaultEviction(key, evictStrategy); } } @@ -217,7 +206,7 @@ class TileManager { if (tile.active) { tile.retain = true; continue; - } else if (tile.loaded != null) { + } else if (tile.loadFinishedAt != null) { tile.retain = true; } } @@ -243,7 +232,7 @@ class TileManager { if (tile.active) { tile.retain = true; return true; - } else if (tile.loaded != null) { + } else if (tile.loadFinishedAt != null) { tile.retain = true; } } diff --git a/lib/src/layer/tile_layer/tile_widget.dart b/lib/src/layer/tile_layer/tile_widget.dart deleted file mode 100644 index 6ff4ff549..000000000 --- a/lib/src/layer/tile_layer/tile_widget.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; - -class AnimatedTile extends StatelessWidget { - final Tile tile; - final CustomPoint currentPixelOrigin; - final double scaledTileSize; - final ImageProvider? errorImage; - final TileBuilder? tileBuilder; - - const AnimatedTile({ - super.key, - required this.tile, - required this.currentPixelOrigin, - required this.scaledTileSize, - required this.errorImage, - required this.tileBuilder, - }); - - @override - Widget build(BuildContext context) { - final pos = tile.coordinate.multiplyBy(scaledTileSize) - currentPixelOrigin; - - Widget tileWidget; - if (tile.loadError && errorImage != null) { - tileWidget = Image(image: errorImage!); - } else if (tile.animationController == null) { - tileWidget = RawImage(image: tile.imageInfo?.image, fit: BoxFit.fill); - } else { - tileWidget = AnimatedBuilder( - animation: tile.animationController!, - builder: (context, child) => RawImage( - image: tile.imageInfo?.image, - fit: BoxFit.fill, - opacity: tile.animationController!, - ), - ); - } - - return Positioned( - left: pos.x.toDouble(), - top: pos.y.toDouble(), - width: scaledTileSize, - height: scaledTileSize, - child: tileBuilder?.call(context, tileWidget, tile) ?? tileWidget, - ); - } -} From 0a0290730e74e4f820faf8770986e6effd99b27a Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 28 Mar 2023 17:41:10 +0200 Subject: [PATCH 08/51] Rename TileManager to TileImageManager Also made a minor tidy-up in TileLayer build(). --- ...e_manager.dart => tile_image_manager.dart} | 2 +- lib/src/layer/tile_layer/tile_layer.dart | 70 +++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) rename lib/src/layer/tile_layer/{tile_manager.dart => tile_image_manager.dart} (99%) diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart similarity index 99% rename from lib/src/layer/tile_layer/tile_manager.dart rename to lib/src/layer/tile_layer/tile_image_manager.dart index bd4fb8b9b..8bef94575 100644 --- a/lib/src/layer/tile_layer/tile_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -5,7 +5,7 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -class TileManager { +class TileImageManager { final Map _tiles = {}; List all() => _tiles.values.toList(); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index c70a3767e..ef5489ee6 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -15,7 +15,7 @@ import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; @@ -302,7 +302,7 @@ class TileLayer extends StatefulWidget { class _TileLayerState extends State with TickerProviderStateMixin { bool _initializedFromMapState = false; - final TileManager _tileManager = TileManager(); + final TileImageManager _tileImageManager = TileImageManager(); late TileBounds _tileBounds; late TileScaleCalculator _tileScaleCalculator; @@ -316,7 +316,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (widget.reset != null) { _resetSub = widget.reset?.listen( - (_) => _tileManager.removeAll( + (_) => _tileImageManager.removeAll( widget.evictErrorTileStrategy, ), ); @@ -390,7 +390,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { reloadTiles = true; } - reloadTiles |= !_tileManager.allWithinZoom(widget.minZoom, widget.maxZoom); + reloadTiles |= + !_tileImageManager.allWithinZoom(widget.minZoom, widget.maxZoom); if (!reloadTiles) { final oldUrl = @@ -404,7 +405,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { !(const MapEquality()) .equals(oldOptions, newOptions)) { if (widget.overrideTilesWhenUrlChanges) { - _tileManager.reloadImages(widget, _tileBounds); + _tileImageManager.reloadImages(widget, _tileBounds); _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); } else { reloadTiles = true; @@ -413,7 +414,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { } if (reloadTiles) { - _tileManager.removeAll(widget.evictErrorTileStrategy); + _tileImageManager.removeAll(widget.evictErrorTileStrategy); _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); } } @@ -421,7 +422,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { @override void dispose() { _movementSubscription?.cancel(); - _tileManager.removeAll(widget.evictErrorTileStrategy); + _tileImageManager.removeAll(widget.evictErrorTileStrategy); _resetSub?.cancel(); _pruneLater?.cancel(); widget.tileProvider.dispose(); @@ -434,19 +435,19 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (!_outsideZoomLimits(tileZoom)) _update(mapState, tileZoom); - _tileManager.prune(widget.evictErrorTileStrategy); + _tileImageManager.prune(widget.evictErrorTileStrategy); } @override Widget build(BuildContext context) { final map = FlutterMapState.maybeOf(context)!; + final roundedMapZoom = map.zoom.round(); - if (_outsideZoomLimits(roundedMapZoom)) { - return const SizedBox.shrink(); - } + if (_outsideZoomLimits(roundedMapZoom)) return const SizedBox.shrink(); final tileZoom = _clampToNativeZoom(roundedMapZoom); - final tileImagesToRender = _tileManager.sortedByDistanceToZoomAscending( + final tileImagesToRender = + _tileImageManager.sortedByDistanceToZoomAscending( widget.maxZoom, tileZoom, ); @@ -457,24 +458,23 @@ class _TileLayerState extends State with TickerProviderStateMixin { ); _tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom); - final tileWidgets = [ - for (var tileImage in tileImagesToRender) - Tile( - key: ValueKey(tileImage.coordsKey), - tileImage: tileImage, - currentPixelOrigin: currentPixelOrigin, - scaledTileSize: _tileScaleCalculator.scaledTileSize( - map.zoom, - tileImage.coordinate.z, - ), - tileBuilder: widget.tileBuilder, - ) - ]; return Container( color: widget.backgroundColor, child: Stack( - children: tileWidgets, + children: [ + for (var tileImage in tileImagesToRender) + Tile( + key: ValueKey(tileImage.coordsKey), + tileImage: tileImage, + currentPixelOrigin: currentPixelOrigin, + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tileImage.coordinate.z, + ), + tileBuilder: widget.tileBuilder, + ) + ], ), ); } @@ -492,7 +492,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Load tiles in the grid's active zoom level according to map bounds void _update(FlutterMapState map, int tileZoom) { - _tileManager.abortLoading(tileZoom, widget.evictErrorTileStrategy); + _tileImageManager.abortLoading(tileZoom, widget.evictErrorTileStrategy); final tileLoadRange = DiscreteTileRange.fromPixelBounds( zoom: tileZoom, @@ -501,7 +501,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { )..expand(widget.panBuffer); // Mark tiles for pruning. - _tileManager.markToPrune( + _tileImageManager.markToPrune( tileZoom, tileLoadRange.expand(widget.keepBuffer), ); @@ -510,11 +510,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); final queue = tileBoundsAtZoom .validCoordinatesIn(tileLoadRange) - .where((coord) => !_tileManager.markTileWithCoordsAsCurrent(coord)) + .where((coord) => !_tileImageManager.markTileWithCoordsAsCurrent(coord)) .toList(); // Evict tiles which have been marked for pruning. - _tileManager.evictErrorTilesBasedOnStrategy( + _tileImageManager.evictErrorTilesBasedOnStrategy( tileLoadRange, widget.evictErrorTileStrategy, ); @@ -527,7 +527,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Create the new Tiles. for (final coords in queue) { - _tileManager.add( + _tileImageManager.add( coords, TileImage( vsync: this, @@ -560,13 +560,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { } void _onTileLoadComplete(TileCoordinate coordinate) { - final tile = _tileManager.tileAt(coordinate); + final tile = _tileImageManager.tileAt(coordinate); if (tile == null) return; - if (!_tileManager.allLoaded) return; + if (!_tileImageManager.allLoaded) return; if (widget.fastReplace) { // We're not waiting for anything, prune the tiles immediately. - _tileManager.prune(widget.evictErrorTileStrategy); + _tileImageManager.prune(widget.evictErrorTileStrategy); } else { // Wait a bit more than tileFadeInDuration to trigger a pruning so that // we don't see tile removal under a fading tile. @@ -575,7 +575,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { widget.tileFadeIn == null ? const Duration(milliseconds: 50) : widget.tileFadeIn!.duration + const Duration(milliseconds: 50), - () => _tileManager.prune(widget.evictErrorTileStrategy), + () => _tileImageManager.prune(widget.evictErrorTileStrategy), ); } } From 866d24cc2347638dce78b7868d4fa9dbd9315a48 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 15:09:28 +0200 Subject: [PATCH 09/51] Add TileUpdateTransformer to tile layer options This allows debouncing/filtering/modifying tile updates triggered by map movement. --- .../lib/pages/animated_map_controller.dart | 63 +++++++- lib/src/layer/tile_layer/tile_layer.dart | 139 ++++++++++++------ .../layer/tile_layer/tile_layer_options.dart | 37 +++++ .../tile_layer/tile_range_calculator.dart | 53 +++++++ 4 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_range_calculator.dart diff --git a/example/lib/pages/animated_map_controller.dart b/example/lib/pages/animated_map_controller.dart index 4dbdce2c5..25f94669c 100644 --- a/example/lib/pages/animated_map_controller.dart +++ b/example/lib/pages/animated_map_controller.dart @@ -16,6 +16,10 @@ class AnimatedMapControllerPage extends StatefulWidget { class AnimatedMapControllerPageState extends State with TickerProviderStateMixin { + static const _startedId = 'AnimatedMapController#MoveStarted'; + static const _inProgressId = 'AnimatedMapController#MoveInProgress'; + static const _finishedId = 'AnimatedMapController#MoveFinished'; + // Note the addition of the TickerProviderStateMixin here. If you are getting an error like // 'The class 'TickerProviderStateMixin' can't be used as a mixin because it extends a class other than Object.' // in your IDE, you can probably fix it by adding an analysis_options.yaml file to your project @@ -55,10 +59,29 @@ class AnimatedMapControllerPageState extends State final Animation animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + // Note this method of encoding the target destination is a workaround. + // When proper animated movement is supported (see #1263) we should be able + // to detect an appropriate animated movement event which contains the + // target zoom/center. + final startIdWithTarget = + '$_startedId#${destLocation.latitude},${destLocation.longitude},$destZoom'; + bool hasTriggeredMove = false; + controller.addListener(() { - mapController.move( - LatLng(latTween.evaluate(animation), lngTween.evaluate(animation)), - zoomTween.evaluate(animation)); + final String id; + if (animation.value == 1.0) { + id = _finishedId; + } else if (!hasTriggeredMove) { + id = startIdWithTarget; + } else { + id = _inProgressId; + } + + hasTriggeredMove |= mapController.move( + LatLng(latTween.evaluate(animation), lngTween.evaluate(animation)), + zoomTween.evaluate(animation), + id: id, + ); }); animation.addStatusListener((status) { @@ -187,6 +210,7 @@ class AnimatedMapControllerPageState extends State urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', + tileUpdateTransformer: _animatedMoveTileUpdateTransformer, ), MarkerLayer(markers: markers), ], @@ -198,3 +222,36 @@ class AnimatedMapControllerPageState extends State ); } } + +/// Causes tiles to be prefetched at the target location and disables pruning +/// whilst animating movement. When proper animated movement is added (see +/// #1263) we should just detect the appropriate AnimatedMove events and +/// use their target zoom/center. +final _animatedMoveTileUpdateTransformer = + TileUpdateTransformer.fromHandlers(handleData: (event, sink) { + final id = event is MapEventMove ? event.id : null; + if (id?.startsWith(AnimatedMapControllerPageState._startedId) == true) { + final parts = id!.split('#')[2].split(','); + final lat = double.parse(parts[0]); + final lon = double.parse(parts[1]); + final zoom = double.parse(parts[2]); + + // When animated movement starts load tiles at the target location and do + // not prune. Disabling pruning means existing tiles will remain visible + // whilst animating. + sink.add( + TileUpdateEvent.loadOnly( + loadCenterOverride: LatLng(lat, lon), + loadZoomOverride: zoom, + ), + ); + } else if (id == AnimatedMapControllerPageState._inProgressId) { + // Do not prune or load whilst animating so that any existing tiles remain + // visible. + } else if (id == AnimatedMapControllerPageState._finishedId) { + // We already prefetched the tiles when animation started so just prune. + sink.add(const TileUpdateEvent.pruneOnly()); + } else { + sink.add(const TileUpdateEvent.loadAndPrune()); + } +}); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index ef5489ee6..b688e4aac 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -19,8 +19,10 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; +import 'package:latlong2/latlong.dart'; part 'tile_layer_options.dart'; @@ -196,7 +198,7 @@ class TileLayer extends StatefulWidget { /// ``` final bool retinaMode; - /// This callback will be execute if some errors occur when fetching tiles. + /// This callback will be executed if an error occurs when fetching tiles. final ErrorTileCallBack? errorTileCallback; final TemplateFunction templateFunction; @@ -233,6 +235,12 @@ class TileLayer extends StatefulWidget { /// Only load tiles that are within these bounds final LatLngBounds? tileBounds; + /// If provided this transformer may be used to modify how/when tile updates + /// are triggered. It is a StreamTransformer from MapEvent to TilUpdateEvent + /// and therefore filtering/modifying/debouncing may be perfored based on the + /// MapEvent. + final TileUpdateTransformer? tileUpdateTransformer; + TileLayer({ super.key, this.urlTemplate, @@ -265,6 +273,7 @@ class TileLayer extends StatefulWidget { this.fastReplace = false, this.reset, this.tileBounds, + this.tileUpdateTransformer, String userAgentPackageName = 'unknown', }) : assert(tileFadeIn == null || tileFadeIn.duration > Duration.zero), maxZoom = @@ -304,9 +313,16 @@ class _TileLayerState extends State with TickerProviderStateMixin { final TileImageManager _tileImageManager = TileImageManager(); late TileBounds _tileBounds; + late TileRangeCalculator _tileRangeCalculator; late TileScaleCalculator _tileScaleCalculator; - StreamSubscription? _movementSubscription; + // Only one of these two subscriptions will be initialized. If + // TileLayer.tileUpdateTransformer is null then we subscribe to map movement + // otherwise we subscribe to tile update events which are transformed from + // map movements. + StreamSubscription? _mapMoveSubscription; + StreamSubscription? _tileUpdateSubscription; + StreamSubscription? _resetSub; Timer? _pruneLater; @@ -321,6 +337,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { ), ); } + + _tileRangeCalculator = TileRangeCalculator( + tileSize: widget.tileSize, + panBuffer: widget.panBuffer, + ); } @override @@ -329,10 +350,17 @@ class _TileLayerState extends State with TickerProviderStateMixin { final mapState = FlutterMapState.maybeOf(context)!; - _movementSubscription?.cancel(); - _movementSubscription = mapState.mapController.mapEventStream.listen( - (mapEvent) => _loadAndPruneTiles(mapState), - ); + _mapMoveSubscription?.cancel(); + _tileUpdateSubscription?.cancel(); + + if (widget.tileUpdateTransformer == null) { + _mapMoveSubscription = mapState.mapController.mapEventStream + .listen((_) => _updateTilesInVisibleBounds(mapState)); + } else { + _tileUpdateSubscription = mapState.mapController.mapEventStream + .transform(widget.tileUpdateTransformer!) + .listen((event) => _onTileUpdateEvent(mapState, event)); + } bool reloadTiles = false; if (!_initializedFromMapState || @@ -357,7 +385,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { } if (reloadTiles) { - _loadAndPruneTiles(mapState); + _updateTilesInVisibleBounds(mapState); } _initializedFromMapState = true; @@ -368,6 +396,12 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); var reloadTiles = false; + // There is no caching in TileRangeCalculator so we can just replace it. + _tileRangeCalculator = TileRangeCalculator( + tileSize: widget.tileSize, + panBuffer: widget.panBuffer, + ); + if (_tileBounds.shouldReplace( _tileBounds.crs, widget.tileSize, widget.tileBounds)) { _tileBounds = TileBounds( @@ -406,7 +440,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { .equals(oldOptions, newOptions)) { if (widget.overrideTilesWhenUrlChanges) { _tileImageManager.reloadImages(widget, _tileBounds); - _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); + _updateTilesInVisibleBounds(FlutterMapState.maybeOf(context)!); } else { reloadTiles = true; } @@ -415,13 +449,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileImageManager.removeAll(widget.evictErrorTileStrategy); - _loadAndPruneTiles(FlutterMapState.maybeOf(context)!); + _updateTilesInVisibleBounds(FlutterMapState.maybeOf(context)!); } } @override void dispose() { - _movementSubscription?.cancel(); + _mapMoveSubscription?.cancel(); + _tileUpdateSubscription?.cancel(); _tileImageManager.removeAll(widget.evictErrorTileStrategy); _resetSub?.cancel(); _pruneLater?.cancel(); @@ -430,22 +465,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.dispose(); } - void _loadAndPruneTiles(FlutterMapState mapState) { - final tileZoom = _clampToNativeZoom(mapState.zoom.round()); - - if (!_outsideZoomLimits(tileZoom)) _update(mapState, tileZoom); - - _tileImageManager.prune(widget.evictErrorTileStrategy); - } - @override Widget build(BuildContext context) { final map = FlutterMapState.maybeOf(context)!; - final roundedMapZoom = map.zoom.round(); - if (_outsideZoomLimits(roundedMapZoom)) return const SizedBox.shrink(); + if (_outsideZoomLimits(map.zoom.round())) return const SizedBox.shrink(); - final tileZoom = _clampToNativeZoom(roundedMapZoom); + final tileZoom = _clampToNativeZoom(map.zoom); final tileImagesToRender = _tileImageManager.sortedByDistanceToZoomAscending( widget.maxZoom, @@ -479,27 +505,50 @@ class _TileLayerState extends State with TickerProviderStateMixin { ); } - int _clampToNativeZoom(int zoom) { - if (widget.minNativeZoom != null) { - zoom = math.max(zoom, widget.minNativeZoom!); + void _onTileUpdateEvent(FlutterMapState mapState, TileUpdateEvent event) { + if (event.load) { + final zoom = event.loadZoomOverride ?? mapState.zoom; + final center = event.loadCenterOverride ?? mapState.center; + + final tileZoom = _clampToNativeZoom(zoom); + if (!_outsideZoomLimits(tileZoom)) { + final tileRange = _tileRangeCalculator.calculate( + mapState: mapState, + tileZoom: tileZoom, + center: center, + viewingZoom: zoom, + ); + _loadTilesAndMarkForPruning(tileRange); + } } - if (widget.maxNativeZoom != null) { - zoom = math.min(zoom, widget.maxNativeZoom!); + + if (event.prune) { + _tileImageManager.prune(widget.evictErrorTileStrategy); + } + } + + // Load new tiles in the visible bounds and prune those outside. + void _updateTilesInVisibleBounds(FlutterMapState mapState) { + final tileZoom = _clampToNativeZoom(mapState.zoom); + if (!_outsideZoomLimits(tileZoom)) { + _loadTilesAndMarkForPruning( + _tileRangeCalculator.calculate( + mapState: mapState, + tileZoom: tileZoom, + ), + ); } - return zoom; + _tileImageManager.prune(widget.evictErrorTileStrategy); } - // Load tiles in the grid's active zoom level according to map bounds - void _update(FlutterMapState map, int tileZoom) { + // Loads tiles which overlap the [pixelBounds] at the [tileZoom] extended by + // [TileLayer.panBuffer]. Tiles which are outside of this range are marked + // for pruning, taking in to account the [TileLayer.keepBuffer]. + void _loadTilesAndMarkForPruning(DiscreteTileRange tileLoadRange) { + final tileZoom = tileLoadRange.zoom; _tileImageManager.abortLoading(tileZoom, widget.evictErrorTileStrategy); - final tileLoadRange = DiscreteTileRange.fromPixelBounds( - zoom: tileZoom, - tileSize: widget.tileSize, - pixelBounds: _visiblePixelBoundsAtZoom(map, tileZoom.toDouble()), - )..expand(widget.panBuffer); - // Mark tiles for pruning. _tileImageManager.markToPrune( tileZoom, @@ -513,7 +562,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { .where((coord) => !_tileImageManager.markTileWithCoordsAsCurrent(coord)) .toList(); - // Evict tiles which have been marked for pruning. + // Evict error tiles according to the eviction strategy. _tileImageManager.evictErrorTilesBasedOnStrategy( tileLoadRange, widget.evictErrorTileStrategy, @@ -545,13 +594,19 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } - /// Returns the bounds of the visible pixels at the target [zoom]. - Bounds _visiblePixelBoundsAtZoom(FlutterMapState map, double zoom) { - final scale = map.getZoomScale(map.zoom, zoom); - final pixelCenter = map.project(map.center, zoom).floor(); - final halfSize = map.size / (scale * 2); + // Rounds the zoom to the nearest int and clamps it to the native zoom limits + // if there are any. + int _clampToNativeZoom(double zoom) { + int result = zoom.round(); + + if (widget.minNativeZoom != null) { + result = math.max(result, widget.minNativeZoom!); + } + if (widget.maxNativeZoom != null) { + result = math.min(result, widget.maxNativeZoom!); + } - return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); + return result; } void _onTileLoadError(TileImage tile, Object error, StackTrace? stackTrace) { diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 119614dd2..d4771af15 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -131,3 +131,40 @@ class TileFadeIn { }) : assert(startOpacity >= 0.0 && startOpacity <= 1.0), assert(reloadStartOpacity >= 0.0 && reloadStartOpacity <= 1.0); } + +typedef TileUpdateTransformer = StreamTransformer; + +/// Describes whether loading and/or pruning should occur and allows overriding +/// the load center/zoom. If loading/pruning is not desired the +/// [TileUpdateTransformer] should just not add a TileUpdateEvent to its sink. +class TileUpdateEvent { + final bool load; + final bool prune; + final LatLng? loadCenterOverride; + final double? loadZoomOverride; + + /// Do not load new tiles, only prune old ones. + const TileUpdateEvent.pruneOnly() + : load = false, + prune = true, + loadCenterOverride = null, + loadZoomOverride = null; + + /// Load new tiles, do not prune old ones. The loading center/zoom can be + /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they + /// will default to the map's current center/zoom. + const TileUpdateEvent.loadOnly({ + this.loadCenterOverride, + this.loadZoomOverride, + }) : load = true, + prune = false; + + /// Load new tiles and prune old ones. The loading center/zoom can be + /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they + /// will default to the map's current center/zoom. + const TileUpdateEvent.loadAndPrune({ + this.loadCenterOverride, + this.loadZoomOverride, + }) : load = true, + prune = true; +} diff --git a/lib/src/layer/tile_layer/tile_range_calculator.dart b/lib/src/layer/tile_layer/tile_range_calculator.dart new file mode 100644 index 000000000..790f38cf5 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_range_calculator.dart @@ -0,0 +1,53 @@ +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/map/flutter_map_state.dart'; +import 'package:latlong2/latlong.dart'; + +class TileRangeCalculator { + final double tileSize; + final int panBuffer; + + const TileRangeCalculator({ + required this.tileSize, + required this.panBuffer, + }); + + /// Calculates the visible pixel bounds at the [tileZoom] zoom level when + /// viewing the map from the [viewingZoom] centered at the [center]. The + /// resulting tile range is expanded by [panBuffer]. + DiscreteTileRange calculate({ + // The map state used to calculate the bounds. + required FlutterMapState mapState, + // The zoom level at which the bounds should be calculated. + required int tileZoom, + // The center from which the map is viewed, defaults to [mapState.center]. + LatLng? center, + // The zoom from which the map is viewed, defaults to [mapState.zoom]. + double? viewingZoom, + }) { + return DiscreteTileRange.fromPixelBounds( + zoom: tileZoom, + tileSize: tileSize, + pixelBounds: _calculatePixelBounds( + mapState, + center ?? mapState.center, + viewingZoom ?? mapState.zoom, + tileZoom, + ), + )..expand(panBuffer); + } + + Bounds _calculatePixelBounds( + FlutterMapState mapState, + LatLng center, + double viewingZoom, + int tileZoom, + ) { + final tileZoomDouble = tileZoom.toDouble(); + final scale = mapState.getZoomScale(viewingZoom, tileZoomDouble); + final pixelCenter = mapState.project(center, tileZoomDouble).floor(); + final halfSize = mapState.size / (scale * 2); + + return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); + } +} From 49b5e84091eec56c9afcdb27fea68a450eaa1af7 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 15:10:25 +0200 Subject: [PATCH 10/51] Avoid notifying listeners once a tile has been disposed --- lib/src/layer/tile_layer/tile_image.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 3a7f04fa7..239e0ac97 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -122,7 +122,7 @@ class TileImage extends ChangeNotifier { if (fadeIn == null || (loadError && errorImage != null)) { _active = true; - notifyListeners(); + if (!_disposed) notifyListeners(); return; } @@ -131,14 +131,14 @@ class TileImage extends ChangeNotifier { if (fadeStartOpacity == 1.0) { _active = true; - notifyListeners(); + if (!_disposed) notifyListeners(); return; } animationController!.reset(); animationController!.forward(from: fadeStartOpacity).then((_) { _active = true; - notifyListeners(); + if (!_disposed) notifyListeners(); }); } From 8a687df292d58069527d08c484ecd01ab70be42b Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 15:14:01 +0200 Subject: [PATCH 11/51] Remove note about maybe needing to enable super mixins Super mixins are enabled by default since flutter 2.1 which is below flutter_map's package minimum requirement. See this comment and thread for why this note was there and why it can be removed. https://github.com/flutter/flutter/issues/14317#issuecomment-446242762 --- example/lib/pages/animated_map_controller.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/example/lib/pages/animated_map_controller.dart b/example/lib/pages/animated_map_controller.dart index 25f94669c..4811a1cd4 100644 --- a/example/lib/pages/animated_map_controller.dart +++ b/example/lib/pages/animated_map_controller.dart @@ -20,16 +20,6 @@ class AnimatedMapControllerPageState extends State static const _inProgressId = 'AnimatedMapController#MoveInProgress'; static const _finishedId = 'AnimatedMapController#MoveFinished'; - // Note the addition of the TickerProviderStateMixin here. If you are getting an error like - // 'The class 'TickerProviderStateMixin' can't be used as a mixin because it extends a class other than Object.' - // in your IDE, you can probably fix it by adding an analysis_options.yaml file to your project - // with the following content: - // analyzer: - // language: - // enableSuperMixins: true - // See https://github.com/flutter/flutter/issues/14317#issuecomment-361085869 - // This project didn't require that change, so YMMV. - static final london = LatLng(51.5, -0.09); static final paris = LatLng(48.8566, 2.3522); static final dublin = LatLng(53.3498, -6.2603); From 7105833fee4d49a360431299728618c0f60a8441 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 15:33:06 +0200 Subject: [PATCH 12/51] Remove TileCoordinate export. I don't see why it would be needed outside of this project. --- lib/flutter_map.dart | 1 - test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart | 1 + test/layer/tile_layer/tile_range_test.dart | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 7f31580b1..5da271b7c 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -31,7 +31,6 @@ export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; diff --git a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart index 4d36d9309..49b072a5d 100644 --- a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart +++ b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_map/plugin_api.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_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:test/test.dart'; import 'package:tuple/tuple.dart'; diff --git a/test/layer/tile_layer/tile_range_test.dart b/test/layer/tile_layer/tile_range_test.dart index ee180625d..81c57e40a 100644 --- a/test/layer/tile_layer/tile_range_test.dart +++ b/test/layer/tile_layer/tile_range_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:test/test.dart'; From dfe53cbedb9de241e29c7b5c3a61854a358c99c4 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 15:35:37 +0200 Subject: [PATCH 13/51] Revert changes which were no longer needed --- lib/src/core/bounds.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/src/core/bounds.dart b/lib/src/core/bounds.dart index 26590ded4..b27379f1f 100644 --- a/lib/src/core/bounds.dart +++ b/lib/src/core/bounds.dart @@ -54,16 +54,13 @@ class Bounds { return max - min; } - // The area of these bounds - num get area => (max.x - min.x) * (max.y - min.y); - - bool contains(CustomPoint point) { + bool contains(CustomPoint point) { final min = point; final max = point; return containsBounds(Bounds(min, max)); } - bool containsBounds(Bounds b) { + bool containsBounds(Bounds b) { return (b.min.x >= min.x) && (b.max.x <= max.x) && (b.min.y >= min.y) && From 96969e40549a4bb1b45c5d8bf45581530b3052c7 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 29 Mar 2023 17:43:47 +0200 Subject: [PATCH 14/51] Ignore Podfile in example since it gets generated when running the app --- example/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/example/.gitignore b/example/.gitignore index 5141725b7..0df6ad0a5 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -30,6 +30,10 @@ .pub-cache/ .pub/ /build/ + +# Gets generated when running the app and we don't need anything special in the +# Podfile so we can ignore it and let flutter generate it. +ios/Podfile # TODO: document why we don't want this file pubspec.lock From 898eb1c80079d74eb571d63cc5a4a8dad4824263 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Fri, 31 Mar 2023 10:17:24 +0200 Subject: [PATCH 15/51] Add tile scale calculations to the cache --- lib/src/layer/tile_layer/tile_scale_calculator.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/layer/tile_layer/tile_scale_calculator.dart b/lib/src/layer/tile_layer/tile_scale_calculator.dart index 8cb17210f..ef0b118cc 100644 --- a/lib/src/layer/tile_layer/tile_scale_calculator.dart +++ b/lib/src/layer/tile_layer/tile_scale_calculator.dart @@ -29,7 +29,10 @@ class TileScaleCalculator { /// Returns a scale value to transform a Tile coordainte to a Tile position. double scaledTileSize(double currentZoom, int tileZoom) { assert(_cachedCurrentZoom == currentZoom); - return _scaledTileSizeImpl(currentZoom, tileZoom); + return _cache.putIfAbsent( + tileZoom, + () => _scaledTileSizeImpl(currentZoom, tileZoom), + ); } double _scaledTileSizeImpl(double currentZoom, int tileZoom) { From eda494a6b9c4813ae0a380d0adc9981f8658cc34 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Fri, 31 Mar 2023 21:01:20 +0200 Subject: [PATCH 16/51] Create missing TileImages during build See the code comments, this avoids a scenario where tiles are missing because a given map movement triggers a TileLayer rebuild before the tile loading is triggered. I added a lot of comments in passing. --- lib/src/layer/tile_layer/tile.dart | 8 +- lib/src/layer/tile_layer/tile_image.dart | 14 +- .../layer/tile_layer/tile_image_manager.dart | 111 ++++---- lib/src/layer/tile_layer/tile_layer.dart | 262 +++++++++++------- .../tile_layer/tile_range_calculator.dart | 8 +- 5 files changed, 233 insertions(+), 170 deletions(-) diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index ae0e48955..0dc936fa9 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -5,15 +5,15 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; class Tile extends StatefulWidget { final TileImage tileImage; - final CustomPoint currentPixelOrigin; - final double scaledTileSize; final TileBuilder? tileBuilder; + final double scaledTileSize; + final CustomPoint currentPixelOrigin; const Tile({ super.key, - required this.tileImage, - required this.currentPixelOrigin, required this.scaledTileSize, + required this.currentPixelOrigin, + required this.tileImage, required this.tileBuilder, }); diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 239e0ac97..764f8ead6 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -3,6 +3,8 @@ import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; class TileImage extends ChangeNotifier { + bool _disposed = false; + /// The z of the coordinate is the TileImage's zoom level whilst the x and y /// indicate the position of the tile at that zoom level. final TileCoordinate coordinate; @@ -25,13 +27,15 @@ class TileImage extends ChangeNotifier { /// An optional image to show when a loading error occurs. final ImageProvider? errorImage; - bool _disposed = false; - ImageProvider imageProvider; - /// If false this TileImage will be pruned when the next prune is run. + /// 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 with full opacity. This means that either: @@ -73,10 +77,12 @@ class TileImage extends ChangeNotifier { bool get active => _active; + // Used to sort TileImages by their distance from the current zoom. double zIndex(double maxZoom, int currentZoom) => maxZoom - (currentZoom - coordinate.z).abs(); - void loadTileImage() { + // Initiate loading of the image. + void load() { loadStarted = DateTime.now(); try { diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index 8bef94575..edfc7379a 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -1,41 +1,49 @@ +import 'package:collection/collection.dart'; import 'package:flutter_map/src/core/point.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_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +typedef TileCreator = TileImage Function(TileCoordinate coordinate); + class TileImageManager { final Map _tiles = {}; - List all() => _tiles.values.toList(); + bool containsTileAt(TileCoordinate coords) => _tiles.containsKey(coords.key); - List sortedByDistanceToZoomAscending( - double maxZoom, int currentZoom) { - return [..._tiles.values]..sort((a, b) => a - .zIndex(maxZoom, currentZoom) - .compareTo(b.zIndex(maxZoom, currentZoom))); - } + bool get allLoaded => + _tiles.values.none((tile) => tile.loadFinishedAt == null); - bool anyWithZoomLevel(double zoomLevel) { - for (final tile in _tiles.values) { - if (tile.coordinate.z == zoomLevel) { - return true; - } - } + // Returns in the order in which they should be rendered: + // 1. Tiles at the current zoom. + // 2. Tiles at the current zoom +/- 1. + // 3. Tiles at the current zoom +/- 2. + // 4. ...etc + List inRenderOrder(double maxZoom, int currentZoom) { + final result = _tiles.values.toList() + ..sort((a, b) => a + .zIndex(maxZoom, currentZoom) + .compareTo(b.zIndex(maxZoom, currentZoom))); - return false; + return result; } - TileImage? tileAt(TileCoordinate coords) => _tiles[coords.key]; - - bool get allLoaded { - for (final entry in _tiles.entries) { - if (entry.value.loadFinishedAt == null) { - return false; - } + // Creates missing tiles in the given range. Does not initiate loading of the + // tiles. + void createMissingTiles( + DiscreteTileRange tileRange, + TileBoundsAtZoom tileBoundsAtZoom, { + required TileCreator createTileImage, + }) { + for (final coordinate in tileBoundsAtZoom.validCoordinatesIn(tileRange)) { + _tiles.putIfAbsent( + coordinate.key, + () => createTileImage(coordinate), + ); } - return true; } bool allWithinZoom(double minZoom, double maxZoom) { @@ -47,23 +55,27 @@ class TileImageManager { return true; } - bool markTileWithCoordsAsCurrent(TileCoordinate coords) { - final tile = _tiles[coords.key]; - if (tile != null) { + // For each coordinate: + // * 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( + Iterable coordinates, { + required TileCreator createTile, + }) { + final notLoaded = []; + + for (final coordinate in coordinates) { + final tile = _tiles.putIfAbsent( + coordinate.key, + () => createTile(coordinate), + ); + tile.current = true; - return true; - } else { - return false; + if (tile.loadStarted == null) notLoaded.add(tile); } - } - - void add(TileCoordinate coords, TileImage tile) { - _tiles[coords.key] = tile; - // This must be done after storing the Tile in the TileManager otherwise - // the callbacks for image load success/fail will not find this Tile in - // the TileManager. - tile.loadTileImage(); + return notLoaded; } /// All removals should be performed by calling this method to ensure that @@ -104,26 +116,12 @@ class TileImageManager { tileBounds.atZoom(tile.coordinate.z).wrap(tile.coordinate), layer, ); - tile.loadTileImage(); - } - } - - void abortLoading(int? tileZoom, EvictErrorTileStrategy evictionStrategy) { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (tile.coordinate.z != tileZoom && tile.loadFinishedAt == null) { - toRemove.add(entry.key); - } - } - - for (final key in toRemove) { - _removeWithDefaultEviction(key, evictionStrategy); + tile.load(); } } - void markToPrune(int currentTileZoom, DiscreteTileRange noPruneRange) { + void markAsNoLongerCurrentOutside( + int currentTileZoom, DiscreteTileRange noPruneRange) { for (final entry in _tiles.entries) { final tile = entry.value; final c = tile.coordinate; @@ -136,8 +134,11 @@ class TileImageManager { } } - void evictErrorTilesBasedOnStrategy( - DiscreteTileRange tileRange, EvictErrorTileStrategy evictStrategy) { + // Evicts error tiles depending on the [evictStrategy]. + void evictErrorTiles( + DiscreteTileRange tileRange, + EvictErrorTileStrategy evictStrategy, + ) { if (evictStrategy == EvictErrorTileStrategy.notVisibleRespectMargin) { final toRemove = []; for (final entry in _tiles.entries) { diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index b688e4aac..cc3feb92c 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -4,20 +4,14 @@ import 'dart:math' as math; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/bounds.dart'; -import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/core/util.dart' as util; -import 'package:flutter_map/src/geo/crs/crs.dart'; -import 'package:flutter_map/src/geo/latlng_bounds.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_builder.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_coordinate.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; @@ -316,6 +310,12 @@ class _TileLayerState extends State with TickerProviderStateMixin { late TileRangeCalculator _tileRangeCalculator; late TileScaleCalculator _tileScaleCalculator; + // We have to hold on to the mapController hashCode to determine whether we + // need to reinitialize the listeners. didChangeDependencies is called on + // every map movement and if we unsubscribe and resubscribe every time we + // miss events. + int? _mapControllerHashCode; + // Only one of these two subscriptions will be initialized. If // TileLayer.tileUpdateTransformer is null then we subscribe to map movement // otherwise we subscribe to tile update events which are transformed from @@ -338,28 +338,32 @@ class _TileLayerState extends State with TickerProviderStateMixin { ); } - _tileRangeCalculator = TileRangeCalculator( - tileSize: widget.tileSize, - panBuffer: widget.panBuffer, - ); + _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); } + // This is called on every map movement so we should avoid expensive logic + // where possible. @override void didChangeDependencies() { super.didChangeDependencies(); final mapState = FlutterMapState.maybeOf(context)!; - _mapMoveSubscription?.cancel(); - _tileUpdateSubscription?.cancel(); - - if (widget.tileUpdateTransformer == null) { - _mapMoveSubscription = mapState.mapController.mapEventStream - .listen((_) => _updateTilesInVisibleBounds(mapState)); - } else { - _tileUpdateSubscription = mapState.mapController.mapEventStream - .transform(widget.tileUpdateTransformer!) - .listen((event) => _onTileUpdateEvent(mapState, event)); + final mapController = mapState.mapController; + if (_mapControllerHashCode != mapController.hashCode) { + _mapMoveSubscription?.cancel(); + _tileUpdateSubscription?.cancel(); + + _mapControllerHashCode = mapController.hashCode; + if (widget.tileUpdateTransformer == null) { + _mapMoveSubscription = mapController.mapEventStream.listen((event) { + _loadAndPruneInVisibleBounds(mapState); + }); + } else { + _tileUpdateSubscription = mapController.mapEventStream + .transform(widget.tileUpdateTransformer!) + .listen((event) => _onTileUpdateEvent(mapState, event)); + } } bool reloadTiles = false; @@ -385,7 +389,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { } if (reloadTiles) { - _updateTilesInVisibleBounds(mapState); + _tryWaitForSizeToBeInitialized( + mapState, + () => _loadAndPruneInVisibleBounds(mapState), + ); } _initializedFromMapState = true; @@ -397,10 +404,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { var reloadTiles = false; // There is no caching in TileRangeCalculator so we can just replace it. - _tileRangeCalculator = TileRangeCalculator( - tileSize: widget.tileSize, - panBuffer: widget.panBuffer, - ); + _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); if (_tileBounds.shouldReplace( _tileBounds.crs, widget.tileSize, widget.tileBounds)) { @@ -424,8 +428,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { reloadTiles = true; } - reloadTiles |= - !_tileImageManager.allWithinZoom(widget.minZoom, widget.maxZoom); + if (oldWidget.minZoom != widget.minZoom || + oldWidget.maxZoom != widget.maxZoom) { + reloadTiles |= + !_tileImageManager.allWithinZoom(widget.minZoom, widget.maxZoom); + } if (!reloadTiles) { final oldUrl = @@ -440,7 +447,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { .equals(oldOptions, newOptions)) { if (widget.overrideTilesWhenUrlChanges) { _tileImageManager.reloadImages(widget, _tileBounds); - _updateTilesInVisibleBounds(FlutterMapState.maybeOf(context)!); + _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); } else { reloadTiles = true; } @@ -449,7 +456,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileImageManager.removeAll(widget.evictErrorTileStrategy); - _updateTilesInVisibleBounds(FlutterMapState.maybeOf(context)!); + _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); } } @@ -472,10 +479,26 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (_outsideZoomLimits(map.zoom.round())) return const SizedBox.shrink(); final tileZoom = _clampToNativeZoom(map.zoom); - final tileImagesToRender = - _tileImageManager.sortedByDistanceToZoomAscending( - widget.maxZoom, - tileZoom, + final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); + final visibleTileRange = _tileRangeCalculator.calculate( + mapState: map, + tileZoom: tileZoom, + ); + + // For a given map event both this rebuild method and the tile + // loading/pruning logic will be fired. Any TileImages which are not + // rendered in a corresponding Tile after this build will not become + // 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. + _tileImageManager.createMissingTiles( + visibleTileRange, + tileBoundsAtZoom, + createTileImage: (coordinate) => _createTileImage( + coordinate, + tileBoundsAtZoom, + ), ); final currentPixelOrigin = CustomPoint( @@ -489,108 +512,122 @@ class _TileLayerState extends State with TickerProviderStateMixin { color: widget.backgroundColor, child: Stack( children: [ - for (var tileImage in tileImagesToRender) - Tile( + ..._tileImageManager + .inRenderOrder(widget.maxZoom, tileZoom) + .map((tileImage) { + return Tile( key: ValueKey(tileImage.coordsKey), - tileImage: tileImage, - currentPixelOrigin: currentPixelOrigin, scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, tileImage.coordinate.z, ), + currentPixelOrigin: currentPixelOrigin, + tileImage: tileImage, tileBuilder: widget.tileBuilder, - ) + ); + }), ], ), ); } + TileImage _createTileImage( + TileCoordinate coordinate, + TileBoundsAtZoom tileBoundsAtZoom, + ) { + return TileImage( + vsync: this, + coordinate: coordinate, + imageProvider: widget.tileProvider.getImage( + tileBoundsAtZoom.wrap(coordinate), + widget, + ), + onLoadError: _onTileLoadError, + onLoadComplete: _onTileLoadComplete, + fadeIn: widget.fastReplace ? null : widget.tileFadeIn, + errorImage: widget.errorImage, + ); + } + + // Load and/or prune tiles according to the visible bounds of the [event] + // center/zoom, or the current center/zoom if not specified. void _onTileUpdateEvent(FlutterMapState mapState, TileUpdateEvent event) { + final zoom = event.loadZoomOverride ?? mapState.zoom; + final center = event.loadCenterOverride ?? mapState.center; + final tileZoom = _clampToNativeZoom(zoom); + final visibleTileRange = _tileRangeCalculator.calculate( + mapState: mapState, + tileZoom: tileZoom, + center: center, + viewingZoom: zoom, + ); + if (event.load) { - final zoom = event.loadZoomOverride ?? mapState.zoom; - final center = event.loadCenterOverride ?? mapState.center; - - final tileZoom = _clampToNativeZoom(zoom); - if (!_outsideZoomLimits(tileZoom)) { - final tileRange = _tileRangeCalculator.calculate( - mapState: mapState, - tileZoom: tileZoom, - center: center, - viewingZoom: zoom, - ); - _loadTilesAndMarkForPruning(tileRange); - } + if (!_outsideZoomLimits(tileZoom)) _loadTiles(visibleTileRange); } if (event.prune) { + _tileImageManager.evictErrorTiles( + visibleTileRange, widget.evictErrorTileStrategy); _tileImageManager.prune(widget.evictErrorTileStrategy); } } // Load new tiles in the visible bounds and prune those outside. - void _updateTilesInVisibleBounds(FlutterMapState mapState) { + void _loadAndPruneInVisibleBounds(FlutterMapState mapState) { final tileZoom = _clampToNativeZoom(mapState.zoom); - if (!_outsideZoomLimits(tileZoom)) { - _loadTilesAndMarkForPruning( - _tileRangeCalculator.calculate( - mapState: mapState, - tileZoom: tileZoom, - ), - ); - } + final visibleTileRange = _tileRangeCalculator.calculate( + mapState: mapState, + tileZoom: tileZoom, + ); + + if (!_outsideZoomLimits(tileZoom)) _loadTiles(visibleTileRange); + _tileImageManager.evictErrorTiles( + visibleTileRange, widget.evictErrorTileStrategy); _tileImageManager.prune(widget.evictErrorTileStrategy); } - // Loads tiles which overlap the [pixelBounds] at the [tileZoom] extended by - // [TileLayer.panBuffer]. Tiles which are outside of this range are marked - // for pruning, taking in to account the [TileLayer.keepBuffer]. - void _loadTilesAndMarkForPruning(DiscreteTileRange tileLoadRange) { + // For each valid coordinate in the [tileLoadRange], expanded by the + // [TileLayer.panBuffer], this method will do the following depending on + // whether a matching TileImage already exists or not: + // * Exists: Mark it as current and initiate image loading if it has not + // already been initiated. + // * Does not exist: Creates the TileImage (they are current when created) + // and initiates loading. + // + // Additionally, any current TileImages outside of the [tileLoadRange], + // expanded by the [TileLayer.panBuffer] + [TileLayer.keepBuffer], are marked + // as not current. + void _loadTiles(DiscreteTileRange tileLoadRange) { final tileZoom = tileLoadRange.zoom; - _tileImageManager.abortLoading(tileZoom, widget.evictErrorTileStrategy); + tileLoadRange = tileLoadRange.expand(widget.panBuffer); - // Mark tiles for pruning. - _tileImageManager.markToPrune( + // 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. Unmarks queued tiles for pruning. + // Build the queue of tiles to load. Marks all tiles with valid coordinates + // in the tileLoadRange as current. final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); - final queue = tileBoundsAtZoom - .validCoordinatesIn(tileLoadRange) - .where((coord) => !_tileImageManager.markTileWithCoordsAsCurrent(coord)) - .toList(); - - // Evict error tiles according to the eviction strategy. - _tileImageManager.evictErrorTilesBasedOnStrategy( - tileLoadRange, - widget.evictErrorTileStrategy, - ); + final tilesToLoad = _tileImageManager.setCurrentAndReturnNotLoadedTiles( + tileBoundsAtZoom.validCoordinatesIn(tileLoadRange), + createTile: (coordinate) => + _createTileImage(coordinate, tileBoundsAtZoom)); - // Sort the queued tiles by their distance to the center. + // Re-order the tiles by their distance to the center of the range. final tileCenter = tileLoadRange.center; - queue.sort( - (a, b) => a.distanceTo(tileCenter).compareTo(b.distanceTo(tileCenter)), + tilesToLoad.sort( + (a, b) => a.coordinate + .distanceTo(tileCenter) + .compareTo(b.coordinate.distanceTo(tileCenter)), ); // Create the new Tiles. - for (final coords in queue) { - _tileImageManager.add( - coords, - TileImage( - vsync: this, - coordinate: coords, - imageProvider: widget.tileProvider.getImage( - tileBoundsAtZoom.wrap(coords), - widget, - ), - onLoadError: _onTileLoadError, - onLoadComplete: _onTileLoadComplete, - fadeIn: widget.fastReplace ? null : widget.tileFadeIn, - errorImage: widget.errorImage, - ), - ); + for (final tile in tilesToLoad) { + tile.load(); } } @@ -614,9 +651,9 @@ 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(TileCoordinate coordinate) { - final tile = _tileImageManager.tileAt(coordinate); - if (tile == null) return; + if (!_tileImageManager.containsTileAt(coordinate)) return; // Already pruned if (!_tileImageManager.allLoaded) return; if (widget.fastReplace) { @@ -637,4 +674,27 @@ class _TileLayerState extends State with TickerProviderStateMixin { bool _outsideZoomLimits(num zoom) => zoom < widget.minZoom || zoom > widget.maxZoom; + + // A workaround for the fact that FlutterMapState size initialization has a + // race condition where sometimes the size is set to CustomPoint(0,0) before + // it is set to the correct value. When this occurs, code that relies on the + // visible bounds will not work correctly. Sometimes it requires more than + // one postFrameCallback to get a non zero size. + void _tryWaitForSizeToBeInitialized( + FlutterMapState mapState, + VoidCallback callback, { + int retries = 3, + }) { + if (retries >= 0 && mapState.size == const CustomPoint(0, 0)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _tryWaitForSizeToBeInitialized( + mapState, + callback, + retries: retries - 1, + ); + }); + } else { + callback(); + } + } } diff --git a/lib/src/layer/tile_layer/tile_range_calculator.dart b/lib/src/layer/tile_layer/tile_range_calculator.dart index 790f38cf5..bcc9cb1d8 100644 --- a/lib/src/layer/tile_layer/tile_range_calculator.dart +++ b/lib/src/layer/tile_layer/tile_range_calculator.dart @@ -5,12 +5,8 @@ import 'package:latlong2/latlong.dart'; class TileRangeCalculator { final double tileSize; - final int panBuffer; - const TileRangeCalculator({ - required this.tileSize, - required this.panBuffer, - }); + const TileRangeCalculator({required this.tileSize}); /// Calculates the visible pixel bounds at the [tileZoom] zoom level when /// viewing the map from the [viewingZoom] centered at the [center]. The @@ -34,7 +30,7 @@ class TileRangeCalculator { viewingZoom ?? mapState.zoom, tileZoom, ), - )..expand(panBuffer); + ); } Bounds _calculatePixelBounds( From 64350c7bfa232c635e2b3ac86208d53c826f9fac Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Sun, 2 Apr 2023 19:47:14 +0200 Subject: [PATCH 17/51] Remove TilesContainer option since wrapping the TileLayer with the desired widget has the same behaviour --- example/lib/pages/tile_builder_example.dart | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/example/lib/pages/tile_builder_example.dart b/example/lib/pages/tile_builder_example.dart index d55bf803a..3741ad0a6 100644 --- a/example/lib/pages/tile_builder_example.dart +++ b/example/lib/pages/tile_builder_example.dart @@ -125,13 +125,13 @@ class _TileBuilderPageState extends State { zoom: 5, ), children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileBuilder: tileBuilder, - tilesContainerBuilder: - darkMode ? darkModeTilesContainerBuilder : null, - panBuffer: panBuffer, + _darkModeContainerIfEnabled( + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'dev.fleaflet.flutter_map.example', + tileBuilder: tileBuilder, + panBuffer: panBuffer, + ), ), MarkerLayer( markers: [ @@ -150,4 +150,10 @@ class _TileBuilderPageState extends State { ), ); } + + Widget _darkModeContainerIfEnabled(Widget child) { + if (!darkMode) return child; + + return darkModeTilesContainerBuilder(context, child); + } } From 716b165774b3743c255b1567b6e1249199b33766 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Sun, 2 Apr 2023 19:47:27 +0200 Subject: [PATCH 18/51] Remove TilesContainer option since wrapping the TileLayer with the desired widget has the same behaviour --- lib/src/layer/tile_layer/tile_builder.dart | 4 ---- lib/src/layer/tile_layer/tile_layer.dart | 5 ----- 2 files changed, 9 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_builder.dart b/lib/src/layer/tile_layer/tile_builder.dart index f47e9e9bb..81f4bb202 100644 --- a/lib/src/layer/tile_layer/tile_builder.dart +++ b/lib/src/layer/tile_layer/tile_builder.dart @@ -4,14 +4,10 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; typedef TileBuilder = Widget Function( BuildContext context, Widget tileWidget, TileImage tile); -typedef TilesContainerBuilder = Widget Function( - BuildContext context, Widget tilesContainer, List tiles); - /// Applies inversion color matrix on Tiles container which may simulate Dark mode. Widget darkModeTilesContainerBuilder( BuildContext context, Widget tilesContainer, - List tiles, ) { return ColorFiltered( colorFilter: const ColorFilter.matrix([ diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index cc3feb92c..7c2237d0c 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -201,10 +201,6 @@ class TileLayer extends StatefulWidget { /// There are predefined examples in 'tile_builder.dart' final TileBuilder? tileBuilder; - /// Function which may wrap Tiles Container with custom Widget - /// There are predefined examples in 'tile_builder.dart' - final TilesContainerBuilder? tilesContainerBuilder; - // If a Tile was loaded with error and if strategy isn't `none` then TileProvider // will be asked to evict Image based on current strategy // (see #576 - even Error Images are cached in flutter) @@ -262,7 +258,6 @@ class TileLayer extends StatefulWidget { this.errorTileCallback, this.templateFunction = util.template, this.tileBuilder, - this.tilesContainerBuilder, this.evictErrorTileStrategy = EvictErrorTileStrategy.none, this.fastReplace = false, this.reset, From e0a6a0ca4cf127aed7765d8ac98cf51de7146cd5 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Sun, 2 Apr 2023 19:50:17 +0200 Subject: [PATCH 19/51] Export TileCoordinate --- lib/flutter_map.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 5da271b7c..7f31580b1 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -31,6 +31,7 @@ export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; From 86ff9e81d9ec1dc6cf024e6811063acaa6c4fc07 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Mon, 3 Apr 2023 12:10:10 +0200 Subject: [PATCH 20/51] Rename TileCoordinate to TileCoordinates --- example/lib/pages/tile_builder_example.dart | 14 ++--- lib/flutter_map.dart | 2 +- lib/src/layer/tile_layer/tile.dart | 4 +- .../tile_bounds/tile_bounds_at_zoom.dart | 38 ++++++------- lib/src/layer/tile_layer/tile_builder.dart | 4 +- ..._coordinate.dart => tile_coordinates.dart} | 6 +-- lib/src/layer/tile_layer/tile_image.dart | 20 +++---- .../layer/tile_layer/tile_image_manager.dart | 37 ++++++------- lib/src/layer/tile_layer/tile_layer.dart | 29 +++++----- .../layer/tile_layer/tile_layer_options.dart | 2 +- .../tile_provider/asset_tile_provider.dart | 8 +-- .../tile_provider/base_tile_provider.dart | 34 ++++++------ .../tile_provider/file_tile_provider_io.dart | 6 +-- .../tile_provider/file_tile_provider_web.dart | 6 +-- .../tile_provider/tile_provider_io.dart | 24 ++++----- .../tile_provider/tile_provider_web.dart | 24 ++++----- lib/src/layer/tile_layer/tile_range.dart | 12 ++--- .../tile_bounds/tile_bounds_at_zoom_test.dart | 31 +++++------ test/layer/tile_layer/tile_range_test.dart | 54 ++++++++++--------- 19 files changed, 180 insertions(+), 175 deletions(-) rename lib/src/layer/tile_layer/{tile_coordinate.dart => tile_coordinates.dart} (79%) diff --git a/example/lib/pages/tile_builder_example.dart b/example/lib/pages/tile_builder_example.dart index 3741ad0a6..d32081149 100644 --- a/example/lib/pages/tile_builder_example.dart +++ b/example/lib/pages/tile_builder_example.dart @@ -15,13 +15,13 @@ class TileBuilderPage extends StatefulWidget { class _TileBuilderPageState extends State { bool darkMode = false; bool loadingTime = false; - bool showCoords = false; + bool showCoordinates = false; bool grid = false; int panBuffer = 0; // mix of [coordinateDebugTileBuilder] and [loadingTimeDebugTileBuilder] from tile_builder.dart Widget tileBuilder(BuildContext context, Widget tileWidget, TileImage tile) { - final coords = tile.coordinate; + final coords = tile.coordinates; return Container( decoration: BoxDecoration( @@ -31,11 +31,11 @@ class _TileBuilderPageState extends State { fit: StackFit.passthrough, children: [ tileWidget, - if (loadingTime || showCoords) + if (loadingTime || showCoordinates) Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (showCoords) + if (showCoordinates) Text( '${coords.x.floor()} : ${coords.y.floor()} : ${coords.z.floor()}', style: Theme.of(context).textTheme.headlineSmall, @@ -77,11 +77,11 @@ class _TileBuilderPageState extends State { FloatingActionButton.extended( heroTag: 'coords', label: Text( - showCoords ? 'Hide coords' : 'Show coords', + showCoordinates ? 'Hide coords' : 'Show coords', textAlign: TextAlign.center, ), - icon: Icon(showCoords ? Icons.unarchive : Icons.bug_report), - onPressed: () => setState(() => showCoords = !showCoords), + icon: Icon(showCoordinates ? Icons.unarchive : Icons.bug_report), + onPressed: () => setState(() => showCoordinates = !showCoordinates), ), const SizedBox(height: 8), FloatingActionButton.extended( diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 7f31580b1..9c89c08a1 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -31,7 +31,7 @@ export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 0dc936fa9..ddd6e19c1 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -41,9 +41,9 @@ class _TileState extends State { @override Widget build(BuildContext context) { return Positioned( - left: widget.tileImage.coordinate.x * widget.scaledTileSize - + left: widget.tileImage.coordinates.x * widget.scaledTileSize - widget.currentPixelOrigin.x, - top: widget.tileImage.coordinate.y * widget.scaledTileSize - + top: widget.tileImage.coordinates.y * widget.scaledTileSize - widget.currentPixelOrigin.y, width: widget.scaledTileSize, height: widget.scaledTileSize, diff --git a/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart index 3efcfaadc..4920a1f10 100644 --- a/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart +++ b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart @@ -1,24 +1,24 @@ import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:tuple/tuple.dart'; abstract class TileBoundsAtZoom { const TileBoundsAtZoom(); - TileCoordinate wrap(TileCoordinate coordinate); + TileCoordinates wrap(TileCoordinates coordinates); - Iterable validCoordinatesIn(DiscreteTileRange tileRange); + Iterable validCoordinatesIn(DiscreteTileRange tileRange); } class InfiniteTileBoundsAtZoom extends TileBoundsAtZoom { const InfiniteTileBoundsAtZoom(); @override - TileCoordinate wrap(TileCoordinate coordinate) => coordinate; + TileCoordinates wrap(TileCoordinates coordinates) => coordinates; @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) => + Iterable validCoordinatesIn(DiscreteTileRange tileRange) => tileRange.coordinates; @override @@ -31,10 +31,10 @@ class DiscreteTileBoundsAtZoom extends TileBoundsAtZoom { const DiscreteTileBoundsAtZoom(this.tileRange); @override - TileCoordinate wrap(TileCoordinate coordinate) => coordinate; + TileCoordinates wrap(TileCoordinates coordinates) => coordinates; @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) { + Iterable validCoordinatesIn(DiscreteTileRange tileRange) { assert(this.tileRange.zoom == tileRange.zoom); return this.tileRange.intersect(tileRange).coordinates; } @@ -64,14 +64,14 @@ class WrappedTileBoundsAtZoom extends TileBoundsAtZoom { }) : assert(!(wrapX == null && wrapY == null)); @override - TileCoordinate wrap(TileCoordinate coordinate) => TileCoordinate( - wrapX != null ? _wrapInt(coordinate.x, wrapX!) : coordinate.x, - wrapY != null ? _wrapInt(coordinate.y, wrapY!) : coordinate.y, - coordinate.z, + TileCoordinates wrap(TileCoordinates coordinates) => TileCoordinates( + wrapX != null ? _wrapInt(coordinates.x, wrapX!) : coordinates.x, + wrapY != null ? _wrapInt(coordinates.y, wrapY!) : coordinates.y, + coordinates.z, ); @override - Iterable validCoordinatesIn(DiscreteTileRange tileRange) { + Iterable validCoordinatesIn(DiscreteTileRange tileRange) { if (wrapX != null && wrapY != null) { if (wrappedAxisIsAlwaysInBounds) return tileRange.coordinates; @@ -100,22 +100,22 @@ class WrappedTileBoundsAtZoom extends TileBoundsAtZoom { } } - bool _wrappedBothContains(TileCoordinate coordinate) { + bool _wrappedBothContains(TileCoordinates coordinates) { return tileRange.contains( CustomPoint( - _wrapInt(coordinate.x, wrapX!), - _wrapInt(coordinate.y, wrapY!), + _wrapInt(coordinates.x, wrapX!), + _wrapInt(coordinates.y, wrapY!), ), ); } - bool _wrappedXInRange(TileCoordinate coordinate) { - final wrappedX = _wrapInt(coordinate.x, wrapX!); + bool _wrappedXInRange(TileCoordinates coordinates) { + final wrappedX = _wrapInt(coordinates.x, wrapX!); return wrappedX >= tileRange.min.x && wrappedX <= tileRange.max.y; } - bool _wrappedYInRange(TileCoordinate coordinate) { - final wrappedY = _wrapInt(coordinate.y, wrapY!); + bool _wrappedYInRange(TileCoordinates coordinates) { + final wrappedY = _wrapInt(coordinates.y, wrapY!); return wrappedY >= tileRange.min.y && wrappedY <= tileRange.max.y; } diff --git a/lib/src/layer/tile_layer/tile_builder.dart b/lib/src/layer/tile_layer/tile_builder.dart index 81f4bb202..0b42a48c6 100644 --- a/lib/src/layer/tile_layer/tile_builder.dart +++ b/lib/src/layer/tile_layer/tile_builder.dart @@ -76,9 +76,9 @@ Widget coordinateDebugTileBuilder( Widget tileWidget, TileImage tile, ) { - final coords = tile.coordinate; + final coordinates = tile.coordinates; final readableKey = - '${coords.x.floor()} : ${coords.y.floor()} : ${coords.z.floor()}'; + '${coordinates.x.floor()} : ${coordinates.y.floor()} : ${coordinates.z.floor()}'; return Container( decoration: BoxDecoration( diff --git a/lib/src/layer/tile_layer/tile_coordinate.dart b/lib/src/layer/tile_layer/tile_coordinates.dart similarity index 79% rename from lib/src/layer/tile_layer/tile_coordinate.dart rename to lib/src/layer/tile_layer/tile_coordinates.dart index 6815042c9..0d019b570 100644 --- a/lib/src/layer/tile_layer/tile_coordinate.dart +++ b/lib/src/layer/tile_layer/tile_coordinates.dart @@ -2,10 +2,10 @@ import 'dart:math'; import 'package:flutter_map/flutter_map.dart'; -class TileCoordinate extends CustomPoint { +class TileCoordinates extends CustomPoint { final int z; - const TileCoordinate(int x, int y, this.z) : super(x, y); + const TileCoordinates(int x, int y, this.z) : super(x, y); String get key => '$x:$y:$z'; @@ -14,7 +14,7 @@ class TileCoordinate extends CustomPoint { @override bool operator ==(Object other) { - if (other is! TileCoordinate) return false; + if (other is! TileCoordinates) return false; return x == other.x && y == other.y && z == other.z; } diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 764f8ead6..dedb664f9 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; class TileImage extends ChangeNotifier { @@ -7,13 +7,13 @@ class TileImage extends ChangeNotifier { /// The z of the coordinate is the TileImage's zoom level whilst the x and y /// indicate the position of the tile at that zoom level. - final TileCoordinate coordinate; + final TileCoordinates coordinates; final 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(TileCoordinate coordinate) onLoadComplete; + final void Function(TileCoordinates coordinates) onLoadComplete; /// Callback fired when an error occurs whilst loading the tile image. /// [onLoadComplete] will be called immediately afterwards. This callback is @@ -59,7 +59,7 @@ class TileImage extends ChangeNotifier { TileImage({ required final TickerProvider vsync, - required this.coordinate, + required this.coordinates, required this.imageProvider, required this.onLoadComplete, required this.onLoadError, @@ -73,13 +73,13 @@ class TileImage extends ChangeNotifier { ? (_active ? 1.0 : 0.0) : animationController!.value; - String get coordsKey => coordinate.key; + String get coordinatesKey => coordinates.key; bool get active => _active; // Used to sort TileImages by their distance from the current zoom. double zIndex(double maxZoom, int currentZoom) => - maxZoom - (currentZoom - coordinate.z).abs(); + maxZoom - (currentZoom - coordinates.z).abs(); // Initiate loading of the image. void load() { @@ -108,7 +108,7 @@ class TileImage extends ChangeNotifier { if (!_disposed) { _activate(); - onLoadComplete(coordinate); + onLoadComplete(coordinates); } } @@ -118,7 +118,7 @@ class TileImage extends ChangeNotifier { if (!_disposed) { _activate(); onLoadError(this, exception, stackTrace); - onLoadComplete(coordinate); + onLoadComplete(coordinates); } } @@ -170,10 +170,10 @@ class TileImage extends ChangeNotifier { } @override - int get hashCode => coordinate.hashCode; + int get hashCode => coordinates.hashCode; @override bool operator ==(Object other) { - return other is TileImage && coordinate == other.coordinate; + return other is TileImage && coordinates == other.coordinates; } } diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index edfc7379a..e019f39de 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -2,17 +2,18 @@ import 'package:collection/collection.dart'; import 'package:flutter_map/src/core/point.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_coordinate.dart'; +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_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -typedef TileCreator = TileImage Function(TileCoordinate coordinate); +typedef TileCreator = TileImage Function(TileCoordinates coordinates); class TileImageManager { final Map _tiles = {}; - bool containsTileAt(TileCoordinate coords) => _tiles.containsKey(coords.key); + bool containsTileAt(TileCoordinates coordinates) => + _tiles.containsKey(coordinates.key); bool get allLoaded => _tiles.values.none((tile) => tile.loadFinishedAt == null); @@ -38,37 +39,37 @@ class TileImageManager { TileBoundsAtZoom tileBoundsAtZoom, { required TileCreator createTileImage, }) { - for (final coordinate in tileBoundsAtZoom.validCoordinatesIn(tileRange)) { + for (final coordinates in tileBoundsAtZoom.validCoordinatesIn(tileRange)) { _tiles.putIfAbsent( - coordinate.key, - () => createTileImage(coordinate), + coordinates.key, + () => createTileImage(coordinates), ); } } bool allWithinZoom(double minZoom, double maxZoom) { for (final tile in _tiles.values) { - if (tile.coordinate.z > (maxZoom) || tile.coordinate.z < (minZoom)) { + if (tile.coordinates.z > (maxZoom) || tile.coordinates.z < (minZoom)) { return false; } } return true; } - // For each coordinate: + // 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( - Iterable coordinates, { + Iterable tileCoordinates, { required TileCreator createTile, }) { final notLoaded = []; - for (final coordinate in coordinates) { + for (final coordinates in tileCoordinates) { final tile = _tiles.putIfAbsent( - coordinate.key, - () => createTile(coordinate), + coordinates.key, + () => createTile(coordinates), ); tile.current = true; @@ -113,7 +114,7 @@ class TileImageManager { ) { for (final tile in _tiles.values) { tile.imageProvider = layer.tileProvider.getImage( - tileBounds.atZoom(tile.coordinate.z).wrap(tile.coordinate), + tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates), layer, ); tile.load(); @@ -124,7 +125,7 @@ class TileImageManager { int currentTileZoom, DiscreteTileRange noPruneRange) { for (final entry in _tiles.entries) { final tile = entry.value; - final c = tile.coordinate; + final c = tile.coordinates; if (tile.current && (c.z != currentTileZoom || @@ -156,7 +157,7 @@ class TileImageManager { final toRemove = []; for (final entry in _tiles.entries) { final tile = entry.value; - final c = tile.coordinate; + final c = tile.coordinates; if (tile.loadError && (!tile.current || !tileRange.contains(CustomPoint(c.x, c.y)))) { @@ -177,7 +178,7 @@ class TileImageManager { for (final tile in _tiles.values) { if (tile.current && !tile.active) { - final coords = tile.coordinate; + 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); } @@ -200,7 +201,7 @@ class TileImageManager { 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 = TileCoordinate(i, j, z + 1); + final coords = TileCoordinates(i, j, z + 1); final tile = _tiles[coords.key]; if (tile != null) { @@ -226,7 +227,7 @@ class TileImageManager { final x2 = (x / 2).floor(); final y2 = (y / 2).floor(); final z2 = z - 1; - final coords2 = TileCoordinate(x2, y2, z2); + final coords2 = TileCoordinates(x2, y2, z2); final tile = _tiles[coords2.key]; if (tile != null) { diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 7c2237d0c..e4e1b2809 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -10,7 +10,6 @@ import 'package:flutter_map/src/core/util.dart' as util; import 'package:flutter_map/src/layer/tile_layer/tile.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_coordinate.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; @@ -511,10 +510,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { .inRenderOrder(widget.maxZoom, tileZoom) .map((tileImage) { return Tile( - key: ValueKey(tileImage.coordsKey), + key: ValueKey(tileImage.coordinatesKey), scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, - tileImage.coordinate.z, + tileImage.coordinates.z, ), currentPixelOrigin: currentPixelOrigin, tileImage: tileImage, @@ -527,14 +526,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { } TileImage _createTileImage( - TileCoordinate coordinate, + TileCoordinates coordinates, TileBoundsAtZoom tileBoundsAtZoom, ) { return TileImage( vsync: this, - coordinate: coordinate, + coordinates: coordinates, imageProvider: widget.tileProvider.getImage( - tileBoundsAtZoom.wrap(coordinate), + tileBoundsAtZoom.wrap(coordinates), widget, ), onLoadError: _onTileLoadError, @@ -583,7 +582,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileImageManager.prune(widget.evictErrorTileStrategy); } - // For each valid coordinate in the [tileLoadRange], expanded by the + // For all valid TileCoordinates in the [tileLoadRange], expanded by the // [TileLayer.panBuffer], this method will do the following depending on // whether a matching TileImage already exists or not: // * Exists: Mark it as current and initiate image loading if it has not @@ -609,15 +608,15 @@ class _TileLayerState extends State with TickerProviderStateMixin { final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); final tilesToLoad = _tileImageManager.setCurrentAndReturnNotLoadedTiles( tileBoundsAtZoom.validCoordinatesIn(tileLoadRange), - createTile: (coordinate) => - _createTileImage(coordinate, tileBoundsAtZoom)); + createTile: (coordinates) => + _createTileImage(coordinates, tileBoundsAtZoom)); // Re-order the tiles by their distance to the center of the range. final tileCenter = tileLoadRange.center; tilesToLoad.sort( - (a, b) => a.coordinate + (a, b) => a.coordinates .distanceTo(tileCenter) - .compareTo(b.coordinate.distanceTo(tileCenter)), + .compareTo(b.coordinates.distanceTo(tileCenter)), ); // Create the new Tiles. @@ -647,9 +646,11 @@ class _TileLayerState extends State with TickerProviderStateMixin { } // This is called whether the tile loads successfully or with an error. - void _onTileLoadComplete(TileCoordinate coordinate) { - if (!_tileImageManager.containsTileAt(coordinate)) return; // Already pruned - if (!_tileImageManager.allLoaded) return; + void _onTileLoadComplete(TileCoordinates coordinates) { + if (!_tileImageManager.containsTileAt(coordinates) || + !_tileImageManager.allLoaded) { + return; + } if (widget.fastReplace) { // We're not waiting for anything, prune the tiles immediately. diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index d4771af15..4ea66191d 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -89,7 +89,7 @@ class WMSTileLayerOptions { return buffer.toString(); } - String getUrl(TileCoordinate coords, int tileSize, bool retinaMode) { + String getUrl(TileCoordinates coords, int tileSize, bool retinaMode) { final tileSizePoint = CustomPoint(tileSize, tileSize); final nwPoint = coords.scaleBy(tileSizePoint); final sePoint = nwPoint + tileSizePoint; diff --git a/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart index db81f991a..0b3e401b6 100644 --- a/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart @@ -2,17 +2,17 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; class AssetTileProvider extends TileProvider { @override - AssetImage getImage(TileCoordinate coords, TileLayer options) { + AssetImage getImage(TileCoordinates coordinates, TileLayer options) { return AssetImage( - getTileUrl(coords, options), + getTileUrl(coordinates, options), bundle: _FlutterMapAssetBundle( - fallbackKey: getTileFallbackUrl(coords, options), + fallbackKey: getTileFallbackUrl(coordinates, options), ), ); } diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 7e4ad8ebf..4d02f3a43 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; /// The base tile provider implementation, extended by other classes such as [NetworkTileProvider] @@ -17,24 +17,24 @@ abstract class TileProvider { }); /// Retrieve a tile as an image, based on it's coordinates and the current [TileLayerOptions] - ImageProvider getImage(TileCoordinate coords, TileLayer options); + ImageProvider getImage(TileCoordinates coordinates, TileLayer options); /// Called when the [TileLayerWidget] is disposed void dispose() {} String _getTileUrl( - String urlTemplate, TileCoordinate coords, TileLayer options) { - final z = _getZoomForUrl(coords, options); + String urlTemplate, TileCoordinates coordinates, TileLayer options) { + final z = _getZoomForUrl(coordinates, options); final data = { - 'x': coords.x.toString(), - 'y': coords.y.toString(), + 'x': coordinates.x.toString(), + 'y': coordinates.y.toString(), 'z': z.toString(), - 's': getSubdomain(coords, options), + 's': getSubdomain(coordinates, options), 'r': '@2x', }; if (options.tms) { - data['y'] = invertY(coords.y, z).toString(); + data['y'] = invertY(coordinates.y, z).toString(); } final allOpts = Map.from(data) ..addAll(options.additionalOptions); @@ -43,24 +43,24 @@ abstract class TileProvider { /// Generate a valid URL for a tile, based on it's coordinates and the current /// [TileLayerOptions] - String getTileUrl(TileCoordinate coords, TileLayer options) { + String getTileUrl(TileCoordinates coordinates, TileLayer options) { final urlTemplate = (options.wmsOptions != null) ? options.wmsOptions! - .getUrl(coords, options.tileSize.toInt(), options.retinaMode) + .getUrl(coordinates, options.tileSize.toInt(), options.retinaMode) : options.urlTemplate; - return _getTileUrl(urlTemplate!, coords, options); + return _getTileUrl(urlTemplate!, coordinates, options); } /// Generates a valid URL for the [fallbackUrl]. - String? getTileFallbackUrl(TileCoordinate coords, TileLayer options) { + String? getTileFallbackUrl(TileCoordinates coordinates, TileLayer options) { final urlTemplate = options.fallbackUrl; if (urlTemplate == null) return null; - return _getTileUrl(urlTemplate, coords, options); + return _getTileUrl(urlTemplate, coordinates, options); } - int _getZoomForUrl(TileCoordinate coords, TileLayer options) { - var zoom = coords.z.toDouble(); + int _getZoomForUrl(TileCoordinates coordinates, TileLayer options) { + var zoom = coordinates.z.toDouble(); if (options.zoomReverse) { zoom = options.maxZoom - zoom; @@ -74,11 +74,11 @@ abstract class TileProvider { } /// Get a subdomain value for a tile, based on it's coordinates and the current [TileLayerOptions] - String getSubdomain(TileCoordinate coords, TileLayer options) { + String getSubdomain(TileCoordinates coordinates, TileLayer options) { if (options.subdomains.isEmpty) { return ''; } - final index = (coords.x + coords.y) % options.subdomains.length; + final index = (coordinates.x + coordinates.y) % options.subdomains.length; return options.subdomains[index]; } } diff --git a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart index 00b7df2bd..88978688b 100644 --- a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; @@ -10,7 +10,7 @@ class FileTileProvider extends TileProvider { FileTileProvider(); @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) { - return FileImage(File(getTileUrl(coords, options))); + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return FileImage(File(getTileUrl(coordinates, options))); } } diff --git a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart index 9794b5daa..f7225c8f4 100644 --- a/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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'; @@ -11,7 +11,7 @@ class FileTileProvider extends TileProvider { FileTileProvider(); @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) { - return NetworkImage(getTileUrl(coords, options)); + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return NetworkImage(getTileUrl(coordinates, options)); } } diff --git a/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart index 55a3044b9..9cccddacd 100644 --- a/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart +++ b/lib/src/layer/tile_layer/tile_provider/tile_provider_io.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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/layer/tile_layer/tile_provider/network_image_provider.dart'; @@ -30,11 +30,11 @@ class NetworkTileProvider extends TileProvider { final http.Client httpClient; @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) => + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => HttpOverrides.runZoned( () => FMNetworkImageProvider( - getTileUrl(coords, options), - fallbackUrl: getTileFallbackUrl(coords, options), + getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers, httpClient: httpClient, ), @@ -63,10 +63,10 @@ class NetworkNoRetryTileProvider extends TileProvider { late final HttpClient httpClient; @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) => + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => FMNetworkNoRetryImageProvider( - getTileUrl(coords, options), - fallbackUrl: getTileFallbackUrl(coords, options), + getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers, httpClient: httpClient, ); @@ -79,18 +79,18 @@ class NetworkNoRetryTileProvider extends TileProvider { /// [TileProvider]s. Instead, visit the online documentation at /// https://docs.fleaflet.dev/plugins/making-a-plugin/creating-new-tile-providers. class CustomTileProvider extends TileProvider { - final String Function(TileCoordinate coors, TileLayer options) customTileUrl; + final String Function(TileCoordinates coors, TileLayer options) customTileUrl; CustomTileProvider({required this.customTileUrl}); @override - String getTileUrl(TileCoordinate coords, TileLayer options) { - return customTileUrl(coords, options); + String getTileUrl(TileCoordinates coordinates, TileLayer options) { + return customTileUrl(coordinates, options); } @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) { - return AssetImage(getTileUrl(coords, options)); + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return AssetImage(getTileUrl(coordinates, options)); } } diff --git a/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart b/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart index 145f66c10..20c3b9cc6 100644 --- a/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart +++ b/lib/src/layer/tile_layer/tile_provider/tile_provider_web.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.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/layer/tile_layer/tile_provider/network_image_provider.dart'; @@ -20,10 +20,10 @@ class NetworkTileProvider extends TileProvider { } @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) => + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => FMNetworkImageProvider( - getTileUrl(coords, options), - fallbackUrl: getTileFallbackUrl(coords, options), + getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers..remove('User-Agent'), ); } @@ -41,10 +41,10 @@ class NetworkNoRetryTileProvider extends TileProvider { } @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) => + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => FMNetworkImageProvider( - getTileUrl(coords, options), - fallbackUrl: getTileFallbackUrl(coords, options), + getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers..remove('User-Agent'), httpClient: http.Client(), ); @@ -54,17 +54,17 @@ class NetworkNoRetryTileProvider extends TileProvider { /// /// Using this method is not recommended any more, except for very simple custom [TileProvider]s. Instead, visit the online documentation at https://docs.fleaflet.dev/plugins/making-a-plugin/creating-new-tile-providers. class CustomTileProvider extends TileProvider { - final String Function(TileCoordinate coors, TileLayer options) customTileUrl; + final String Function(TileCoordinates coors, TileLayer options) customTileUrl; CustomTileProvider({required this.customTileUrl}); @override - String getTileUrl(TileCoordinate coords, TileLayer options) { - return customTileUrl(coords, options); + String getTileUrl(TileCoordinates coordinates, TileLayer options) { + return customTileUrl(coordinates, options); } @override - ImageProvider getImage(TileCoordinate coords, TileLayer options) { - return AssetImage(getTileUrl(coords, options)); + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return AssetImage(getTileUrl(coordinates, options)); } } diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart index 671f6d7a9..f33add411 100644 --- a/lib/src/layer/tile_layer/tile_range.dart +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -2,22 +2,22 @@ import 'dart:math' as math; import 'package:flutter_map/src/core/bounds.dart'; import 'package:flutter_map/src/core/point.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; abstract class TileRange { final int zoom; const TileRange(this.zoom); - Iterable get coordinates; + Iterable get coordinates; } class EmptyTileRange extends TileRange { const EmptyTileRange._(super.zoom); @override - Iterable get coordinates => - const Iterable.empty(); + Iterable get coordinates => + const Iterable.empty(); } class DiscreteTileRange extends TileRange { @@ -110,10 +110,10 @@ class DiscreteTileRange extends TileRange { CustomPoint get center => _bounds.center; @override - Iterable get coordinates sync* { + Iterable get coordinates sync* { for (var j = _bounds.min.y; j <= _bounds.max.y; j++) { for (var i = _bounds.min.x; i <= _bounds.max.x; i++) { - yield TileCoordinate(i, j, zoom); + yield TileCoordinates(i, j, zoom); } } } diff --git a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart index 49b072a5d..c425dfa05 100644 --- a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart +++ b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart @@ -1,13 +1,14 @@ -import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.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_coordinate.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:test/test.dart'; import 'package:tuple/tuple.dart'; void main() { group('TileBoundsAtZoom', () { - const hugeCoordinate = TileCoordinate(999999999, 999999999, 0); + const hugeCoordinate = TileCoordinates(999999999, 999999999, 0); final tileRangeWithHugeCoordinate = DiscreteTileRange.fromPixelBounds( zoom: 0, tileSize: 1, @@ -72,18 +73,18 @@ void main() { // Only wraps x, x is larger than range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(13, 13, 0)), - const TileCoordinate(0, 13, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(13, 13, 0)), + const TileCoordinates(0, 13, 0), ); // Only wraps x, x is smaller than range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(-1, -1, 0)), - const TileCoordinate(12, -1, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(-1, -1, 0)), + const TileCoordinates(12, -1, 0), ); // No wrap, x is within range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(12, 12, 0)), - const TileCoordinate(12, 12, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(12, 12, 0)), + const TileCoordinates(12, 12, 0), ); // Filters out invalid coordinates @@ -125,18 +126,18 @@ void main() { // Only wraps x, x is larger than range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(13, 13, 0)), - const TileCoordinate(0, 13, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(13, 13, 0)), + const TileCoordinates(0, 13, 0), ); // Only wraps x, x is smaller than range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(-1, -1, 0)), - const TileCoordinate(12, -1, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(-1, -1, 0)), + const TileCoordinates(12, -1, 0), ); // No wrap, x is within range expect( - tileBoundsAtZoom.wrap(const TileCoordinate(12, 12, 0)), - const TileCoordinate(12, 12, 0), + tileBoundsAtZoom.wrap(const TileCoordinates(12, 12, 0)), + const TileCoordinates(12, 12, 0), ); // Filters out invalid coordinates diff --git a/test/layer/tile_layer/tile_range_test.dart b/test/layer/tile_layer/tile_range_test.dart index 81c57e40a..831b05755 100644 --- a/test/layer/tile_layer/tile_range_test.dart +++ b/test/layer/tile_layer/tile_range_test.dart @@ -1,5 +1,6 @@ -import 'package:flutter_map/plugin_api.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinate.dart'; +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:test/test.dart'; @@ -40,7 +41,7 @@ void main() { ); expect( - tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); + tileRange.coordinates.toList(), [const TileCoordinates(2, 2, 0)]); }); test('lower tile edge', () { @@ -54,7 +55,7 @@ void main() { ); expect( - tileRange.coordinates.toList(), [const TileCoordinate(0, 0, 0)]); + tileRange.coordinates.toList(), [const TileCoordinates(0, 0, 0)]); }); test('upper tile edge', () { @@ -68,7 +69,7 @@ void main() { ); expect( - tileRange.coordinates.toList(), [const TileCoordinate(0, 0, 0)]); + tileRange.coordinates.toList(), [const TileCoordinates(0, 0, 0)]); }); test('both tile edges', () { @@ -82,15 +83,15 @@ void main() { ); expect(tileRange.coordinates.toList(), [ - const TileCoordinate(1, 1, 0), - const TileCoordinate(2, 1, 0), - const TileCoordinate(3, 1, 0), - const TileCoordinate(1, 2, 0), - const TileCoordinate(2, 2, 0), - const TileCoordinate(3, 2, 0), - const TileCoordinate(1, 3, 0), - const TileCoordinate(2, 3, 0), - const TileCoordinate(3, 3, 0), + const TileCoordinates(1, 1, 0), + const TileCoordinates(2, 1, 0), + const TileCoordinates(3, 1, 0), + const TileCoordinates(1, 2, 0), + const TileCoordinates(2, 2, 0), + const TileCoordinates(3, 2, 0), + const TileCoordinates(1, 3, 0), + const TileCoordinates(2, 3, 0), + const TileCoordinates(3, 3, 0), ]); }); }); @@ -105,19 +106,20 @@ void main() { ), ); - expect(tileRange.coordinates.toList(), [const TileCoordinate(2, 2, 0)]); + expect( + tileRange.coordinates.toList(), [const TileCoordinates(2, 2, 0)]); final expandedTileRange = tileRange.expand(1); expect(expandedTileRange.coordinates.toList(), [ - const TileCoordinate(1, 1, 0), - const TileCoordinate(2, 1, 0), - const TileCoordinate(3, 1, 0), - const TileCoordinate(1, 2, 0), - const TileCoordinate(2, 2, 0), - const TileCoordinate(3, 2, 0), - const TileCoordinate(1, 3, 0), - const TileCoordinate(2, 3, 0), - const TileCoordinate(3, 3, 0), + const TileCoordinates(1, 1, 0), + const TileCoordinates(2, 1, 0), + const TileCoordinates(3, 1, 0), + const TileCoordinates(1, 2, 0), + const TileCoordinates(2, 2, 0), + const TileCoordinates(3, 2, 0), + const TileCoordinates(1, 3, 0), + const TileCoordinates(2, 3, 0), + const TileCoordinates(3, 3, 0), ]); }); @@ -171,8 +173,8 @@ void main() { final intersectionB = tileRange1.intersect(tileRange2).coordinates.toList(); - expect(intersectionA, [const TileCoordinate(3, 3, 0)]); - expect(intersectionB, [const TileCoordinate(3, 3, 0)]); + expect(intersectionA, [const TileCoordinates(3, 3, 0)]); + expect(intersectionB, [const TileCoordinates(3, 3, 0)]); }); test('range within other range', () { From 664d001e7277bd457c880830d104198a9a96e831 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Mon, 3 Apr 2023 20:29:43 +0200 Subject: [PATCH 21/51] Add tileOpacity option --- lib/src/layer/tile_layer/tile.dart | 10 ++++++++- lib/src/layer/tile_layer/tile_image.dart | 27 +++++++++++++++++++++--- lib/src/layer/tile_layer/tile_layer.dart | 17 ++++++++++++--- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index ddd6e19c1..2eb620f9e 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -54,11 +54,19 @@ class _TileState extends State { Widget get _tileImage { if (widget.tileImage.loadError && widget.tileImage.errorImage != null) { - return Image(image: widget.tileImage.errorImage!); + return Image( + image: widget.tileImage.errorImage!, + opacity: widget.tileImage.opacity == 1 + ? null + : AlwaysStoppedAnimation(widget.tileImage.opacity), + ); } else if (widget.tileImage.animationController == null) { return RawImage( image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, + opacity: widget.tileImage.opacity == 1 + ? null + : AlwaysStoppedAnimation(widget.tileImage.opacity), ); } else { return AnimatedBuilder( diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index dedb664f9..10be3a214 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -9,6 +9,9 @@ class TileImage extends ChangeNotifier { /// indicate the position of the tile at that zoom level. final TileCoordinates coordinates; + /// The opacity of the tile image when it is fully loaded. + final double maximumOpacity; + final AnimationController? animationController; /// Callback fired when loading finishes with or withut an error. This @@ -38,7 +41,7 @@ class TileImage extends ChangeNotifier { /// Used during pruning to determine which tiles should be kept. bool retain = false; - /// Whether the tile is displayable with full opacity. This means that either: + /// 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. @@ -61,16 +64,21 @@ class TileImage extends ChangeNotifier { required final TickerProvider vsync, required this.coordinates, required this.imageProvider, + required this.maximumOpacity, required this.onLoadComplete, required this.onLoadError, required this.fadeIn, required this.errorImage, }) : animationController = fadeIn == null ? null - : AnimationController(duration: fadeIn.duration, vsync: vsync); + : AnimationController( + duration: fadeIn.duration, + vsync: vsync, + upperBound: maximumOpacity, + ); double get opacity => animationController == null - ? (_active ? 1.0 : 0.0) + ? (_active ? maximumOpacity : 0.0) : animationController!.value; String get coordinatesKey => coordinates.key; @@ -150,7 +158,9 @@ class TileImage extends ChangeNotifier { @override void dispose({bool evictImageFromCache = false}) { + assert(!_disposed); _disposed = true; + if (evictImageFromCache) { try { imageProvider.evict().catchError((Object e) { @@ -164,6 +174,12 @@ class TileImage extends ChangeNotifier { } } + // Mark the image as inactive. + _active = false; + animationController?.stop(canceled: false); + animationController?.value = 0.0; + notifyListeners(); + animationController?.dispose(); _imageStream?.removeListener(_listener); super.dispose(); @@ -176,4 +192,9 @@ class TileImage extends ChangeNotifier { bool operator ==(Object other) { return other is TileImage && coordinates == other.coordinates; } + + @override + String toString() { + return 'TileImage($coordinates, active: $_active)'; + } } diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index e4e1b2809..466d8536e 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -100,6 +100,11 @@ class TileLayer extends StatefulWidget { /// Color shown behind the tiles final Color backgroundColor; + /// Sets the opacity of tile images. Overlapping tiles from separate layers + /// will be simultaneously visible when opacity is less than one. To prevent + /// this [fastReplace] should be enabled. + final double tileOpacity; + /// Provider with which to load map tiles /// /// The default is [NetworkNoRetryTileProvider]. Alternatively, use @@ -246,6 +251,7 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor = const Color(0xFFE0E0E0), + this.tileOpacity = 1.0, this.errorImage, TileProvider? tileProvider, this.tms = false, @@ -395,7 +401,9 @@ class _TileLayerState extends State with TickerProviderStateMixin { @override void didUpdateWidget(TileLayer oldWidget) { super.didUpdateWidget(oldWidget); - var reloadTiles = false; + bool reloadTiles = false; + + if (oldWidget.tileOpacity != widget.tileOpacity) reloadTiles = true; // There is no caching in TileRangeCalculator so we can just replace it. _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); @@ -510,7 +518,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { .inRenderOrder(widget.maxZoom, tileZoom) .map((tileImage) { return Tile( - key: ValueKey(tileImage.coordinatesKey), + // Must be an ObjectKey, not a ValueKey using the coordinates, in + // case we remove and replace the TileImage e.g. when the tile + /// opacity changes. + key: ObjectKey(tileImage), scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, tileImage.coordinates.z, @@ -536,6 +547,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileBoundsAtZoom.wrap(coordinates), widget, ), + maximumOpacity: widget.tileOpacity, onLoadError: _onTileLoadError, onLoadComplete: _onTileLoadComplete, fadeIn: widget.fastReplace ? null : widget.tileFadeIn, @@ -651,7 +663,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { !_tileImageManager.allLoaded) { return; } - if (widget.fastReplace) { // We're not waiting for anything, prune the tiles immediately. _tileImageManager.prune(widget.evictErrorTileStrategy); From dadab124b6b37c7e829094f38e0daa1495a1e8f1 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 4 Apr 2023 09:18:43 +0200 Subject: [PATCH 22/51] Add an example TileUpdateTransformer --- lib/flutter_map.dart | 2 + lib/src/layer/tile_layer/tile_layer.dart | 16 ++++++-- .../layer/tile_layer/tile_layer_options.dart | 37 ------------------- .../layer/tile_layer/tile_update_event.dart | 36 ++++++++++++++++++ .../tile_layer/tile_update_transformer.dart | 21 +++++++++++ 5 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_update_event.dart create mode 100644 lib/src/layer/tile_layer/tile_update_transformer.dart diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 9c89c08a1..68c15d2cb 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -40,6 +40,8 @@ export 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provide if (dart.library.html) 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_io.dart' if (dart.library.html) 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; /// Renders a map composed of a list of layers powered by [LayerOptions]. /// diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 466d8536e..7ebe0190b 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -4,18 +4,27 @@ import 'dart:math' as math; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/core/util.dart' as util; +import 'package:flutter_map/src/geo/crs/crs.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.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_builder.dart'; +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_image_manager.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; -import 'package:latlong2/latlong.dart'; part 'tile_layer_options.dart'; @@ -519,8 +528,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { .map((tileImage) { return Tile( // Must be an ObjectKey, not a ValueKey using the coordinates, in - // case we remove and replace the TileImage e.g. when the tile - /// opacity changes. + // case we remove and replace the TileImage with a different one. key: ObjectKey(tileImage), scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 4ea66191d..4ccf55b25 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -131,40 +131,3 @@ class TileFadeIn { }) : assert(startOpacity >= 0.0 && startOpacity <= 1.0), assert(reloadStartOpacity >= 0.0 && reloadStartOpacity <= 1.0); } - -typedef TileUpdateTransformer = StreamTransformer; - -/// Describes whether loading and/or pruning should occur and allows overriding -/// the load center/zoom. If loading/pruning is not desired the -/// [TileUpdateTransformer] should just not add a TileUpdateEvent to its sink. -class TileUpdateEvent { - final bool load; - final bool prune; - final LatLng? loadCenterOverride; - final double? loadZoomOverride; - - /// Do not load new tiles, only prune old ones. - const TileUpdateEvent.pruneOnly() - : load = false, - prune = true, - loadCenterOverride = null, - loadZoomOverride = null; - - /// Load new tiles, do not prune old ones. The loading center/zoom can be - /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they - /// will default to the map's current center/zoom. - const TileUpdateEvent.loadOnly({ - this.loadCenterOverride, - this.loadZoomOverride, - }) : load = true, - prune = false; - - /// Load new tiles and prune old ones. The loading center/zoom can be - /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they - /// will default to the map's current center/zoom. - const TileUpdateEvent.loadAndPrune({ - this.loadCenterOverride, - this.loadZoomOverride, - }) : load = true, - prune = true; -} diff --git a/lib/src/layer/tile_layer/tile_update_event.dart b/lib/src/layer/tile_layer/tile_update_event.dart new file mode 100644 index 000000000..b61c09d78 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_update_event.dart @@ -0,0 +1,36 @@ +import 'package:latlong2/latlong.dart'; + +/// Describes whether loading and/or pruning should occur and allows overriding +/// the load center/zoom. If loading/pruning is not desired the +/// [TileUpdateTransformer] should just not add a TileUpdateEvent to its sink. +class TileUpdateEvent { + final bool load; + final bool prune; + final LatLng? loadCenterOverride; + final double? loadZoomOverride; + + /// Do not load new tiles, only prune old ones. + const TileUpdateEvent.pruneOnly() + : load = false, + prune = true, + loadCenterOverride = null, + loadZoomOverride = null; + + /// Load new tiles, do not prune old ones. The loading center/zoom can be + /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they + /// will default to the map's current center/zoom. + const TileUpdateEvent.loadOnly({ + this.loadCenterOverride, + this.loadZoomOverride, + }) : load = true, + prune = false; + + /// Load new tiles and prune old ones. The loading center/zoom can be + /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they + /// will default to the map's current center/zoom. + const TileUpdateEvent.loadAndPrune({ + this.loadCenterOverride, + this.loadZoomOverride, + }) : load = true, + prune = true; +} diff --git a/lib/src/layer/tile_layer/tile_update_transformer.dart b/lib/src/layer/tile_layer/tile_update_transformer.dart new file mode 100644 index 000000000..10f6c2953 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_update_transformer.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; + +typedef TileUpdateTransformer = StreamTransformer; + +/// Avoid loading/updating tiles when a tap occurs on the assumption that it +/// should not cause new tiles to be loaded. +final ignoreTapEventsTransformer = + TileUpdateTransformer.fromHandlers(handleData: (event, sink) { + // Ignore known events that we know should not cause new tiles to load. + if (event is MapEventTap || + event is MapEventSecondaryTap || + event is MapEventLongPress) { + return; + } + + // Let the event trigger load/prune. + sink.add(const TileUpdateEvent.loadAndPrune()); +}); From 8e50027fbc666d9d83fcf214136ba4e7b978ed19 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Apr 2023 12:33:17 +0100 Subject: [PATCH 23/51] Updated CHANGELOG and versioning throughout Renamed `tileOpacity` to `opacity` Removed deprecated features --- CHANGELOG.md | 17 +++++++++++++++-- example/android/app/build.gradle | 4 ++-- example/pubspec.yaml | 2 +- lib/flutter_map.dart | 10 ---------- lib/src/geo/latlng_bounds.dart | 5 ----- lib/src/layer/tile_layer/tile_layer.dart | 8 ++++---- pubspec.yaml | 2 +- 7 files changed, 23 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d9082be..640f15dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,37 @@ # Changelog -## [3.2.0] - 2022/02/XX +## [4.0.0] - 2022/04/XX Contains the following additions/removals: -- Removed `LatLngBounds.pad` (unused and broken) method - [#1427](https://github.com/fleaflet/flutter_map/pull/1427) +- Reimplemented `TileLayer` and underlying systems šŸŽ‰ - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) +- Added secondary tap handling to `MapOptions` - [#1448](https://github.com/fleaflet/flutter_map/pull/1448) for [#1444](https://github.com/fleaflet/flutter_map/issues/1444) - Migrated `LatLngBounds` to proper null safety - [#1431](https://github.com/fleaflet/flutter_map/pull/1431) +- Removed `LatLngBounds.pad` (unused and broken) method - [#1427](https://github.com/fleaflet/flutter_map/pull/1427) +- Removed `absorbPanEventsOnScrollables` option - [#1455](https://github.com/fleaflet/flutter_map/pull/1455) for [#1454](https://github.com/fleaflet/flutter_map/issues/1454) +- Removed leftover deprecations - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) - Minor example application improvements - [#1440](https://github.com/fleaflet/flutter_map/pull/1440) Contains the following bug fixes: - Fixed deprecations - [#1438](https://github.com/fleaflet/flutter_map/pull/1438) +- Prevented scrolling of list and simultaneous panning of map on some platforms - [#1453](https://github.com/fleaflet/flutter_map/pull/1453) + +Contains the following performance and stability improvements: + +- Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) +- Added a threshold for rasterization to avoid excessive fixed overhead cost for cheap redraws - [#1462](https://github.com/fleaflet/flutter_map/pull/1462) Many thanks to these contributors (in no particular order): - @pablojimpas - @augustweinbren - @ignatz +- @rorystephenson - ... and all the maintainers +And an additional special thanks to @rorystephenson & @ignatz for investing so much of his time into this project recently - we appreciate it! + ## [3.1.0] - 2022/12/21 Contains the following additions/removals: diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 43e428b5d..e6d6224dc 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -13,12 +13,12 @@ if (flutterRoot == null) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '1' + flutterVersionCode = '4' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '1.0' + flutterVersionName = '4.0.0' } apply plugin: 'com.android.application' diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5b4e948cb..aa02f0449 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_example description: Example application for 'flutter_map' package publish_to: "none" -version: 1.0.0 +version: 4.0.0 environment: sdk: ">=2.18.0 <3.0.0" diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 68c15d2cb..784b01bb9 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -342,14 +342,6 @@ class MapOptions { class FitBoundsOptions { final EdgeInsets padding; final double maxZoom; - - /// This property is deprecated and unused internally. It will be removed in a - /// future major update - // TODO: remove this property - @Deprecated( - 'This property is unused internally and will be removed in a future major update', - ) - final double? zoom; final bool inside; /// By default calculations will return fractional zoom levels. @@ -360,8 +352,6 @@ class FitBoundsOptions { const FitBoundsOptions({ this.padding = EdgeInsets.zero, this.maxZoom = 17.0, - @Deprecated('This property is unused and will be removed in the next major release.') - this.zoom, this.inside = false, this.forceIntegerZoomLevel = false, }); diff --git a/lib/src/geo/latlng_bounds.dart b/lib/src/geo/latlng_bounds.dart index d3f07591a..5f273fd0a 100644 --- a/lib/src/geo/latlng_bounds.dart +++ b/lib/src/geo/latlng_bounds.dart @@ -117,11 +117,6 @@ class LatLngBounds { return LatLng(radianToDeg(phi3), radianToDeg(lambda3)); } - /// Checks whether bound object is valid - /// TODO: remove this property in the next major release. - @Deprecated('This method is unnecessary and will be removed in the future.') - bool get isValid => true; - /// Checks whether [point] is inside bounds bool contains(LatLng point) { final sw2 = point; diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 7ebe0190b..f76d05add 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -112,7 +112,7 @@ class TileLayer extends StatefulWidget { /// Sets the opacity of tile images. Overlapping tiles from separate layers /// will be simultaneously visible when opacity is less than one. To prevent /// this [fastReplace] should be enabled. - final double tileOpacity; + final double opacity; /// Provider with which to load map tiles /// @@ -260,7 +260,7 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor = const Color(0xFFE0E0E0), - this.tileOpacity = 1.0, + this.opacity = 1.0, this.errorImage, TileProvider? tileProvider, this.tms = false, @@ -412,7 +412,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); bool reloadTiles = false; - if (oldWidget.tileOpacity != widget.tileOpacity) reloadTiles = true; + if (oldWidget.opacity != widget.opacity) reloadTiles = true; // There is no caching in TileRangeCalculator so we can just replace it. _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); @@ -555,7 +555,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileBoundsAtZoom.wrap(coordinates), widget, ), - maximumOpacity: widget.tileOpacity, + maximumOpacity: widget.opacity, onLoadError: _onTileLoadError, onLoadComplete: _onTileLoadComplete, fadeIn: widget.fastReplace ? null : widget.tileFadeIn, diff --git a/pubspec.yaml b/pubspec.yaml index 588291730..32f0f4e74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map description: A versatile mapping package for Flutter, based off leaflet.js, that's simple and easy to learn, yet completely customizable and configurable. -version: 3.2.0 +version: 4.0.0-dev.1 repository: https://github.com/fleaflet/flutter_map issue_tracker: https://github.com/fleaflet/flutter_map/issues documentation: https://docs.fleaflet.dev From e3a1f22f72b231418356db1f4a7928551718d286 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 4 Apr 2023 15:41:42 +0200 Subject: [PATCH 24/51] Avoid reloading images when opacity changes --- lib/src/layer/tile_layer/tile.dart | 6 +- lib/src/layer/tile_layer/tile_image.dart | 133 ++++++++++++------ .../layer/tile_layer/tile_image_manager.dart | 7 + lib/src/layer/tile_layer/tile_layer.dart | 84 ++++++----- lib/src/layer/tile_layer/tile_transition.dart | 62 ++++++++ 5 files changed, 208 insertions(+), 84 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_transition.dart diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 2eb620f9e..50acd6335 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -60,7 +60,7 @@ class _TileState extends State { ? null : AlwaysStoppedAnimation(widget.tileImage.opacity), ); - } else if (widget.tileImage.animationController == null) { + } else if (widget.tileImage.animation == null) { return RawImage( image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, @@ -70,11 +70,11 @@ class _TileState extends State { ); } else { return AnimatedBuilder( - animation: widget.tileImage.animationController!, + animation: widget.tileImage.animation!, builder: (context, child) => RawImage( image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, - opacity: widget.tileImage.animationController!, + opacity: widget.tileImage.animation!, ), ); } diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 10be3a214..44d1844cd 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -1,18 +1,20 @@ 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_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; class TileImage extends ChangeNotifier { bool _disposed = false; + /// Used by animationController. Still required if animation is disabled in + /// case the tile transition is changed at a later point. + final TickerProvider vsync; + /// The z of the coordinate is the TileImage's zoom level whilst the x and y /// indicate the position of the tile at that zoom level. final TileCoordinates coordinates; - /// The opacity of the tile image when it is fully loaded. - final double maximumOpacity; - - final AnimationController? animationController; + AnimationController? _animationController; /// Callback fired when loading finishes with or withut an error. This /// callback is not triggered after this TileImage is disposed. @@ -24,8 +26,8 @@ class TileImage extends ChangeNotifier { final void Function(TileImage tile, Object error, StackTrace? stackTrace) onLoadError; - /// Options for controlling whether tile fade in. - final TileFadeIn? fadeIn; + /// The style of transition. + TileTransition _transition; /// An optional image to show when a loading error occurs. final ImageProvider? errorImage; @@ -61,25 +63,27 @@ class TileImage extends ChangeNotifier { late ImageStreamListener _listener; TileImage({ - required final TickerProvider vsync, + required this.vsync, required this.coordinates, required this.imageProvider, - required this.maximumOpacity, required this.onLoadComplete, required this.onLoadError, - required this.fadeIn, + required TileTransition transition, required this.errorImage, - }) : animationController = fadeIn == null - ? null - : AnimationController( - duration: fadeIn.duration, - vsync: vsync, - upperBound: maximumOpacity, - ); - - double get opacity => animationController == null - ? (_active ? maximumOpacity : 0.0) - : animationController!.value; + }) : _transition = transition, + _animationController = transition.map( + instantaneous: (_) => null, + faded: (faded) => AnimationController( + vsync: vsync, + duration: faded.tileFadeIn.duration, + ), + ); + + double get opacity => _transition.map( + instantaneous: (instantaneous) => _active ? instantaneous.opacity : 0.0, + faded: (faded) => _animationController!.value); + + AnimationController? get animation => _animationController; String get coordinatesKey => coordinates.key; @@ -89,6 +93,40 @@ class TileImage extends ChangeNotifier { double zIndex(double maxZoom, int currentZoom) => maxZoom - (currentZoom - coordinates.z).abs(); + // Change the tile transition. + set transition(TileTransition newTransition) { + final oldTransition = _transition; + _transition = newTransition; + + // Handle disabling/enabling of animation controller if necessary + oldTransition.when( + instantaneous: (instantaneous) { + newTransition.when( + faded: (faded) { + // Became animated. + _animationController = AnimationController( + duration: faded.tileFadeIn.duration, + vsync: vsync, + value: _active ? 1.0 : 0.0, + ); + }, + ); + }, + faded: (faded) { + newTransition.when(instantaneous: (instantaneous) { + // No longer animated. + _animationController!.dispose(); + _animationController = null; + }, faded: (faded) { + // Still animated with different fade. + _animationController!.duration = faded.tileFadeIn.duration; + }); + }, + ); + + if (!_disposed) notifyListeners(); + } + // Initiate loading of the image. void load() { loadStarted = DateTime.now(); @@ -134,26 +172,35 @@ class TileImage extends ChangeNotifier { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); - if (fadeIn == null || (loadError && errorImage != null)) { - _active = true; - if (!_disposed) notifyListeners(); - return; - } - - final fadeStartOpacity = - previouslyLoaded ? fadeIn!.reloadStartOpacity : fadeIn!.startOpacity; - - if (fadeStartOpacity == 1.0) { - _active = true; - if (!_disposed) notifyListeners(); - return; - } - - animationController!.reset(); - animationController!.forward(from: fadeStartOpacity).then((_) { - _active = true; - if (!_disposed) notifyListeners(); - }); + _transition.when( + instantaneous: (_) { + _active = true; + if (!_disposed) notifyListeners(); + }, + faded: (faded) { + if (loadError && errorImage != null) { + _active = true; + if (!_disposed) notifyListeners(); + return; + } + + final fadeStartOpacity = previouslyLoaded + ? faded.tileFadeIn.reloadStartOpacity + : faded.tileFadeIn.startOpacity; + + if (fadeStartOpacity == 1.0) { + _active = true; + if (!_disposed) notifyListeners(); + return; + } + + _animationController!.reset(); + _animationController!.forward(from: fadeStartOpacity).then((_) { + _active = true; + if (!_disposed) notifyListeners(); + }); + }, + ); } @override @@ -176,11 +223,11 @@ class TileImage extends ChangeNotifier { // Mark the image as inactive. _active = false; - animationController?.stop(canceled: false); - animationController?.value = 0.0; + _animationController?.stop(canceled: false); + _animationController?.value = 0.0; notifyListeners(); - animationController?.dispose(); + _animationController?.dispose(); _imageStream?.removeListener(_listener); super.dispose(); } diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index e019f39de..2f2029169 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -6,6 +6,7 @@ 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_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; typedef TileCreator = TileImage Function(TileCoordinates coordinates); @@ -79,6 +80,12 @@ class TileImageManager { return notLoaded; } + void updateTileTransition(TileTransition tileTransition) { + for (final tile in _tiles.values) { + tile.transition = tileTransition; + } + } + /// All removals should be performed by calling this method to ensure that // disposal is performed correctly. void _remove( diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index f76d05add..48e0153a6 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -22,6 +22,7 @@ import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; @@ -109,11 +110,6 @@ class TileLayer extends StatefulWidget { /// Color shown behind the tiles final Color backgroundColor; - /// Sets the opacity of tile images. Overlapping tiles from separate layers - /// will be simultaneously visible when opacity is less than one. To prevent - /// this [fastReplace] should be enabled. - final double opacity; - /// Provider with which to load map tiles /// /// The default is [NetworkNoRetryTileProvider]. Alternatively, use @@ -177,10 +173,6 @@ class TileLayer extends StatefulWidget { /// ``` final Map additionalOptions; - /// Options for fading in tiles when they are loaded. If this is set to null - /// no fade is performed. - final TileFadeIn? tileFadeIn; - /// `false`: current Tiles will be first dropped and then reload via new url /// (default) `true`: current Tiles will be visible until new ones aren't /// loaded (new Tiles are loaded independently) @see @@ -219,19 +211,6 @@ class TileLayer extends StatefulWidget { // (see #576 - even Error Images are cached in flutter) final EvictErrorTileStrategy evictErrorTileStrategy; - /// This option is useful when you have a transparent layer: rather than - /// keeping the old layer visible when zooming (resulting in both layers - /// being temporarily visible), the old layer is removed as quickly as - /// possible when this is set to `true` (default `false`). - /// - /// This option is likely to cause some flickering of the transparent layer, - /// most noticeable when using pinch-to-zoom. It's best used with maps that - /// have `interactive` set to `false`, and zoom using buttons that call - /// `MapController.move()`. - /// - /// When set to `true`, the `tileFadeIn*` options will be ignored. - final bool fastReplace; - /// Stream to notify the [TileLayer] that it needs resetting final Stream? reset; @@ -244,6 +223,9 @@ class TileLayer extends StatefulWidget { /// MapEvent. final TileUpdateTransformer? tileUpdateTransformer; + /// Used internally to encapsulate the tile transition options. + final TileTransition _tileTransition; + TileLayer({ super.key, this.urlTemplate, @@ -260,20 +242,44 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor = const Color(0xFFE0E0E0), - this.opacity = 1.0, + + /// Sets the opacity of tile images to the given value (0.0 - 1.0), default + /// 1.0. When opacity is not 1.0 [fastReplace] is always enabled to prevent + /// overlapping tiles from being simultaneously visible. This disables fade + /// in and is not suitable for interactive maps, see [fastReplace] for more + /// information. + /// + /// If you need the map to be interactive and transparent you should wrap + /// [TileLayer] in an [Opacity] widget. This is less performant but avoids + /// flickering when moving the map. + double opacity = 1.0, this.errorImage, TileProvider? tileProvider, this.tms = false, this.wmsOptions, - Duration tileFadeInDuration = const Duration(milliseconds: 100), - this.tileFadeIn = const TileFadeIn(), + + /// Options for fading in tiles when they are loaded. If this is set to null + /// no fade is performed. + TileFadeIn? tileFadeIn, this.overrideTilesWhenUrlChanges = false, this.retinaMode = false, this.errorTileCallback, this.templateFunction = util.template, this.tileBuilder, this.evictErrorTileStrategy = EvictErrorTileStrategy.none, - this.fastReplace = false, + + /// This option is useful when you have a transparent layer: rather than + /// keeping the old layer visible when zooming (resulting in both layers + /// being temporarily visible), the old layer is removed as quickly as + /// possible when this is set to `true` (default `false`). + /// + /// This option is likely to cause some flickering of the transparent layer, + /// most noticeable when using pinch-to-zoom. It's best used with maps that + /// have `interactive` set to `false`, and zoom using buttons that call + /// `MapController.move()`. + /// + /// When set to `true`, the `tileFadeIn*` options will be ignored. + bool? fastReplace, this.reset, this.tileBounds, this.tileUpdateTransformer, @@ -305,7 +311,12 @@ class TileLayer extends StatefulWidget { ...tileProvider.headers, if (!tileProvider.headers.containsKey('User-Agent')) 'User-Agent': 'flutter_map ($userAgentPackageName)', - }); + }), + _tileTransition = TileTransition.from( + opacity: opacity, + fastReplace: fastReplace, + tileFadeIn: tileFadeIn, + ); @override State createState() => _TileLayerState(); @@ -412,8 +423,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); bool reloadTiles = false; - if (oldWidget.opacity != widget.opacity) reloadTiles = true; - // There is no caching in TileRangeCalculator so we can just replace it. _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); @@ -468,6 +477,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileImageManager.removeAll(widget.evictErrorTileStrategy); _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); + } else if (oldWidget._tileTransition != widget._tileTransition) { + _tileImageManager.updateTileTransition(widget._tileTransition); } } @@ -555,10 +566,9 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileBoundsAtZoom.wrap(coordinates), widget, ), - maximumOpacity: widget.opacity, onLoadError: _onTileLoadError, onLoadComplete: _onTileLoadComplete, - fadeIn: widget.fastReplace ? null : widget.tileFadeIn, + transition: widget._tileTransition, errorImage: widget.errorImage, ); } @@ -671,20 +681,18 @@ class _TileLayerState extends State with TickerProviderStateMixin { !_tileImageManager.allLoaded) { return; } - if (widget.fastReplace) { - // We're not waiting for anything, prune the tiles immediately. + + widget._tileTransition.when(instantaneous: (_) { _tileImageManager.prune(widget.evictErrorTileStrategy); - } else { + }, faded: (faded) { // 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( - widget.tileFadeIn == null - ? const Duration(milliseconds: 50) - : widget.tileFadeIn!.duration + const Duration(milliseconds: 50), + faded.duration + const Duration(milliseconds: 50), () => _tileImageManager.prune(widget.evictErrorTileStrategy), ); - } + }); } bool _outsideZoomLimits(num zoom) => diff --git a/lib/src/layer/tile_layer/tile_transition.dart b/lib/src/layer/tile_layer/tile_transition.dart new file mode 100644 index 000000000..315557330 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_transition.dart @@ -0,0 +1,62 @@ +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; + +abstract class TileTransition { + const TileTransition(); + + factory TileTransition.from({ + required double opacity, + bool? fastReplace, + TileFadeIn? tileFadeIn, + }) { + if ((opacity != 1.0) || fastReplace == true) { + return InstantaneousTileTransition(opacity: opacity); + } + + return FadedTileTransition(tileFadeIn ?? const TileFadeIn()); + } + + T map({ + required T Function(InstantaneousTileTransition instantaneous) + instantaneous, + required T Function(FadedTileTransition faded) faded, + }) { + switch (runtimeType) { + case InstantaneousTileTransition: + return instantaneous(this as InstantaneousTileTransition); + case FadedTileTransition: + return faded(this as FadedTileTransition); + default: + throw 'Unknown TileTransition type: $runtimeType'; + } + } + + void when({ + void Function(InstantaneousTileTransition instantaneous)? instantaneous, + void Function(FadedTileTransition faded)? faded, + }) { + switch (runtimeType) { + case InstantaneousTileTransition: + return instantaneous?.call(this as InstantaneousTileTransition); + case FadedTileTransition: + return faded?.call(this as FadedTileTransition); + default: + throw 'Unknown TileTransition type: $runtimeType'; + } + } +} + +class InstantaneousTileTransition extends TileTransition { + final double opacity; + + const InstantaneousTileTransition({ + this.opacity = 1.0, + }) : assert(opacity >= 0.0 && opacity <= 1.0); +} + +class FadedTileTransition extends TileTransition { + final TileFadeIn tileFadeIn; + + const FadedTileTransition(this.tileFadeIn); + + Duration get duration => tileFadeIn.duration; +} From f8624b78723b0c5cd2b5e4b52e67e434b080dcf1 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 4 Apr 2023 19:33:40 +0200 Subject: [PATCH 25/51] Combine opacity/fastReplace/tileFadeIn with a single tileDisplay option --- lib/flutter_map.dart | 1 + lib/src/layer/tile_layer/tile_display.dart | 115 ++++++++++++++++++ lib/src/layer/tile_layer/tile_image.dart | 55 ++++----- .../layer/tile_layer/tile_image_manager.dart | 6 +- lib/src/layer/tile_layer/tile_layer.dart | 61 +++------- .../layer/tile_layer/tile_layer_options.dart | 22 ---- lib/src/layer/tile_layer/tile_transition.dart | 62 ---------- 7 files changed, 162 insertions(+), 160 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_display.dart delete mode 100644 lib/src/layer/tile_layer/tile_transition.dart diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 784b01bb9..6cb7ce085 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -32,6 +32,7 @@ export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; diff --git a/lib/src/layer/tile_layer/tile_display.dart b/lib/src/layer/tile_layer/tile_display.dart new file mode 100644 index 000000000..74a1f39d5 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_display.dart @@ -0,0 +1,115 @@ +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; + +abstract class TileDisplay { + const TileDisplay(); + + // Instantly display tiles once they are loaded without a fade animation. + const factory TileDisplay.instantaneous({ + /// Sets the opacity of tile images to the given value (0.0 - 1.0), default + /// 1.0. Note that this opacity setting is applied at the tile level which + /// means that overlapping tiles will be simultaneously visible. This can + /// happen when changing zoom as tiles from the previous zoom level will + /// not be cleared until all of the tiles at the new zoom level have + /// finished loading. For this reason this opacity setting is only + /// recommended when the displayed map will remain at the same zoom level + /// or will not move gradually between zoom levels at the same position. + /// + /// If you wish to show a transparent map without these restrictions you + /// can simply wrap the entire [TileLayer] in an [Opacity] widget. + double opacity, + }) = InstantaneousTileDisplay; + + /// Fade in the tile when it is loaded. Not that opacity is not supported + /// when fading is enabled. This is because underlying tiles are kept when + /// fading in a new tile until it is loaded and with a partially transparent + /// tile they are both visible during fading which causes flickering. + /// + /// If you wish to make the TileLayer transparent you must disable fading + /// (see the TileDisplay.instantaneous opacity option) or wrap the whole + /// TileLayer in an Opacity widget. + const factory TileDisplay.fadeIn({ + /// Duration of the fade. Defaults to 100ms. + Duration duration, + + /// Opacity start value when a tile is faded in, default 1.0. The allowed + /// range is (0.0 - 0.1). + double startOpacity, + + /// Opacity start value when a tile is reloaded, default 1.0. A tile reload + /// will occur when the provider tile url changes and + /// [TileLayer.overrideTilesWhenUrlChanges] is true. Valid range is + /// (0.0 - 0.1). + double reloadStartOpacity, + }) = FadeInTileDisplay; + + T map({ + required T Function(InstantaneousTileDisplay instantaneous) instantaneous, + required T Function(FadeInTileDisplay fadeIn) fadeIn, + }) { + switch (runtimeType) { + case InstantaneousTileDisplay: + return instantaneous(this as InstantaneousTileDisplay); + case FadeInTileDisplay: + return fadeIn(this as FadeInTileDisplay); + default: + throw 'Unknown TileDisplay type: $runtimeType'; + } + } + + void when({ + void Function(InstantaneousTileDisplay instantaneous)? instantaneous, + void Function(FadeInTileDisplay fadeIn)? fadeIn, + }) { + switch (runtimeType) { + case InstantaneousTileDisplay: + return instantaneous?.call(this as InstantaneousTileDisplay); + case FadeInTileDisplay: + return fadeIn?.call(this as FadeInTileDisplay); + default: + throw 'Unknown TileDisplay type: $runtimeType'; + } + } +} + +class InstantaneousTileDisplay extends TileDisplay { + final double opacity; + + const InstantaneousTileDisplay({ + this.opacity = 1.0, + }) : assert(opacity >= 0.0 && opacity <= 1.0); + + // Note this is used to check if the option has changed. + @override + bool operator ==(Object other) { + return other is InstantaneousTileDisplay && opacity == other.opacity; + } + + @override + int get hashCode => opacity.hashCode; +} + +class FadeInTileDisplay extends TileDisplay { + final Duration duration; + final double startOpacity; + final double reloadStartOpacity; + + /// Options for fading in tiles when they are loaded. + const FadeInTileDisplay({ + this.duration = const Duration(milliseconds: 100), + this.startOpacity = 0.0, + this.reloadStartOpacity = 0.0, + }) : assert(startOpacity >= 0.0 && startOpacity <= 1.0), + assert(reloadStartOpacity >= 0.0 && reloadStartOpacity <= 1.0); + + // Note this is used to check if the option has changed. + @override + bool operator ==(Object other) { + return other is FadeInTileDisplay && + duration == other.duration && + startOpacity == other.startOpacity && + reloadStartOpacity == other.reloadStartOpacity; + } + + @override + int get hashCode => Object.hash(duration, startOpacity, reloadStartOpacity); +} diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 44d1844cd..a41e10b3d 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -1,13 +1,13 @@ 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'; -import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; class TileImage extends ChangeNotifier { bool _disposed = false; /// Used by animationController. Still required if animation is disabled in - /// case the tile transition is changed at a later point. + /// case the tile display is changed at a later point. final TickerProvider vsync; /// The z of the coordinate is the TileImage's zoom level whilst the x and y @@ -26,8 +26,8 @@ class TileImage extends ChangeNotifier { final void Function(TileImage tile, Object error, StackTrace? stackTrace) onLoadError; - /// The style of transition. - TileTransition _transition; + /// Options for how the tile image is displayed. + TileDisplay _display; /// An optional image to show when a loading error occurs. final ImageProvider? errorImage; @@ -68,20 +68,20 @@ class TileImage extends ChangeNotifier { required this.imageProvider, required this.onLoadComplete, required this.onLoadError, - required TileTransition transition, + required TileDisplay tileDisplay, required this.errorImage, - }) : _transition = transition, - _animationController = transition.map( + }) : _display = tileDisplay, + _animationController = tileDisplay.map( instantaneous: (_) => null, - faded: (faded) => AnimationController( + fadeIn: (fadeIn) => AnimationController( vsync: vsync, - duration: faded.tileFadeIn.duration, + duration: fadeIn.duration, ), ); - double get opacity => _transition.map( + double get opacity => _display.map( instantaneous: (instantaneous) => _active ? instantaneous.opacity : 0.0, - faded: (faded) => _animationController!.value); + fadeIn: (fadeIn) => _animationController!.value); AnimationController? get animation => _animationController; @@ -93,33 +93,33 @@ class TileImage extends ChangeNotifier { double zIndex(double maxZoom, int currentZoom) => maxZoom - (currentZoom - coordinates.z).abs(); - // Change the tile transition. - set transition(TileTransition newTransition) { - final oldTransition = _transition; - _transition = newTransition; + // Change the tile display options. + set tileDisplay(TileDisplay newTileDisplay) { + final oldTileDisplay = _display; + _display = newTileDisplay; // Handle disabling/enabling of animation controller if necessary - oldTransition.when( + oldTileDisplay.when( instantaneous: (instantaneous) { - newTransition.when( - faded: (faded) { + newTileDisplay.when( + fadeIn: (fadeIn) { // Became animated. _animationController = AnimationController( - duration: faded.tileFadeIn.duration, + duration: fadeIn.duration, vsync: vsync, value: _active ? 1.0 : 0.0, ); }, ); }, - faded: (faded) { - newTransition.when(instantaneous: (instantaneous) { + fadeIn: (fadeIn) { + newTileDisplay.when(instantaneous: (instantaneous) { // No longer animated. _animationController!.dispose(); _animationController = null; - }, faded: (faded) { + }, fadeIn: (fadeIn) { // Still animated with different fade. - _animationController!.duration = faded.tileFadeIn.duration; + _animationController!.duration = fadeIn.duration; }); }, ); @@ -172,21 +172,20 @@ class TileImage extends ChangeNotifier { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); - _transition.when( + _display.when( instantaneous: (_) { _active = true; if (!_disposed) notifyListeners(); }, - faded: (faded) { + fadeIn: (fadeIn) { if (loadError && errorImage != null) { _active = true; if (!_disposed) notifyListeners(); return; } - final fadeStartOpacity = previouslyLoaded - ? faded.tileFadeIn.reloadStartOpacity - : faded.tileFadeIn.startOpacity; + final fadeStartOpacity = + previouslyLoaded ? fadeIn.reloadStartOpacity : fadeIn.startOpacity; if (fadeStartOpacity == 1.0) { _active = true; diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index 2f2029169..18ea0441b 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -3,10 +3,10 @@ import 'package:flutter_map/src/core/point.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_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; typedef TileCreator = TileImage Function(TileCoordinates coordinates); @@ -80,9 +80,9 @@ class TileImageManager { return notLoaded; } - void updateTileTransition(TileTransition tileTransition) { + void updateTileDisplay(TileDisplay tileDisplay) { for (final tile in _tiles.values) { - tile.transition = tileTransition; + tile.tileDisplay = tileDisplay; } } diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 48e0153a6..19341a871 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -15,6 +15,7 @@ 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_builder.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_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; @@ -22,7 +23,6 @@ import 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider_web import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_transition.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; import 'package:flutter_map/src/map/flutter_map_state.dart'; @@ -107,6 +107,10 @@ class TileLayer extends StatefulWidget { /// https://c.tile.openstreetmap.org/{z}/{x}/{y}.png final List subdomains; + // Control how tiles are displayed and whether they are faded in when loaded. + // Defaults to TileDisplay.fadeIn(). + final TileDisplay tileDisplay; + /// Color shown behind the tiles final Color backgroundColor; @@ -223,9 +227,6 @@ class TileLayer extends StatefulWidget { /// MapEvent. final TileUpdateTransformer? tileUpdateTransformer; - /// Used internally to encapsulate the tile transition options. - final TileTransition _tileTransition; - TileLayer({ super.key, this.urlTemplate, @@ -242,49 +243,24 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor = const Color(0xFFE0E0E0), - - /// Sets the opacity of tile images to the given value (0.0 - 1.0), default - /// 1.0. When opacity is not 1.0 [fastReplace] is always enabled to prevent - /// overlapping tiles from being simultaneously visible. This disables fade - /// in and is not suitable for interactive maps, see [fastReplace] for more - /// information. - /// - /// If you need the map to be interactive and transparent you should wrap - /// [TileLayer] in an [Opacity] widget. This is less performant but avoids - /// flickering when moving the map. - double opacity = 1.0, this.errorImage, TileProvider? tileProvider, this.tms = false, this.wmsOptions, - - /// Options for fading in tiles when they are loaded. If this is set to null - /// no fade is performed. - TileFadeIn? tileFadeIn, + this.tileDisplay = const TileDisplay.fadeIn(), this.overrideTilesWhenUrlChanges = false, this.retinaMode = false, this.errorTileCallback, this.templateFunction = util.template, this.tileBuilder, this.evictErrorTileStrategy = EvictErrorTileStrategy.none, - - /// This option is useful when you have a transparent layer: rather than - /// keeping the old layer visible when zooming (resulting in both layers - /// being temporarily visible), the old layer is removed as quickly as - /// possible when this is set to `true` (default `false`). - /// - /// This option is likely to cause some flickering of the transparent layer, - /// most noticeable when using pinch-to-zoom. It's best used with maps that - /// have `interactive` set to `false`, and zoom using buttons that call - /// `MapController.move()`. - /// - /// When set to `true`, the `tileFadeIn*` options will be ignored. - bool? fastReplace, this.reset, this.tileBounds, this.tileUpdateTransformer, String userAgentPackageName = 'unknown', - }) : assert(tileFadeIn == null || tileFadeIn.duration > Duration.zero), + }) : assert(tileDisplay.map( + instantaneous: (_) => true, + fadeIn: (fadeIn) => fadeIn.duration > Duration.zero)), maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -311,12 +287,7 @@ class TileLayer extends StatefulWidget { ...tileProvider.headers, if (!tileProvider.headers.containsKey('User-Agent')) 'User-Agent': 'flutter_map ($userAgentPackageName)', - }), - _tileTransition = TileTransition.from( - opacity: opacity, - fastReplace: fastReplace, - tileFadeIn: tileFadeIn, - ); + }); @override State createState() => _TileLayerState(); @@ -477,8 +448,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileImageManager.removeAll(widget.evictErrorTileStrategy); _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); - } else if (oldWidget._tileTransition != widget._tileTransition) { - _tileImageManager.updateTileTransition(widget._tileTransition); + } else if (oldWidget.tileDisplay != widget.tileDisplay) { + _tileImageManager.updateTileDisplay(widget.tileDisplay); } } @@ -568,7 +539,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { ), onLoadError: _onTileLoadError, onLoadComplete: _onTileLoadComplete, - transition: widget._tileTransition, + tileDisplay: widget.tileDisplay, errorImage: widget.errorImage, ); } @@ -682,14 +653,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { return; } - widget._tileTransition.when(instantaneous: (_) { + widget.tileDisplay.when(instantaneous: (_) { _tileImageManager.prune(widget.evictErrorTileStrategy); - }, faded: (faded) { + }, 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( - faded.duration + const Duration(milliseconds: 50), + fadeIn.duration + const Duration(milliseconds: 50), () => _tileImageManager.prune(widget.evictErrorTileStrategy), ); }); diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart index 4ccf55b25..925772ba0 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -109,25 +109,3 @@ class WMSTileLayerOptions { return buffer.toString(); } } - -class TileFadeIn { - final Duration duration; - final double startOpacity; - final double reloadStartOpacity; - - /// Options for fading in tiles when they are loaded. - const TileFadeIn({ - /// Duration of the fade. - this.duration = const Duration(milliseconds: 100), - - /// Opacity start value when a tile is faded in. Valid range is (0.0 - 0.1). - this.startOpacity = 0.0, - - /// Opacity start value when a tile is reloaded. A tile reload will occur - /// when the provider tile url changes and - /// [TileLayer.overrideTilesWhenUrlChanges] is true. - /// provider url. Valid range is (0.0 - 0.1). - this.reloadStartOpacity = 0.0, - }) : assert(startOpacity >= 0.0 && startOpacity <= 1.0), - assert(reloadStartOpacity >= 0.0 && reloadStartOpacity <= 1.0); -} diff --git a/lib/src/layer/tile_layer/tile_transition.dart b/lib/src/layer/tile_layer/tile_transition.dart deleted file mode 100644 index 315557330..000000000 --- a/lib/src/layer/tile_layer/tile_transition.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; - -abstract class TileTransition { - const TileTransition(); - - factory TileTransition.from({ - required double opacity, - bool? fastReplace, - TileFadeIn? tileFadeIn, - }) { - if ((opacity != 1.0) || fastReplace == true) { - return InstantaneousTileTransition(opacity: opacity); - } - - return FadedTileTransition(tileFadeIn ?? const TileFadeIn()); - } - - T map({ - required T Function(InstantaneousTileTransition instantaneous) - instantaneous, - required T Function(FadedTileTransition faded) faded, - }) { - switch (runtimeType) { - case InstantaneousTileTransition: - return instantaneous(this as InstantaneousTileTransition); - case FadedTileTransition: - return faded(this as FadedTileTransition); - default: - throw 'Unknown TileTransition type: $runtimeType'; - } - } - - void when({ - void Function(InstantaneousTileTransition instantaneous)? instantaneous, - void Function(FadedTileTransition faded)? faded, - }) { - switch (runtimeType) { - case InstantaneousTileTransition: - return instantaneous?.call(this as InstantaneousTileTransition); - case FadedTileTransition: - return faded?.call(this as FadedTileTransition); - default: - throw 'Unknown TileTransition type: $runtimeType'; - } - } -} - -class InstantaneousTileTransition extends TileTransition { - final double opacity; - - const InstantaneousTileTransition({ - this.opacity = 1.0, - }) : assert(opacity >= 0.0 && opacity <= 1.0); -} - -class FadedTileTransition extends TileTransition { - final TileFadeIn tileFadeIn; - - const FadedTileTransition(this.tileFadeIn); - - Duration get duration => tileFadeIn.duration; -} From fbf782c102e9e2d687edec302e70f6aa465ebd72 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Apr 2023 20:19:54 +0100 Subject: [PATCH 26/51] Privatized `TileDisplay` extender's constructors Minor documentation fixes --- lib/src/layer/tile_layer/tile_display.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_display.dart b/lib/src/layer/tile_layer/tile_display.dart index 74a1f39d5..e096812d3 100644 --- a/lib/src/layer/tile_layer/tile_display.dart +++ b/lib/src/layer/tile_layer/tile_display.dart @@ -17,7 +17,7 @@ abstract class TileDisplay { /// If you wish to show a transparent map without these restrictions you /// can simply wrap the entire [TileLayer] in an [Opacity] widget. double opacity, - }) = InstantaneousTileDisplay; + }) = InstantaneousTileDisplay._; /// Fade in the tile when it is loaded. Not that opacity is not supported /// when fading is enabled. This is because underlying tiles are kept when @@ -32,15 +32,15 @@ abstract class TileDisplay { Duration duration, /// Opacity start value when a tile is faded in, default 1.0. The allowed - /// range is (0.0 - 0.1). + /// range is (0.0 - 1.0). double startOpacity, /// Opacity start value when a tile is reloaded, default 1.0. A tile reload /// will occur when the provider tile url changes and /// [TileLayer.overrideTilesWhenUrlChanges] is true. Valid range is - /// (0.0 - 0.1). + /// (0.0 - 1.0). double reloadStartOpacity, - }) = FadeInTileDisplay; + }) = FadeInTileDisplay._; T map({ required T Function(InstantaneousTileDisplay instantaneous) instantaneous, @@ -74,7 +74,7 @@ abstract class TileDisplay { class InstantaneousTileDisplay extends TileDisplay { final double opacity; - const InstantaneousTileDisplay({ + const InstantaneousTileDisplay._({ this.opacity = 1.0, }) : assert(opacity >= 0.0 && opacity <= 1.0); @@ -94,7 +94,7 @@ class FadeInTileDisplay extends TileDisplay { final double reloadStartOpacity; /// Options for fading in tiles when they are loaded. - const FadeInTileDisplay({ + const FadeInTileDisplay._({ this.duration = const Duration(milliseconds: 100), this.startOpacity = 0.0, this.reloadStartOpacity = 0.0, From 601e6a504056e5090689cb05ab6bd4ee4262319b Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Apr 2023 20:44:28 +0100 Subject: [PATCH 27/51] Unified `TileDisplay`'s `.map` and `.when` methods --- lib/src/layer/tile_layer/tile_display.dart | 23 ++++------------------ lib/src/layer/tile_layer/tile_image.dart | 13 ++++++------ lib/src/layer/tile_layer/tile_layer.dart | 10 ++++++---- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_display.dart b/lib/src/layer/tile_layer/tile_display.dart index e096812d3..ca96c4aa6 100644 --- a/lib/src/layer/tile_layer/tile_display.dart +++ b/lib/src/layer/tile_layer/tile_display.dart @@ -42,32 +42,17 @@ abstract class TileDisplay { double reloadStartOpacity, }) = FadeInTileDisplay._; - T map({ - required T Function(InstantaneousTileDisplay instantaneous) instantaneous, - required T Function(FadeInTileDisplay fadeIn) fadeIn, - }) { - switch (runtimeType) { - case InstantaneousTileDisplay: - return instantaneous(this as InstantaneousTileDisplay); - case FadeInTileDisplay: - return fadeIn(this as FadeInTileDisplay); - default: - throw 'Unknown TileDisplay type: $runtimeType'; - } - } - - void when({ - void Function(InstantaneousTileDisplay instantaneous)? instantaneous, - void Function(FadeInTileDisplay fadeIn)? fadeIn, + T? map({ + T? Function(InstantaneousTileDisplay instantaneous)? instantaneous, + T? Function(FadeInTileDisplay fadeIn)? fadeIn, }) { switch (runtimeType) { case InstantaneousTileDisplay: return instantaneous?.call(this as InstantaneousTileDisplay); case FadeInTileDisplay: return fadeIn?.call(this as FadeInTileDisplay); - default: - throw 'Unknown TileDisplay type: $runtimeType'; } + return null; } } diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index a41e10b3d..e3be76dc3 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -80,8 +80,9 @@ class TileImage extends ChangeNotifier { ); double get opacity => _display.map( - instantaneous: (instantaneous) => _active ? instantaneous.opacity : 0.0, - fadeIn: (fadeIn) => _animationController!.value); + instantaneous: (instantaneous) => _active ? instantaneous.opacity : 0.0, + fadeIn: (fadeIn) => _animationController!.value, + )!; AnimationController? get animation => _animationController; @@ -99,9 +100,9 @@ class TileImage extends ChangeNotifier { _display = newTileDisplay; // Handle disabling/enabling of animation controller if necessary - oldTileDisplay.when( + oldTileDisplay.map( instantaneous: (instantaneous) { - newTileDisplay.when( + newTileDisplay.map( fadeIn: (fadeIn) { // Became animated. _animationController = AnimationController( @@ -113,7 +114,7 @@ class TileImage extends ChangeNotifier { ); }, fadeIn: (fadeIn) { - newTileDisplay.when(instantaneous: (instantaneous) { + newTileDisplay.map(instantaneous: (instantaneous) { // No longer animated. _animationController!.dispose(); _animationController = null; @@ -172,7 +173,7 @@ class TileImage extends ChangeNotifier { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); - _display.when( + _display.map( instantaneous: (_) { _active = true; if (!_disposed) notifyListeners(); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 19341a871..6fa7599cb 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -258,9 +258,11 @@ class TileLayer extends StatefulWidget { this.tileBounds, this.tileUpdateTransformer, String userAgentPackageName = 'unknown', - }) : assert(tileDisplay.map( - instantaneous: (_) => true, - fadeIn: (fadeIn) => fadeIn.duration > Duration.zero)), + }) : assert( + tileDisplay.map( + instantaneous: (_) => true, + fadeIn: (fadeIn) => fadeIn.duration > Duration.zero)!, + ), maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -653,7 +655,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { return; } - widget.tileDisplay.when(instantaneous: (_) { + widget.tileDisplay.map(instantaneous: (_) { _tileImageManager.prune(widget.evictErrorTileStrategy); }, fadeIn: (fadeIn) { // Wait a bit more than tileFadeInDuration to trigger a pruning so that From ad785589c1f77911a2e242ddf8049ea6d4f6890f Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Apr 2023 21:00:38 +0100 Subject: [PATCH 28/51] Simplified `TileProvider._getTileUrl` method --- .../tile_provider/base_tile_provider.dart | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 4d02f3a43..480de467a 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -23,22 +23,20 @@ abstract class TileProvider { void dispose() {} String _getTileUrl( - String urlTemplate, TileCoordinates coordinates, TileLayer options) { + String urlTemplate, + TileCoordinates coordinates, + TileLayer options, + ) { final z = _getZoomForUrl(coordinates, options); - final data = { + return options.templateFunction(urlTemplate, { 'x': coordinates.x.toString(), - 'y': coordinates.y.toString(), + 'y': (options.tms ? invertY(coordinates.y, z) : coordinates.y).toString(), 'z': z.toString(), 's': getSubdomain(coordinates, options), 'r': '@2x', - }; - if (options.tms) { - data['y'] = invertY(coordinates.y, z).toString(); - } - final allOpts = Map.from(data) - ..addAll(options.additionalOptions); - return options.templateFunction(urlTemplate, allOpts); + ...options.additionalOptions, + }); } /// Generate a valid URL for a tile, based on it's coordinates and the current From e88d6004b8be88bad98c1daf6dd5da6f258b4b54 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 4 Apr 2023 21:36:51 +0200 Subject: [PATCH 29/51] Make default TileUpdateTransformer ignore taps Given that we now default to using the TileUpdateTransformer removed listening to the move stream directly and we now always listen to the move stream mapped to TileLayerUpdate. This cleans up the TileLayer a bit and simplified implementation/combination for TileUpdateTransformers. --- .../lib/pages/animated_map_controller.dart | 15 ++-- lib/src/layer/tile_layer/tile_layer.dart | 39 +++++----- .../layer/tile_layer/tile_update_event.dart | 76 +++++++++++++------ .../tile_layer/tile_update_transformer.dart | 36 +++++---- 4 files changed, 104 insertions(+), 62 deletions(-) diff --git a/example/lib/pages/animated_map_controller.dart b/example/lib/pages/animated_map_controller.dart index 4811a1cd4..b33592f17 100644 --- a/example/lib/pages/animated_map_controller.dart +++ b/example/lib/pages/animated_map_controller.dart @@ -218,8 +218,10 @@ class AnimatedMapControllerPageState extends State /// #1263) we should just detect the appropriate AnimatedMove events and /// use their target zoom/center. final _animatedMoveTileUpdateTransformer = - TileUpdateTransformer.fromHandlers(handleData: (event, sink) { - final id = event is MapEventMove ? event.id : null; + TileUpdateTransformer.fromHandlers(handleData: (updateEvent, sink) { + final mapEvent = updateEvent.mapEvent; + + final id = mapEvent is MapEventMove ? mapEvent.id : null; if (id?.startsWith(AnimatedMapControllerPageState._startedId) == true) { final parts = id!.split('#')[2].split(','); final lat = double.parse(parts[0]); @@ -230,18 +232,19 @@ final _animatedMoveTileUpdateTransformer = // not prune. Disabling pruning means existing tiles will remain visible // whilst animating. sink.add( - TileUpdateEvent.loadOnly( + updateEvent.loadOnly( loadCenterOverride: LatLng(lat, lon), loadZoomOverride: zoom, ), ); } else if (id == AnimatedMapControllerPageState._inProgressId) { // Do not prune or load whilst animating so that any existing tiles remain - // visible. + // visible. A smarter implementation may start pruning once we are close to + // the target zoom/location. } else if (id == AnimatedMapControllerPageState._finishedId) { // We already prefetched the tiles when animation started so just prune. - sink.add(const TileUpdateEvent.pruneOnly()); + sink.add(updateEvent.pruneOnly()); } else { - sink.add(const TileUpdateEvent.loadAndPrune()); + sink.add(updateEvent); } }); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 6fa7599cb..468f355f1 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -221,11 +221,18 @@ class TileLayer extends StatefulWidget { /// Only load tiles that are within these bounds final LatLngBounds? tileBounds; - /// If provided this transformer may be used to modify how/when tile updates - /// are triggered. It is a StreamTransformer from MapEvent to TilUpdateEvent - /// and therefore filtering/modifying/debouncing may be perfored based on the - /// MapEvent. - final TileUpdateTransformer? tileUpdateTransformer; + /// This transformer modifies how/when tile updates and pruning are triggered + /// based on [MapEvent]s. It is a StreamTransformer and therefore it is + /// possible to filter/modify/debounce the [TileUpdateEvent]s. Defaults to + /// [TileUpdateTransformers.ignoreTapEvents] which disables loading/pruning + /// for map taps, secondary taps and long presses. + /// + /// If you want all [MapEvent]s to trigger loading and pruning use + /// [TileUpdateTransformers.alwaysLoadAndPrune]. + /// + /// Note: Changing the [tileUpdateTransformer] after TileLayer is created has + /// no affect. + final TileUpdateTransformer tileUpdateTransformer; TileLayer({ super.key, @@ -256,7 +263,7 @@ class TileLayer extends StatefulWidget { this.evictErrorTileStrategy = EvictErrorTileStrategy.none, this.reset, this.tileBounds, - this.tileUpdateTransformer, + TileUpdateTransformer? tileUpdateTransformer, String userAgentPackageName = 'unknown', }) : assert( tileDisplay.map( @@ -289,7 +296,9 @@ class TileLayer extends StatefulWidget { ...tileProvider.headers, if (!tileProvider.headers.containsKey('User-Agent')) 'User-Agent': 'flutter_map ($userAgentPackageName)', - }); + }), + tileUpdateTransformer = + tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents; @override State createState() => _TileLayerState(); @@ -313,7 +322,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { // TileLayer.tileUpdateTransformer is null then we subscribe to map movement // otherwise we subscribe to tile update events which are transformed from // map movements. - StreamSubscription? _mapMoveSubscription; StreamSubscription? _tileUpdateSubscription; StreamSubscription? _resetSub; @@ -344,19 +352,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { final mapController = mapState.mapController; if (_mapControllerHashCode != mapController.hashCode) { - _mapMoveSubscription?.cancel(); _tileUpdateSubscription?.cancel(); _mapControllerHashCode = mapController.hashCode; - if (widget.tileUpdateTransformer == null) { - _mapMoveSubscription = mapController.mapEventStream.listen((event) { - _loadAndPruneInVisibleBounds(mapState); - }); - } else { - _tileUpdateSubscription = mapController.mapEventStream - .transform(widget.tileUpdateTransformer!) - .listen((event) => _onTileUpdateEvent(mapState, event)); - } + _tileUpdateSubscription = mapController.mapEventStream + .map((mapEvent) => TileUpdateEvent(mapEvent: mapEvent)) + .transform(widget.tileUpdateTransformer) + .listen((event) => _onTileUpdateEvent(mapState, event)); } bool reloadTiles = false; @@ -457,7 +459,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { @override void dispose() { - _mapMoveSubscription?.cancel(); _tileUpdateSubscription?.cancel(); _tileImageManager.removeAll(widget.evictErrorTileStrategy); _resetSub?.cancel(); diff --git a/lib/src/layer/tile_layer/tile_update_event.dart b/lib/src/layer/tile_layer/tile_update_event.dart index b61c09d78..a8aacb3df 100644 --- a/lib/src/layer/tile_layer/tile_update_event.dart +++ b/lib/src/layer/tile_layer/tile_update_event.dart @@ -1,36 +1,66 @@ +import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:latlong2/latlong.dart'; /// Describes whether loading and/or pruning should occur and allows overriding -/// the load center/zoom. If loading/pruning is not desired the -/// [TileUpdateTransformer] should just not add a TileUpdateEvent to its sink. +/// the load center/zoom. class TileUpdateEvent { + final MapEvent mapEvent; final bool load; final bool prune; final LatLng? loadCenterOverride; final double? loadZoomOverride; - /// Do not load new tiles, only prune old ones. - const TileUpdateEvent.pruneOnly() - : load = false, - prune = true, - loadCenterOverride = null, - loadZoomOverride = null; - - /// Load new tiles, do not prune old ones. The loading center/zoom can be - /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they - /// will default to the map's current center/zoom. - const TileUpdateEvent.loadOnly({ + const TileUpdateEvent({ + required this.mapEvent, + this.load = true, + this.prune = true, this.loadCenterOverride, this.loadZoomOverride, - }) : load = true, - prune = false; + }); - /// Load new tiles and prune old ones. The loading center/zoom can be - /// overriden with [loadCenterOverride] and [loadZoomOverride] otherwise they - /// will default to the map's current center/zoom. - const TileUpdateEvent.loadAndPrune({ - this.loadCenterOverride, - this.loadZoomOverride, - }) : load = true, - prune = true; + /// Returns a copy of this TileUpdateEvent with only pruning enabled and the + /// loadCenterOverride/loadZoomOverride removed. + TileUpdateEvent pruneOnly() => TileUpdateEvent( + mapEvent: mapEvent, + load: false, + prune: true, + loadCenterOverride: null, + loadZoomOverride: null, + ); + + /// Returns a copy of this TileUpdateEvent with only loading enabled. The + /// loading center/zoom can be overriden with [loadCenterOverride] and + /// [loadZoomOverride] otherwise they will default to the map's current + /// center/zoom. + TileUpdateEvent loadOnly({ + LatLng? loadCenterOverride, + double? loadZoomOverride, + }) => + TileUpdateEvent( + mapEvent: mapEvent, + load: true, + prune: false, + loadCenterOverride: loadCenterOverride, + loadZoomOverride: loadZoomOverride, + ); + + /// Returns a copy of this TileUpdateEvent with loading and pruning enabled. + /// The loading center/zoom can be overriden with [loadCenterOverride] and + /// [loadZoomOverride] otherwise they will default to the map's current + /// center/zoom. + TileUpdateEvent loadAndPrune({ + LatLng? loadCenterOverride, + double? loadZoomOverride, + }) => + TileUpdateEvent( + mapEvent: mapEvent, + load: true, + prune: true, + loadCenterOverride: loadCenterOverride, + loadZoomOverride: loadZoomOverride, + ); + + @override + String toString() => + 'TileUpdateEvent(mapEvent: $mapEvent, load: $load, prune: $prune, loadCenterOverride: $loadCenterOverride, loadZoomOverride: $loadZoomOverride)'; } diff --git a/lib/src/layer/tile_layer/tile_update_transformer.dart b/lib/src/layer/tile_layer/tile_update_transformer.dart index 10f6c2953..c95e424d7 100644 --- a/lib/src/layer/tile_layer/tile_update_transformer.dart +++ b/lib/src/layer/tile_layer/tile_update_transformer.dart @@ -3,19 +3,27 @@ import 'dart:async'; import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; -typedef TileUpdateTransformer = StreamTransformer; +typedef TileUpdateTransformer + = StreamTransformer; -/// Avoid loading/updating tiles when a tap occurs on the assumption that it -/// should not cause new tiles to be loaded. -final ignoreTapEventsTransformer = - TileUpdateTransformer.fromHandlers(handleData: (event, sink) { - // Ignore known events that we know should not cause new tiles to load. - if (event is MapEventTap || - event is MapEventSecondaryTap || - event is MapEventLongPress) { - return; - } +class TileUpdateTransformers { + const TileUpdateTransformers._(); - // Let the event trigger load/prune. - sink.add(const TileUpdateEvent.loadAndPrune()); -}); + /// Avoid loading/updating tiles when a tap occurs on the assumption that it + /// should not cause new tiles to be loaded. + static final ignoreTapEvents = + TileUpdateTransformer.fromHandlers(handleData: (event, sink) { + if (!_triggeredByTap(event)) sink.add(event); + }); + + /// Always load and update tiles for every map event. + static final alwaysLoadAndPrune = + TileUpdateTransformer.fromHandlers(handleData: (event, sink) { + sink.add(event); + }); + + static bool _triggeredByTap(TileUpdateEvent event) => + event.mapEvent is MapEventTap || + event.mapEvent is MapEventSecondaryTap || + event.mapEvent is MapEventLongPress; +} From 64d20070b8c2001e7c26860e4fc9af4503b75048 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Tue, 4 Apr 2023 21:57:59 +0200 Subject: [PATCH 30/51] Add a throttling tile update transformer --- lib/src/core/util.dart | 6 +++++- lib/src/layer/tile_layer/tile_layer.dart | 8 +++----- .../layer/tile_layer/tile_update_transformer.dart | 12 ++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/src/core/util.dart b/lib/src/core/util.dart index fa030c815..43d3fae7a 100644 --- a/lib/src/core/util.dart +++ b/lib/src/core/util.dart @@ -23,7 +23,9 @@ String template(String str, Map data) { } StreamTransformer throttleStreamTransformerWithTrailingCall( - Duration duration) { + Duration duration, { + bool Function(T)? ignore, +}) { Timer? timer; T recentData; var trailingCall = false; @@ -31,6 +33,8 @@ StreamTransformer throttleStreamTransformerWithTrailingCall( late final void Function(T data, EventSink sink) throttleHandler; throttleHandler = (T data, EventSink sink) { + if (ignore?.call(data) == true) return; + recentData = data; if (timer == null) { diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 468f355f1..7350136f7 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -223,12 +223,10 @@ class TileLayer extends StatefulWidget { /// This transformer modifies how/when tile updates and pruning are triggered /// based on [MapEvent]s. It is a StreamTransformer and therefore it is - /// possible to filter/modify/debounce the [TileUpdateEvent]s. Defaults to + /// possible to filter/modify/throttle the [TileUpdateEvent]s. Defaults to /// [TileUpdateTransformers.ignoreTapEvents] which disables loading/pruning - /// for map taps, secondary taps and long presses. - /// - /// If you want all [MapEvent]s to trigger loading and pruning use - /// [TileUpdateTransformers.alwaysLoadAndPrune]. + /// for map taps, secondary taps and long presses. See TileUpdateTransformers + /// for more transformer presets or implement your own. /// /// Note: Changing the [tileUpdateTransformer] after TileLayer is created has /// no affect. diff --git a/lib/src/layer/tile_layer/tile_update_transformer.dart b/lib/src/layer/tile_layer/tile_update_transformer.dart index c95e424d7..953c94148 100644 --- a/lib/src/layer/tile_layer/tile_update_transformer.dart +++ b/lib/src/layer/tile_layer/tile_update_transformer.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter_map/src/core/util.dart'; import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; @@ -22,6 +23,17 @@ class TileUpdateTransformers { sink.add(event); }); + /// Throttle updates such that maximum one per [duration] is emitted. + static TileUpdateTransformer throttle( + Duration duration, { + /// If true tap events will be filtered out. + bool ignoreTapEvents = true, + }) => + throttleStreamTransformerWithTrailingCall( + duration, + ignore: ignoreTapEvents ? _triggeredByTap : null, + ); + static bool _triggeredByTap(TileUpdateEvent event) => event.mapEvent is MapEventTap || event.mapEvent is MapEventSecondaryTap || From 6fbd8f70146a8b55f828f986355b0ae1891e28b0 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 5 Apr 2023 08:05:10 +0200 Subject: [PATCH 31/51] Remove overrideTilesWhenUrlChanges option Now we will always hold on to old tiles until the new tiles have loaded to prevent flickering. --- lib/src/layer/tile_layer/tile_layer.dart | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 7350136f7..11c90ad50 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -177,12 +177,6 @@ class TileLayer extends StatefulWidget { /// ``` final Map additionalOptions; - /// `false`: current Tiles will be first dropped and then reload via new url - /// (default) `true`: current Tiles will be visible until new ones aren't - /// loaded (new Tiles are loaded independently) @see - /// https://github.com/johnpryan/flutter_map/issues/583 - final bool overrideTilesWhenUrlChanges; - /// If `true`, it will request four tiles of half the specified size and a /// bigger zoom level in place of one to utilize the high resolution. /// @@ -253,7 +247,6 @@ class TileLayer extends StatefulWidget { this.tms = false, this.wmsOptions, this.tileDisplay = const TileDisplay.fadeIn(), - this.overrideTilesWhenUrlChanges = false, this.retinaMode = false, this.errorTileCallback, this.templateFunction = util.template, @@ -438,12 +431,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (oldUrl != newUrl || !(const MapEquality()) .equals(oldOptions, newOptions)) { - if (widget.overrideTilesWhenUrlChanges) { - _tileImageManager.reloadImages(widget, _tileBounds); - _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); - } else { - reloadTiles = true; - } + _tileImageManager.reloadImages(widget, _tileBounds); + _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); } } From 543b64139c43858722646a0726aaebffa7737941 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 11 Apr 2023 17:25:58 +0100 Subject: [PATCH 32/51] Updated CHANGELOG Updated GitHub Workflow --- .github/workflows/flutter.yml | 2 +- CHANGELOG.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 5a66de71e..28de0c735 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -1,5 +1,5 @@ name: Analyse & Build -on: [push, workflow_dispatch] +on: [push, pull_request, workflow_dispatch] jobs: package-analysis: diff --git a/CHANGELOG.md b/CHANGELOG.md index 640f15dda..21cde1f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Contains the following additions/removals: - Removed `absorbPanEventsOnScrollables` option - [#1455](https://github.com/fleaflet/flutter_map/pull/1455) for [#1454](https://github.com/fleaflet/flutter_map/issues/1454) - Removed leftover deprecations - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) - Minor example application improvements - [#1440](https://github.com/fleaflet/flutter_map/pull/1440) +- Rotation gestures now cause rotation about the gesture center - [#1437](https://github.com/fleaflet/flutter_map/pull/1437) +- Improve number (`num`/`int`/`double`) consistency internally - [#1482](https://github.com/fleaflet/flutter_map/pull/1482) Contains the following bug fixes: @@ -19,7 +21,7 @@ Contains the following bug fixes: Contains the following performance and stability improvements: -- Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) +- Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) & [#1462](https://github.com/fleaflet/flutter_map/pull/1462) - Added a threshold for rasterization to avoid excessive fixed overhead cost for cheap redraws - [#1462](https://github.com/fleaflet/flutter_map/pull/1462) Many thanks to these contributors (in no particular order): From 593a592f72167fbed82ca25855240e8f1d9bfc98 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 11 Apr 2023 17:27:29 +0100 Subject: [PATCH 33/51] Applied formatting to 'gestures.dart' --- lib/src/gestures/gestures.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/gestures/gestures.dart b/lib/src/gestures/gestures.dart index 62f273367..1ca39f57d 100644 --- a/lib/src/gestures/gestures.dart +++ b/lib/src/gestures/gestures.dart @@ -480,11 +480,15 @@ abstract class MapGestureMixin extends State if (_rotationStarted) { final rotationDiff = currentRotation - _lastRotation; final oldCenterPt = mapState.project(mapState.center); - final rotationCenter = mapState.project(_offsetToCrs(_lastFocalLocal)); + final rotationCenter = + mapState.project(_offsetToCrs(_lastFocalLocal)); final vector = oldCenterPt - rotationCenter; final rotatedVector = vector.rotate(degToRadian(rotationDiff)); final newCenter = rotationCenter + rotatedVector; - mapMoved = mapState.move(mapState.unproject(newCenter), mapState.zoom, source: eventSource) || mapMoved; + mapMoved = mapState.move( + mapState.unproject(newCenter), mapState.zoom, + source: eventSource) || + mapMoved; mapRotated = mapState.rotate( mapState.rotation + rotationDiff, hasGesture: true, From cc4651dca42831a15720a1e64508e83b492c0408 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 11 Apr 2023 17:55:56 +0100 Subject: [PATCH 34/51] Updated version Updated LICENSE to better represent copyright claims Removed 'CONTRIBUTING.md' --- CONTRIBUTING.md | 7 ------- LICENSE | 6 +++--- pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b79bef70c..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,7 +0,0 @@ -# Contributing - -*Please note: A code of conduct is present in this project, please follow it in all your interactions with this project, including contributions.* - -Read the contributing instructions on the documentation website: . - -We always appreciate your ideas and changes! diff --git a/LICENSE b/LICENSE index 7acb4398a..760dc4f77 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -Copyright (c) 2020, the flutter_map authors -Copyright (c) 2010-2019, Vladimir Agafonkin -Copyright (c) 2010-2011, CloudMade +Copyright (c) 2018-2023, the 'flutter_map' authors and maintainers +Loosely based on the original works of 'leaflet.js' (c) by Vladimir Agafonkin & CloudMade + All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/pubspec.yaml b/pubspec.yaml index 32f0f4e74..af7c437b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map description: A versatile mapping package for Flutter, based off leaflet.js, that's simple and easy to learn, yet completely customizable and configurable. -version: 4.0.0-dev.1 +version: 4.0.0 repository: https://github.com/fleaflet/flutter_map issue_tracker: https://github.com/fleaflet/flutter_map/issues documentation: https://docs.fleaflet.dev From 8d0b44779ae604dca60edf866a4908bcbc447d71 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 14 Apr 2023 22:28:30 +0100 Subject: [PATCH 35/51] Updated CHANGELOG to include #1487 --- CHANGELOG.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21cde1f0e..6c6955942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,29 @@ ## [4.0.0] - 2022/04/XX -Contains the following additions/removals: +**"Out With The Old, In With The New"** + +Contains the following improvements: -- Reimplemented `TileLayer` and underlying systems šŸŽ‰ - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) +- šŸŽ‰ Reimplemented `TileLayer` and underlying systems - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) +- šŸŽ‰ Reimplemented attribution layers - [#1487](https://github.com/fleaflet/flutter_map/pull/1487) & [#1390](https://github.com/fleaflet/flutter_map/pull/1390) - Added secondary tap handling to `MapOptions` - [#1448](https://github.com/fleaflet/flutter_map/pull/1448) for [#1444](https://github.com/fleaflet/flutter_map/issues/1444) -- Migrated `LatLngBounds` to proper null safety - [#1431](https://github.com/fleaflet/flutter_map/pull/1431) - Removed `LatLngBounds.pad` (unused and broken) method - [#1427](https://github.com/fleaflet/flutter_map/pull/1427) - Removed `absorbPanEventsOnScrollables` option - [#1455](https://github.com/fleaflet/flutter_map/pull/1455) for [#1454](https://github.com/fleaflet/flutter_map/issues/1454) - Removed leftover deprecations - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) -- Minor example application improvements - [#1440](https://github.com/fleaflet/flutter_map/pull/1440) -- Rotation gestures now cause rotation about the gesture center - [#1437](https://github.com/fleaflet/flutter_map/pull/1437) -- Improve number (`num`/`int`/`double`) consistency internally - [#1482](https://github.com/fleaflet/flutter_map/pull/1482) +- Improved rotation gestures (cause rotation about the gesture center) - [#1437](https://github.com/fleaflet/flutter_map/pull/1437) +- Improved number (`num`/`int`/`double`) consistency internally - [#1482](https://github.com/fleaflet/flutter_map/pull/1482) +- Minor example application improvements - [#1440](https://github.com/fleaflet/flutter_map/pull/1440) & [#1487](https://github.com/fleaflet/flutter_map/pull/1487) Contains the following bug fixes: -- Fixed deprecations - [#1438](https://github.com/fleaflet/flutter_map/pull/1438) - Prevented scrolling of list and simultaneous panning of map on some platforms - [#1453](https://github.com/fleaflet/flutter_map/pull/1453) +- Improved `LatLngBounds`'s null safety situation to improve stability - [#1431](https://github.com/fleaflet/flutter_map/pull/1431) +- Migrated from multiple deprecated APIs - [#1438](https://github.com/fleaflet/flutter_map/pull/1438) Contains the following performance and stability improvements: -- Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) & [#1462](https://github.com/fleaflet/flutter_map/pull/1462) +- šŸŽ‰ Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) & [#1462](https://github.com/fleaflet/flutter_map/pull/1462) - Added a threshold for rasterization to avoid excessive fixed overhead cost for cheap redraws - [#1462](https://github.com/fleaflet/flutter_map/pull/1462) Many thanks to these contributors (in no particular order): @@ -30,6 +33,7 @@ Many thanks to these contributors (in no particular order): - @augustweinbren - @ignatz - @rorystephenson +- @ianthetechie - ... and all the maintainers And an additional special thanks to @rorystephenson & @ignatz for investing so much of his time into this project recently - we appreciate it! @@ -66,6 +70,8 @@ Many thanks to these contributors (in no particular order): ## [3.0.0] - 2022/09/04 +**"Boiler(plate) Repairs"** + Contains the following additions/removals: - Multiple changes - [#1333](https://github.com/fleaflet/flutter_map/pull/1333) @@ -161,6 +167,8 @@ Many thanks to these contributors (in no particular order): ## [2.0.0] - 2022/07/11 +**"~~Blocked By OSM~~"** + Contains the following additions/removals: - Added adjustable mouse wheel zoom speed - [#1289](https://github.com/fleaflet/flutter_map/pull/1289) From 5a14d01f2e5b353a0a2315b78a643572d28a4f7e Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 21 Apr 2023 18:05:28 +0100 Subject: [PATCH 36/51] Updated CHANGELOG to include #1495 --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6955942..b23ee280e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ Contains the following improvements: -- šŸŽ‰ Reimplemented `TileLayer` and underlying systems - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) -- šŸŽ‰ Reimplemented attribution layers - [#1487](https://github.com/fleaflet/flutter_map/pull/1487) & [#1390](https://github.com/fleaflet/flutter_map/pull/1390) +- Reimplemented `TileLayer` and underlying systems - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) +- Reimplemented attribution layers - [#1487](https://github.com/fleaflet/flutter_map/pull/1487) & [#1390](https://github.com/fleaflet/flutter_map/pull/1390) - Added secondary tap handling to `MapOptions` - [#1448](https://github.com/fleaflet/flutter_map/pull/1448) for [#1444](https://github.com/fleaflet/flutter_map/issues/1444) +- Refactored `FlutterMapState`'s `maybeOf` method into `maybeOf` & `of` - [#1495](https://github.com/fleaflet/flutter_map/pull/1495) - Removed `LatLngBounds.pad` (unused and broken) method - [#1427](https://github.com/fleaflet/flutter_map/pull/1427) - Removed `absorbPanEventsOnScrollables` option - [#1455](https://github.com/fleaflet/flutter_map/pull/1455) for [#1454](https://github.com/fleaflet/flutter_map/issues/1454) - Removed leftover deprecations - [#1475](https://github.com/fleaflet/flutter_map/pull/1475) @@ -24,7 +25,7 @@ Contains the following bug fixes: Contains the following performance and stability improvements: -- šŸŽ‰ Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) & [#1462](https://github.com/fleaflet/flutter_map/pull/1462) +- Batched polygon and polyline rendering to minimize redraws and maximize their efficiency - [#1442](https://github.com/fleaflet/flutter_map/pull/1442) & [#1462](https://github.com/fleaflet/flutter_map/pull/1462) - Added a threshold for rasterization to avoid excessive fixed overhead cost for cheap redraws - [#1462](https://github.com/fleaflet/flutter_map/pull/1462) Many thanks to these contributors (in no particular order): @@ -36,7 +37,7 @@ Many thanks to these contributors (in no particular order): - @ianthetechie - ... and all the maintainers -And an additional special thanks to @rorystephenson & @ignatz for investing so much of his time into this project recently - we appreciate it! +And an additional special thanks to @rorystephenson & @ignatz for investing so much of their time into this project recently - we appreciate it! ## [3.1.0] - 2022/12/21 From 8324c0c835b73b69f2a985e1569ed4d3ec3b849b Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 22 Apr 2023 12:57:56 +0100 Subject: [PATCH 37/51] Fixed Android build warning in example app --- example/android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index e6d6224dc..145f610b0 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 31 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' From 6e06533b045e96b14fcf62d08ec717d853a25279 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 23 Apr 2023 12:27:36 +0100 Subject: [PATCH 38/51] Updated GitHub configuration --- .github/ISSUE_TEMPLATE/bug-report.yaml | 101 ++++++-------------- .github/ISSUE_TEMPLATE/config.yml | 13 +-- .github/ISSUE_TEMPLATE/feature-request.yaml | 62 ++++-------- .github/workflows/flutter.yml | 2 +- 4 files changed, 58 insertions(+), 120 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 87454f1d1..21a40f273 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -8,63 +8,55 @@ body: value: " # Bug Report - Thanks for taking the time to fill out this bug report! It helps us to improve the experience for you and other developers that may be facing a similar problem. We aim to respond to bug reports as soon as possible, but it may take longer to resolve this issue depending on its complexity and severity. To help us verify the issue quicker, please include as much information as you can. + Thanks for taking the time to fill out this bug report! To help us verify the issue quicker, please include as much information as you can. + + + --- + + + Before reporting a bug, please: + + * Check if there is already an open or closed issue that is similar to yours + + * Ensure that you have fully read the documentation (both the website and API docs) that pertains to the function you're having problems with + + * Ensure that your Flutter environment is correctly installed & set-up + + * Remember that we're volunteers trying our best to help, so please be polite + + + --- " - - type: markdown - attributes: - value: --- - type: textarea - id: description + id: details attributes: label: What is the bug? - description: What were you implementing when you found this issue? What happens when the bug triggers? - validations: - required: true - - type: textarea - id: expected-behaviour - attributes: - label: What is the expected behaviour? - description: What do you think should have happened? + description: What were you implementing when you found this issue? What happens when the bug triggers? What do you think should have happened instead? Please include as much detail as possible, including screenshots and screen-recordings if you can. validations: required: true - type: textarea id: reproduce attributes: - label: How can we reproduce this issue? + label: How can we reproduce it? description: | - Please include a [minimal reproducible example](https://en.wikipedia.org/wiki/Minimal_reproducible_example) (preferable), otherwise detail the exact steps to reproduce this issue. - If you do not include any information here, it will take longer for us to verify your issue. - placeholder: Text automatically formatted as Dart code, on submission - render: dart + Please include a fully formatted [minimal reproducible example](https://en.wikipedia.org/wiki/Minimal_reproducible_example) wrapped in a [Dart code block](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting), otherwise, detail the exact steps to reproduce this issue. + If you do not include any information here, it will take longer for us to verify your issue. + validations: + required: true - type: textarea id: solution attributes: label: Do you have a potential solution? - description: "If so, please detail it: it will make it quicker for us to fix the issue" - - type: textarea - id: additional-info - attributes: - label: Can you provide any other information? - description: | - Please attach any other logs, screenshots, or screen recordings. - Is there anything else you'd like to say? + description: "If so, please detail it: it will make it quicker for us to fix the issue." - type: markdown attributes: value: --- - - type: dropdown + - type: input id: platform attributes: - label: Platforms Affected - description: What platforms does this issue affect? - multiple: true - options: - - Android - - iOS - - Web - - Windows - - MacOS - - Linux - - Other + label: Platforms + description: Please detail the devices and operating systems you can reproduce this bug on, separated by commas. + placeholder: eg. Android 13 (Samsung Galaxy S99), Windows 11 (x64) validations: required: true - type: dropdown @@ -78,35 +70,4 @@ body: - "Erroneous: Prevents normal functioning and causes errors in the console" - "Fatal: Causes the application to crash" validations: - required: true - - type: dropdown - id: frequency - attributes: - label: Frequency - description: How often does this issue occur? - options: - - "Once: Occurred on a single occasion" - - "Rarely: Occurs every so often" - - "Often: Occurs more often than when it doesn't" - - "Consistently: Always occurs at the same time and location" - validations: - required: true - - type: markdown - attributes: - value: --- - - type: checkboxes - id: terms - attributes: - label: Requirements - description: These are in place to prevent spam and unnecessary reports. - options: - - label: I agree to follow this project's [Code of Conduct](https://github.com/fleaflet/flutter_map/blob/master/CODE_OF_CONDUCT.md) - required: true - - label: My Flutter/Dart installation is unaltered, and `flutter doctor` finds no relevant issues - required: true - - label: I am using the [latest stable version](https://pub.dev/packages/flutter_map) of this package - required: true - - label: I have checked the FAQs section on the documentation website - required: true - - label: I have checked for similar issues which may be duplicates - required: true + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 35e62dc6f..ecc1b73da 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Discord Server + - name: Get Help url: https://discord.gg/egEGeByf4q - about: Need more generalised help, or just want to talk? Join the Discord server! - - name: Common Issues - url: https://docs.fleaflet.dev/usage/common-issues - about: Check whether your issue is listed as a common issue in the documentation - - name: Plugins - url: https://docs.fleaflet.dev/plugins/list - about: If you need help with a plugin, please ask on their issue tracker first + about: Don't quite understand how to implement something, or just want to talk? Join the Discord server! + - name: Documentation + url: https://docs.fleaflet.dev/ + about: Before posting an issue, please ensure you read the documentation thoroughly diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 7e042184f..a28faa540 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -8,11 +8,24 @@ body: value: " # Feature Request - Thanks for taking the time to let us know what you want to see! It helps us to improve the experience for you and other developers that may be facing a similar problem. Unfortunately, response times for feature requests are longer than bug reports, as they are lower priority. If you want this functionality implemented quickly, please make a Pull Request to go alongside this feature request. + Thanks for taking the time to let us know what you want to see! + Unfortunately, response times for feature requests are longer than bug reports, as they are lower priority. If you want this functionality implemented quickly, please make a Pull Request to go alongside this feature request. + + + --- + + + Before requesting a feature, please: + + * Check if there is already an open or closed issue that is similar to yours + + * Ensure that you're using the latest version of flutter_map + + * Ensure that you've read the documentation (both the website and API docs) thoroughly + + + --- " - - type: markdown - attributes: - value: --- - type: textarea id: description attributes: @@ -25,32 +38,14 @@ body: attributes: label: What other alternatives are available? description: Have you used any workarounds, for example? - - type: textarea - id: additional-info - attributes: - label: Can you provide any other information? - description: | - Please attach any other logs, screenshots, or screen recordings. - Is there anything else you'd like to say? - type: markdown attributes: value: --- - - type: dropdown - id: platform + - type: textarea + id: additional-info attributes: - label: Platforms Affected - description: What platforms does this issue affect? - multiple: true - options: - - Android - - iOS - - Web - - Windows - - MacOS - - Linux - - Other - validations: - required: true + label: Can you provide any other information? + description: Is there anything else you'd like to say? - type: dropdown id: severity attributes: @@ -62,18 +57,3 @@ body: - "Obtrusive: No workarounds are available, and this is essential to me" validations: required: true - - type: markdown - attributes: - value: --- - - type: checkboxes - id: terms - attributes: - label: Requirements - description: These are in place to prevent spam and unnecessary reports. - options: - - label: I agree to follow this project's [Code of Conduct](https://github.com/fleaflet/flutter_map/blob/master/CODE_OF_CONDUCT.md) - required: true - - label: I am using the [latest stable version](https://pub.dev/packages/flutter_map) of this package - required: true - - label: I have checked for similar feature requests which may be duplicates - required: true diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 28de0c735..9cc15d37d 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -1,4 +1,4 @@ -name: Analyse & Build +name: Analyse on: [push, pull_request, workflow_dispatch] jobs: From 38561ad59da196702c0865effb64d71ac21d4fa9 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 23 Apr 2023 13:16:05 +0100 Subject: [PATCH 39/51] Added example app building to workflow Improvements to the example app and runner --- .../{flutter.yml => analyse-test.yml} | 29 +++--- .github/workflows/build.yml | 88 ++++++++++++++++++ example/assets/ProjectIcon.ico | Bin 0 -> 4286 bytes example/assets/ProjectIcon.png | Bin 0 -> 2424 bytes example/lib/main.dart | 2 +- example/lib/widgets/drawer.dart | 22 ++++- example/pubspec.yaml | 1 + example/web/favicon.png | Bin 917 -> 2424 bytes example/web/icons/Icon-192.png | Bin 5292 -> 14344 bytes example/web/icons/Icon-512.png | Bin 8252 -> 78671 bytes example/web/icons/Icon-maskable-192.png | Bin 5594 -> 14344 bytes example/web/icons/Icon-maskable-512.png | Bin 20998 -> 78671 bytes example/web/index.html | 7 +- example/web/manifest.json | 4 +- example/windows/runner/main.cpp | 2 +- example/windows/runner/resources/app_icon.ico | Bin 33772 -> 4286 bytes tool/windowsApplicationInstallerSetup.iss | 78 ++++++++++++++++ 17 files changed, 211 insertions(+), 22 deletions(-) rename .github/workflows/{flutter.yml => analyse-test.yml} (91%) create mode 100644 .github/workflows/build.yml create mode 100644 example/assets/ProjectIcon.ico create mode 100644 example/assets/ProjectIcon.png create mode 100644 tool/windowsApplicationInstallerSetup.iss diff --git a/.github/workflows/flutter.yml b/.github/workflows/analyse-test.yml similarity index 91% rename from .github/workflows/flutter.yml rename to .github/workflows/analyse-test.yml index 9cc15d37d..0a5b01f5b 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/analyse-test.yml @@ -1,9 +1,9 @@ -name: Analyse +name: Analyse & Test on: [push, pull_request, workflow_dispatch] jobs: - package-analysis: - name: "Analyse Package" + score-package: + name: "Score Package" runs-on: ubuntu-latest steps: - name: Checkout Repository @@ -24,29 +24,34 @@ jobs: exit 1 fi - content-analysis: - name: "Analyse Contents" + analyse-code: + name: "Analyse Code" runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - - name: Setup Java 17 Environment - uses: actions/setup-java@v3 - with: - distribution: "temurin" - java-version: "17" - name: Setup Flutter Environment uses: subosito/flutter-action@v2 with: channel: "stable" - - name: Ensure Correct Flutter Installation - run: flutter --version - name: Get All Dependencies run: flutter pub get - name: Check Formatting run: dart format --output=none --set-exit-if-changed . - name: Check Lints run: dart analyze --fatal-infos --fatal-warnings + + run-tests: + name: "Run Tests" + runs-on: ubuntu-latest + needs: analyse-code + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Setup Flutter Environment + uses: subosito/flutter-action@v2 + with: + channel: "stable" - name: Run Tests run: flutter test -r expanded diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..7142dd40b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,88 @@ +name: Build +on: + workflow_run: + workflows: ["Analyse & Test"] + branches: [master] + types: + - completed + workflow_call: + +jobs: + build-web: + name: "Build Web Example App" + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + defaults: + run: + working-directory: ./example + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Setup Flutter Environment + uses: subosito/flutter-action@v2 + with: + channel: "stable" + - name: Build Web Application + run: flutter build web + - name: Archive Artifact + uses: actions/upload-artifact@master + with: + name: web-build + path: build/web + + build-android: + name: "Build Android Example App" + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + defaults: + run: + working-directory: ./example + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Setup Java 17 Environment + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + - name: Setup Flutter Environment + uses: subosito/flutter-action@v2 + with: + channel: "stable" + - name: Build Android Application + run: flutter build apk --obfuscate --split-debug-info=/symbols + - name: Archive Artifact + uses: actions/upload-artifact@master + with: + name: apk-build + path: build/app/outputs/apk/release + + build-windows: + name: "Build Windows Example App" + runs-on: windows-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + defaults: + run: + working-directory: ./example + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Setup Java 17 Environment + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + - name: Setup Flutter Environment + uses: subosito/flutter-action@v2 + with: + channel: "stable" + - name: Build Windows Application + run: flutter build windows --obfuscate --split-debug-info=/symbols + - name: Create Windows Application Installer + run: iscc "tool/windowsApplicationInstallerSetup.iss" + working-directory: . + - name: Archive Artifact + uses: actions/upload-artifact@master + with: + name: exe-build + path: tool/windowsTemp/WindowsApplication.exe \ No newline at end of file diff --git a/example/assets/ProjectIcon.ico b/example/assets/ProjectIcon.ico new file mode 100644 index 0000000000000000000000000000000000000000..07baf3221fa6b4ffaf201b756d84b2569945424c GIT binary patch literal 4286 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 diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index 4769aa3e2..35eb43efa 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -58,9 +58,25 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { return Drawer( child: ListView( children: [ - const DrawerHeader( - child: Center( - child: Text('Flutter Map Examples'), + DrawerHeader( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/ProjectIcon.png', + height: 48, + ), + const SizedBox(height: 16), + const Text( + 'flutter_map Demo', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const Text( + 'Ā© flutter_map Authors & Contributors', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14), + ), + ], ), ), _buildMenuItem( diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4a3c88516..9234876f7 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -61,3 +61,4 @@ flutter: - assets/map/anholt_osmbright/14/8726/ - assets/map/anholt_osmbright/14/8727/ - assets/map/epsg3413/amsr2.png + - assets/ diff --git a/example/web/favicon.png b/example/web/favicon.png index 8aaa46ac1ae21512746f852a42ba87e4165dfdd1..8603d0a3d2a91580f77171968c7d13e73fd1482a 100644 GIT binary patch literal 2424 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 diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png index b749bfef07473333cf1dd31e9eed89862a5d52aa..f9f76fc57bf39158cfb1127935bd5806e4ebdc4b 100644 GIT binary patch literal 14344 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 ZvfC3nOm%g|V*kAdXsT+dlqeyB{~v((VBr7& literal 5292 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 diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png index 88cfd48dff1169879ba46840804b412fe02fefd6..9d38f6e4a044bcaee3b480141ac56b2403578b74 100644 GIT binary patch literal 78671 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î_kswd@+k!$@o)+ex;WjAmE&+c)iBC&zzP|pkLA{4l7h%>zbH9 z%Q(IQMLd}wO*OfHtJdaz!M5r_M>KW9c=_{O_1Qs7d-<3g+)LQlyXDUxtqkEB3&IZe z7WnvS_r|u8ln0Ud48Bpnx+Z%K9uA+{;k(wPr-9n~B0?3tuHZvjY&0UF? z(DVy%0Iwp1=+Of<7ZZ!61ZSc~|N0$2h;59xS(1Tup>MLye^6yg*vRdCv#_`{ce2@V zW_F`GU`1y@>g|qHghJ=s0A_G*V|mF}L+r#BX}c~PWJcT+xuG0hVMsIM=39mAe(!#> zq1%s9yI>6QyjCRsRCY1caB_`6EdhRBpmzy_fm>_|mqjJ1pdx@M)_He0U>x=C%M=eh z?%89X`g1qG_!*a)vvulfGLGKL1qlTG4C-RgVl7F;kDADAwfFQcZZu-dDW8gb+oY>G zxRE$1b%j{P_@ZeK*U}p$6r#lB*FBK*m{ILcTGCHyzI9-FNwOP0!M4Bq%LD5AH2_tS z2S*`>x`uj&`UWt|lR0oXRFP-P{MFjG-GXID9+yOq_gO6;J2K2)yU+TtdW(Tc#VA)g zQon_!|J4%x=uq@4sd1GO@P6k&2e`tm&Eol0;jkHsB+Ll_4&Sr~0`kc}bR2|*O+C`PZ`lkc~zO7S!6(DT!>{mHQm8lO*YB;wzQ(7;}!WEW*Sl#SX>oh^(t zS#%O{(YalonU|6JX$chWyTqeLkA-;F-#LdIdsYy6oPbpP{67OJF5>zkzgSW1Y(jfT zf32dm0{py)Xc(E3hfF-t0XlvVM;YvHvw9W-=4{9ct1hi`vfFNbGZpQ|H^t~s0^*m2 zIJ7_W;80=&Qfq-WVDXIP^^~XUF^&#HoTp8Y_x9XYfSFUZG`mADz|s-Ac6;!4$Eq>2 z<20;|zfH$^G%NF7{9RacKj)ZBlgkUU*en4KFVRh0V5^jz#j-c{8jROSO;k4L?5Z2Z&MWNGbIKjBF6O9TsfwoW zfU4Vl>deFLivC0<6vuy<9O?x5*+}j-w2Py#$Cy37jxlSgJ zijlmM#pZJt3kubj+6`u@2S`~m>jkmW*7b3$IQbSgkV)=MjW8L}mX2Azg0 zlU*+4_l3$kGCt9zVYBr`r8SZ9RJFd^tCjkTruTTIz6@LvM~l6Rze~)IJlaebK~>Oi zDq~_i;;N&#>t@=Cu^NiKk*q{h8!w{h>s(e=1=;84oliPOoidMLf})+7=tz3WO3IoFx*CA>xe(M@z5> z=g9NCGG|%y{~*Ntk{oSpyk-k@3XR zqze6|3N=On!AO*oR45F(Uxw-V@l zCb5a1R01gBG*JTe`~duH?beVmeb%u05v*`c zg-a5O%|oR+d$ezpIRQI4l&Qv{^f^fPo%{Q?$mRcZ%^pMWY0|N(xC-bP12-XijMaDW zDS8z)O`S!9Fw31fD~y$=Tw*3rb$YRX7FQj?!HiWgpqcy!BJ4|;i*r+(;>D_vT|KU! zU$+}*RisweB~$g|9`?X9gd}ZAZo!!n;33Fj&$D86NbOWZDu3ussMYaThEW`;(IV!{ z7gVF?9y*6`fK$4|VHj{TnX2IGGvVAIDZAu+1ScaYQ*a}|M^60Fz2XMbvI zyR#&V;DYGYwNvX^il>JVk-#1OVYNu$0+`T9R1mX)qynzC=ioay1u-UtF0KZmL)3NP z=Q~89&Rj(+X74b@0Qi|Mv>-Z2dl_M3eT8ovVi{1|J80^^ z3k{S2{_H7AY_J4o)V9w-isQb&J5x6A7rF!ZbD{OW`b}ISP45C*w_`L}kj8+)5It50 zkk^hDq=wope@Q;2yZF~UFfK{rPsx#0w+H>eVxsebx+d*NPwqhoFvZ;8`>;YGQ>F z+ep;dJ#yg^L<$Wi$pOSy`+gnQp5q4|0zbKdS7#ST2{LOeV{g8hcEe|4 zDiyL`Ga3qJp!Ony0byzV83INe{Y}SqClZ_*7L?U>HtQaoaid0;OIXJ3rClrzp?%f? z>;TWbfDuSB=yF0239A5yAEo9SdTV3i8Eg}9K5(xx7~2cjL}A#~_1+Y-fM?xYac)6o zMDy<;uv2wS^zwEX%;V>Gew=VG5!! Wq|d9RD0tA2Vt`H#9lqj>mu%?*1f6SlgoM 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 literal 8252 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 diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png index eb9b4d76e525556d5d89141648c724331630325d..f9f76fc57bf39158cfb1127935bd5806e4ebdc4b 100644 GIT binary patch literal 14344 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 ZvfC3nOm%g|V*kAdXsT+dlqeyB{~v((VBr7& literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png index d69c56691fbdb0b7efa65097c7cc1edac12a6d3e..9d38f6e4a044bcaee3b480141ac56b2403578b74 100644 GIT binary patch literal 78671 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î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 Wq|d9RD0tA2Vt`H#9lqj>mu%?*1f6SlgoM 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 literal 20998 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 MPgg&ebxsLQ025IeI{*Lx diff --git a/example/web/index.html b/example/web/index.html index 6cbe5dff0..74938b05c 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -19,18 +19,19 @@ - + - + - flutter_map Example + flutter_map Demo