diff --git a/example/lib/main.dart b/example/lib/main.dart index c2ad69bd1..1ca993fdd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -25,7 +25,7 @@ import 'package:flutter_map_example/pages/secondary_tap.dart'; import 'package:flutter_map_example/pages/sliding_map.dart'; import 'package:flutter_map_example/pages/stateful_markers.dart'; import 'package:flutter_map_example/pages/tile_builder_example.dart'; -import 'package:flutter_map_example/pages/tile_loading_error_handle.dart'; +import 'package:flutter_map_example/pages/tile_error_handling.dart'; import 'package:flutter_map_example/pages/wms_tile_layer.dart'; import 'package:url_strategy/url_strategy.dart'; @@ -62,8 +62,7 @@ class MyApp extends StatelessWidget { SlidingMapPage.route: (_) => const SlidingMapPage(), WMSLayerPage.route: (context) => const WMSLayerPage(), CustomCrsPage.route: (context) => const CustomCrsPage(), - TileLoadingErrorHandle.route: (context) => - const TileLoadingErrorHandle(), + TileErrorHandling.route: (context) => const TileErrorHandling(), TileBuilderPage.route: (context) => const TileBuilderPage(), InteractiveTestPage.route: (context) => const InteractiveTestPage(), ManyMarkersPage.route: (context) => const ManyMarkersPage(), diff --git a/example/lib/pages/tile_loading_error_handle.dart b/example/lib/pages/tile_error_handling.dart similarity index 70% rename from example/lib/pages/tile_loading_error_handle.dart rename to example/lib/pages/tile_error_handling.dart index 577b12704..e6b18ca50 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_error_handling.dart @@ -6,25 +6,60 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_example/widgets/drawer.dart'; import 'package:latlong2/latlong.dart'; -class TileLoadingErrorHandle extends StatefulWidget { - static const String route = '/tile_loading_error_handle'; +class TileErrorHandling extends StatefulWidget { + static const String route = '/tile_error_handling'; - const TileLoadingErrorHandle({Key? key}) : super(key: key); + const TileErrorHandling({Key? key}) : super(key: key); @override - _TileLoadingErrorHandleState createState() => _TileLoadingErrorHandleState(); + _TileErrorHandlingState createState() => _TileErrorHandlingState(); } -class _TileLoadingErrorHandleState extends State { +class _TileErrorHandlingState extends State { static const _showSnackBarDuration = Duration(seconds: 1); + + late final TileLayerController _tileLayerController; + bool _simulateTileLoadErrors = false; DateTime? _lastShowedTileLoadError; + @override + void initState() { + super.initState(); + _tileLayerController = TileLayerController(); + } + + @override + void dispose() { + _tileLayerController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Tile Loading Error Handle')), - drawer: buildDrawer(context, TileLoadingErrorHandle.route), + appBar: AppBar( + title: const Text('Tile Error Handling'), + actions: [ + IconButton( + icon: const Icon(Icons.info), + onPressed: () { + showDialog( + context: context, + builder: (context) => const AlertDialog( + title: Text('Tile Error Handling'), + content: Text( + 'To trigger tile loading errors enable tile loading error ' + 'simulation or disable internet access and try to move the ' + 'map.', + ), + ), + ); + }, + ) + ], + ), + drawer: buildDrawer(context, TileErrorHandling.route), body: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -36,11 +71,11 @@ class _TileLoadingErrorHandleState extends State { _simulateTileLoadErrors = newValue; }), ), - const Padding( - padding: EdgeInsets.only(top: 8, bottom: 8), - child: Text( - 'Enable tile load error simulation or disable internet and try to move or zoom map.'), + ElevatedButton( + onPressed: () => _tileLayerController.reloadErrorTiles(), + child: const Text('Reload error tiles'), ), + const SizedBox(height: 12), Flexible( child: Builder(builder: (BuildContext context) { return FlutterMap( @@ -50,6 +85,7 @@ class _TileLoadingErrorHandleState extends State { ), children: [ TileLayer( + controller: _tileLayerController, urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index 63527f99d..159def76d 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:flutter_map_example/pages/animated_map_controller.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; @@ -26,7 +25,7 @@ import 'package:flutter_map_example/pages/secondary_tap.dart'; import 'package:flutter_map_example/pages/sliding_map.dart'; import 'package:flutter_map_example/pages/stateful_markers.dart'; import 'package:flutter_map_example/pages/tile_builder_example.dart'; -import 'package:flutter_map_example/pages/tile_loading_error_handle.dart'; +import 'package:flutter_map_example/pages/tile_error_handling.dart'; import 'package:flutter_map_example/pages/wms_tile_layer.dart'; Widget _buildMenuItem( @@ -228,8 +227,8 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { const Divider(), _buildMenuItem( context, - const Text('Custom Tile Error Handling'), - TileLoadingErrorHandle.route, + const Text('Tile Error Handling'), + TileErrorHandling.route, currentRoute, ), _buildMenuItem( diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index a37757612..8976af599 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -14,6 +14,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/controller/tile_layer_controller.dart' + hide TileLayerControllerImpl; +export 'package:flutter_map/src/layer/tile_layer/placeholder_grid_tile.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'; diff --git a/lib/src/layer/tile_layer/controller/tile_layer_controller.dart b/lib/src/layer/tile_layer/controller/tile_layer_controller.dart new file mode 100644 index 000000000..8924e351f --- /dev/null +++ b/lib/src/layer/tile_layer/controller/tile_layer_controller.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +sealed class TileLayerController { + factory TileLayerController() => TileLayerControllerImpl(); + + /// Trigger reloading of tiles which failed to load. + void reloadErrorTiles(); + + /// Dispose of this controller, should be called when this TileLayerController + /// is no longer used. + void dispose(); +} + +class TileLayerControllerImpl implements TileLayerController { + final StreamController _streamController; + + TileLayerControllerImpl() : _streamController = StreamController.broadcast(); + + Stream get stream => _streamController.stream; + + @override + void reloadErrorTiles() { + _streamController.add(null); + } + + @override + void dispose() { + _streamController.close(); + } +} diff --git a/lib/src/layer/tile_layer/placeholder_grid_tile.dart b/lib/src/layer/tile_layer/placeholder_grid_tile.dart new file mode 100644 index 000000000..b88c1f3cf --- /dev/null +++ b/lib/src/layer/tile_layer/placeholder_grid_tile.dart @@ -0,0 +1,88 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +class PlaceholderGridTile extends StatelessWidget { + final int cellCount; + final Color lineColor; + final Color? backgroundColor; + + /// Creates a placeholder tile which is a grid with [cellCount] cells on both + /// axis. The cells are have a background of [backgroundColor] (default + /// transparent) and are divided by lines with the color [lineColor]. + const PlaceholderGridTile({ + super.key, + this.cellCount = 8, + this.lineColor = Colors.white, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _PlaceholderGridTilePainter( + lineColor: lineColor, + cellCount: cellCount, + backgroundColor: backgroundColor, + ), + ); + } +} + +class _PlaceholderGridTilePainter extends CustomPainter { + final int cellCount; + final Color lineColor; + final Color? backgroundColor; + + _PlaceholderGridTilePainter({ + required this.cellCount, + required this.lineColor, + required this.backgroundColor, + }); + + @override + void paint(ui.Canvas canvas, ui.Size size) { + final paint = Paint(); + if (backgroundColor != null && backgroundColor != Colors.transparent) { + paint + ..color = backgroundColor! + ..style = PaintingStyle.fill; + canvas.drawRect(Offset.zero & size, paint); + } + + paint + ..color = lineColor + ..style = PaintingStyle.stroke; + + final tileSize = size.width; + final cellOffsetSize = tileSize / cellCount; + + // Draw lines + for (int i = 0; i <= cellCount; i++) { + if (i % cellCount == 0) { + paint.strokeWidth = 1; + } else { + paint.strokeWidth = 0.5; + } + final cellOffset = cellOffsetSize * i; + // Horizontal line. + canvas.drawLine( + Offset(0, cellOffset), + Offset(tileSize, cellOffset), + paint, + ); + // Vertical line. + canvas.drawLine( + Offset(cellOffset, 0), + Offset(cellOffset, tileSize), + paint, + ); + } + } + + @override + bool shouldRepaint(_PlaceholderGridTilePainter oldDelegate) => + lineColor != oldDelegate.lineColor || + cellCount != oldDelegate.cellCount || + backgroundColor != oldDelegate.backgroundColor; +} diff --git a/lib/src/layer/tile_layer/positioned_tile.dart b/lib/src/layer/tile_layer/positioned_tile.dart new file mode 100644 index 000000000..42d31b708 --- /dev/null +++ b/lib/src/layer/tile_layer/positioned_tile.dart @@ -0,0 +1,30 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +class PositionedTile extends StatelessWidget { + final TileCoordinates tileCoordinates; + final double scaledTileSize; + final Point currentPixelOrigin; + final Widget child; + + const PositionedTile({ + super.key, + required this.tileCoordinates, + required this.scaledTileSize, + required this.currentPixelOrigin, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: tileCoordinates.x * scaledTileSize - currentPixelOrigin.x, + top: tileCoordinates.y * scaledTileSize - currentPixelOrigin.y, + width: scaledTileSize, + height: scaledTileSize, + child: child, + ); + } +} diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 124125cb3..e40c6d283 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; @@ -8,13 +6,9 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; class Tile extends StatefulWidget { final TileImage tileImage; final TileBuilder? tileBuilder; - final double scaledTileSize; - final Point currentPixelOrigin; const Tile({ super.key, - required this.scaledTileSize, - required this.currentPixelOrigin, required this.tileImage, required this.tileBuilder, }); @@ -42,27 +36,12 @@ class _TileState extends State { @override Widget build(BuildContext context) { - return Positioned( - left: widget.tileImage.coordinates.x * widget.scaledTileSize - - widget.currentPixelOrigin.x, - top: widget.tileImage.coordinates.y * widget.scaledTileSize - - widget.currentPixelOrigin.y, - width: widget.scaledTileSize, - height: widget.scaledTileSize, - child: widget.tileBuilder?.call(context, _tileImage, widget.tileImage) ?? - _tileImage, - ); + return widget.tileBuilder?.call(context, _tileImage, widget.tileImage) ?? + _tileImage; } Widget get _tileImage { - if (widget.tileImage.loadError && widget.tileImage.errorImage != null) { - return Image( - image: widget.tileImage.errorImage!, - opacity: widget.tileImage.opacity == 1 - ? null - : AlwaysStoppedAnimation(widget.tileImage.opacity), - ); - } else if (widget.tileImage.animation == null) { + if (widget.tileImage.animation == null) { return RawImage( image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, diff --git a/lib/src/layer/tile_layer/tile_display.dart b/lib/src/layer/tile_layer/tile_display.dart index 7acd29f8f..386bc5aa2 100644 --- a/lib/src/layer/tile_layer/tile_display.dart +++ b/lib/src/layer/tile_layer/tile_display.dart @@ -45,14 +45,14 @@ sealed class TileDisplay { }) = FadeInTileDisplay._; /// Output a value of type [T] dependent on [this] and its type - T? when({ - T? Function(InstantaneousTileDisplay instantaneous)? instantaneous, - T? Function(FadeInTileDisplay fadeIn)? fadeIn, + T when({ + required T Function(InstantaneousTileDisplay instantaneous) instantaneous, + required T Function(FadeInTileDisplay fadeIn) fadeIn, }) { final display = this; return switch (display) { - InstantaneousTileDisplay() => instantaneous?.call(display), - FadeInTileDisplay() => fadeIn?.call(display), + InstantaneousTileDisplay() => instantaneous(display), + FadeInTileDisplay() => fadeIn(display), }; } } diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 9ae41b72a..ee0595488 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -8,8 +8,9 @@ class TileImage extends ChangeNotifier { // Controls fade-in opacity. AnimationController? _animationController; - // Whether the tile is displayable. See [readyToDisplay]. - bool _readyToDisplay = false; + // Whether the tile has both loaded and finished transitioning. See + // [transitionComplete]. + bool _transitionComplete = false; /// Used by animationController. Still required if animation is disabled in /// case the tile display is changed at a later point. @@ -19,7 +20,7 @@ class TileImage extends ChangeNotifier { /// indicate the position of the tile at that zoom level. final TileCoordinates coordinates; - /// Callback fired when loading finishes with or withut an error. This + /// Callback fired when loading finishes with or without an error. This /// callback is not triggered after this TileImage is disposed. final void Function(TileCoordinates coordinates) onLoadComplete; @@ -32,13 +33,10 @@ class TileImage extends ChangeNotifier { /// Options for how the tile image is displayed. TileDisplay _tileDisplay; - /// An optional image to show when a loading error occurs. - final ImageProvider? errorImage; - ImageProvider imageProvider; /// True if an error occurred during loading. - bool loadError = false; + bool _loadError = false; /// When loading started. DateTime? loadStarted; @@ -57,7 +55,6 @@ class TileImage extends ChangeNotifier { required this.onLoadComplete, required this.onLoadError, required TileDisplay tileDisplay, - required this.errorImage, }) : _tileDisplay = tileDisplay, _animationController = tileDisplay.when( instantaneous: (_) => null, @@ -67,9 +64,11 @@ class TileImage extends ChangeNotifier { ), ); + bool get loadError => _loadError; + double get opacity => _tileDisplay.when( instantaneous: (instantaneous) => - _readyToDisplay ? instantaneous.opacity : 0.0, + _transitionComplete ? instantaneous.opacity : 0.0, fadeIn: (fadeIn) => _animationController!.value, )!; @@ -77,14 +76,14 @@ class TileImage extends ChangeNotifier { String get coordinatesKey => coordinates.key; - /// Whether the tile is displayable. This means that either: - /// * Loading errored but an error image is configured. - /// * Loading succeeded and the fade animation has finished. - /// * Loading succeeded and there is no fade animation. + /// Whether the tile has loaded and finished fading in. This is true when + /// loading succeeded and: + /// * The fade animation has finished. + /// * There is no fade animation. /// /// Note that [opacity] can be less than 1 when this is true if instantaneous /// tile display is used with a maximum opacity less than 1. - bool get readyToDisplay => _readyToDisplay; + bool get transitionComplete => _transitionComplete; // Used to sort TileImages by their distance from the current zoom. double zIndex(double maxZoom, int currentZoom) => @@ -99,12 +98,15 @@ class TileImage extends ChangeNotifier { oldTileDisplay.when( instantaneous: (instantaneous) { newTileDisplay.when( + instantaneous: (instantaneous) { + // There was and is no animation. + }, fadeIn: (fadeIn) { // Became animated. _animationController = AnimationController( duration: fadeIn.duration, vsync: vsync, - value: _readyToDisplay ? 1.0 : 0.0, + value: _transitionComplete ? 1.0 : 0.0, ); }, ); @@ -148,46 +150,34 @@ class TileImage extends ChangeNotifier { } void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { - loadError = false; + _loadError = false; this.imageInfo = imageInfo; if (!_disposed) { - _display(); + _initiateTransition(); onLoadComplete(coordinates); } } void _onImageLoadError(Object exception, StackTrace? stackTrace) { - loadError = true; + _loadError = true; if (!_disposed) { - if (errorImage != null) _display(); onLoadError(this, exception, stackTrace); onLoadComplete(coordinates); } } - // Initiates fading in and marks this TileImage as readyToDisplay when fading - // finishes. If fading is disabled or a loading error occurred this TileImage - // becomes readyToDisplay immediately. - void _display() { + // Initiates fading in and sets [transitionComplete] to true when fading + // finishes. If fading is disabled [transitionComplete] is set to true + // immediately. + void _initiateTransition() { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); - if (loadError) { - assert( - errorImage != null, - 'A TileImage should not be displayed if loading errors and there is no ' - 'errorImage to show.', - ); - _readyToDisplay = true; - if (!_disposed) notifyListeners(); - return; - } - _tileDisplay.when( instantaneous: (_) { - _readyToDisplay = true; + _transitionComplete = true; if (!_disposed) notifyListeners(); }, fadeIn: (fadeIn) { @@ -195,12 +185,12 @@ class TileImage extends ChangeNotifier { previouslyLoaded ? fadeIn.reloadStartOpacity : fadeIn.startOpacity; if (fadeStartOpacity == 1.0) { - _readyToDisplay = true; + _transitionComplete = true; if (!_disposed) notifyListeners(); } else { _animationController!.reset(); _animationController!.forward(from: fadeStartOpacity).then((_) { - _readyToDisplay = true; + _transitionComplete = true; if (!_disposed) notifyListeners(); }); } @@ -230,7 +220,7 @@ class TileImage extends ChangeNotifier { } } - _readyToDisplay = false; + _transitionComplete = false; _animationController?.stop(canceled: false); _animationController?.value = 0.0; notifyListeners(); @@ -250,6 +240,6 @@ class TileImage extends ChangeNotifier { @override String toString() { - return 'TileImage($coordinates, readyToDisplay: $_readyToDisplay)'; + return 'TileImage($coordinates, transitionComplete: $_transitionComplete)'; } } diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index c3c7c2a7a..e6b32ef86 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -26,14 +26,11 @@ class TileImageManager { /// 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 result; - } + List inRenderOrder(double maxZoom, int currentZoom) => + _tiles.values.toList() + ..sort((a, b) => a + .zIndex(maxZoom, currentZoom) + .compareTo(b.zIndex(maxZoom, currentZoom))); // Creates missing tiles in the given range. Does not initiate loading of the // tiles. @@ -114,8 +111,9 @@ class TileImageManager { void reloadImages( TileLayer layer, - TileBounds tileBounds, - ) { + TileBounds tileBounds, { + bool Function(TileImage tileImage)? test, + }) { // If a TileImage's imageInfo is already available when load() is called it // will call its onLoadComplete callback synchronously which can trigger // pruning. Since pruning may cause removals from _tiles we must not @@ -125,6 +123,8 @@ class TileImageManager { final tilesToReload = List.from(_tiles.values); for (final tile in tilesToReload) { + if (test?.call(tile) == false) continue; + tile.imageProvider = layer.tileProvider.getImage( tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates), layer, diff --git a/lib/src/layer/tile_layer/tile_image_view.dart b/lib/src/layer/tile_layer/tile_image_view.dart index 7aca827e2..5de432385 100644 --- a/lib/src/layer/tile_layer/tile_image_view.dart +++ b/lib/src/layer/tile_layer/tile_image_view.dart @@ -33,7 +33,7 @@ class TileImageView { final retain = Set.from(tilesInKeepRange); for (final tile in tilesInKeepRange) { - if (!tile.readyToDisplay) { + if (!tile.transitionComplete) { final coords = tile.coordinates; if (!_retainAncestor( retain, coords.x, coords.y, coords.z, coords.z - 5)) { @@ -64,7 +64,7 @@ class TileImageView { final tile = _tileImages[coords2.key]; if (tile != null) { - if (tile.readyToDisplay) { + if (tile.transitionComplete) { retain.add(tile); return true; } else if (tile.loadFinishedAt != null) { @@ -94,7 +94,7 @@ class TileImageView { final tile = _tileImages[coords.key]; if (tile != null) { - if (tile.readyToDisplay || tile.loadFinishedAt != null) { + if (tile.transitionComplete || tile.loadFinishedAt != null) { retain.add(tile); } } diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 799048d58..e0af079bb 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -5,6 +5,8 @@ import 'dart:math' show Point; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map/src/layer/tile_layer/controller/tile_layer_controller.dart'; +import 'package:flutter_map/src/layer/tile_layer/positioned_tile.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'; @@ -25,6 +27,9 @@ part 'tile_layer_options.dart'; /// avoid issues. @immutable class TileLayer extends StatefulWidget { + /// An optional controller for controlling this layer. + final TileLayerController? controller; + /// Defines the structure to create the URLs for the tiles. `{s}` means one of /// the available subdomains (can be omitted) `{z}` zoom level `{x}` and `{y}` /// — tile coordinates `{r}` can be used to add "@2x" to the URL to @@ -153,8 +158,15 @@ class TileLayer extends StatefulWidget { /// or one. final int panBuffer; - /// Tile image to show in place of the tile that failed to load. - final ImageProvider? errorImage; + /// The widget which is shown in place of a tile that has not yet loaded or + /// fails to load. Note that if tiles from a higher/lower zoom fill the space + /// the missing tile would fill then they will be visible instead of a + /// placeholder. + /// + /// The default placeholder is a grid image. See [PlaceholderGridTile] for a + /// grid with custom styling or provide your own widget. To disable + /// placeholders set to null. + final Widget? tilePlaceholder; /// Static information that should replace placeholders in the [urlTemplate]. /// Applying API keys is a good example on how to use this parameter. @@ -225,6 +237,7 @@ class TileLayer extends StatefulWidget { TileLayer({ super.key, + this.controller, this.urlTemplate, this.fallbackUrl, double tileSize = 256.0, @@ -239,8 +252,16 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor, - this.errorImage, - final TileProvider? tileProvider, + Widget? tilePlaceholder = const PlaceholderGridTile(), + @Deprecated( + 'Prefer `tilePlaceholder` instead. ' + 'This option is now replaced by `tilePlaceholder` and the behaviour has ' + 'changed to show a tilePlaceholder when no tiles from any zoom fill the ' + 'area which the missing tile would cover. ' + 'This option is deprecated since v6.', + ) + ImageProvider? errorImage, + TileProvider? tileProvider, this.tms = false, this.wmsOptions, this.tileDisplay = const TileDisplay.fadeIn(), @@ -254,10 +275,12 @@ class TileLayer extends StatefulWidget { TileUpdateTransformer? tileUpdateTransformer, String userAgentPackageName = 'unknown', }) : assert( - tileDisplay.when( - instantaneous: (_) => true, - fadeIn: (fadeIn) => fadeIn.duration > Duration.zero)!, - 'The tile fade in duration needs to be bigger than zero'), + tileDisplay.when( + instantaneous: (_) => true, + fadeIn: (fadeIn) => fadeIn.duration > Duration.zero, + )!, + 'The tile fade in duration needs to be bigger than zero', + ), maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -286,7 +309,13 @@ class TileLayer extends StatefulWidget { 'User-Agent': 'flutter_map ($userAgentPackageName)', }), tileUpdateTransformer = - tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents; + tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents, + tilePlaceholder = errorImage != null + ? Image( + image: errorImage, + fit: BoxFit.fill, + ) + : tilePlaceholder; @override State createState() => _TileLayerState(); @@ -306,10 +335,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { // 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 - // map movements. + StreamSubscription? _tileLayerControllerSubscription; StreamSubscription? _tileUpdateSubscription; StreamSubscription? _resetSub; @@ -319,6 +345,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { void initState() { super.initState(); + _listenToTileLayerController(); + if (widget.reset != null) { _resetSub = widget.reset?.listen( (_) => _tileImageManager.removeAll( @@ -383,6 +411,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { // There is no caching in TileRangeCalculator so we can just replace it. _tileRangeCalculator = TileRangeCalculator(tileSize: widget.tileSize); + if (oldWidget.controller != widget.controller) { + _listenToTileLayerController(); + } + if (_tileBounds.shouldReplace( _tileBounds.crs, widget.tileSize, widget.tileBounds)) { _tileBounds = TileBounds( @@ -434,8 +466,21 @@ class _TileLayerState extends State with TickerProviderStateMixin { } } + void _listenToTileLayerController() { + _tileLayerControllerSubscription?.cancel(); + _tileLayerControllerSubscription = + (widget.controller as TileLayerControllerImpl?)?.stream.listen((_) { + _tileImageManager.reloadImages( + widget, + _tileBounds, + test: (tileImage) => tileImage.loadError, + ); + }); + } + @override void dispose() { + _tileLayerControllerSubscription?.cancel(); _tileUpdateSubscription?.cancel(); _tileImageManager.removeAll(widget.evictErrorTileStrategy); _resetSub?.cancel(); @@ -483,31 +528,72 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom); + final tileImagesInRenderOrder = + _tileImageManager.inRenderOrder(widget.maxZoom, tileZoom); + return _addBackgroundColor( Stack( children: [ - ..._tileImageManager - .inRenderOrder(widget.maxZoom, tileZoom) - .map((tileImage) { - return Tile( + if (widget.tilePlaceholder != null) + ..._placeholderCoordinates( + tileImagesInRenderOrder.takeWhile( + (tileImage) => tileImage.coordinates.z == tileZoom), + visibleTileRange, + ).map( + (tileCoordinates) => PositionedTile( + key: ValueKey('placeholder-${tileCoordinates.key}'), + tileCoordinates: tileCoordinates, + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tileZoom, + ), + currentPixelOrigin: currentPixelOrigin, + child: widget.tilePlaceholder!, + ), + ), + ...tileImagesInRenderOrder.map( + (tileImage) => PositionedTile( // Must be an ObjectKey, not a ValueKey using the coordinates, in // case we remove and replace the TileImage with a different one. key: ObjectKey(tileImage), + tileCoordinates: tileImage.coordinates, scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, tileImage.coordinates.z, ), currentPixelOrigin: currentPixelOrigin, - tileImage: tileImage, - tileBuilder: widget.tileBuilder, - ); - }), + child: Tile( + tileImage: tileImage, + tileBuilder: widget.tileBuilder, + ), + ), + ), ], ), ); } - /// This can be removed once the deprecated backgroundColor option is removed. + /// Returns the visible coordinates from the current zoom for which there is + /// no TileImage which has finished loading and transitioning. It is possible + /// a returned placeholder coordinate will be for a tile which is obscured by + /// a tile from a lower zoom or a collection of tiles from higher zooms, this + /// simple algorithm is a tradeoff between minimising the number of + /// unnecessary placeholders and keeping the calculation fast. + Iterable _placeholderCoordinates( + Iterable tileImagesAtCurrentZoom, + DiscreteTileRange visibleTileRange, + ) { + final obscuredCoordinatesAtCurrentZoom = tileImagesAtCurrentZoom + .where((tileImage) => tileImage.transitionComplete) + .map((tileImage) => tileImage.coordinates) + .toSet(); + + return visibleTileRange.coordinates + .toSet() + .difference(obscuredCoordinatesAtCurrentZoom); + } + + // This can be removed once the deprecated backgroundColor option is removed. Widget _addBackgroundColor(Widget child) { // ignore: deprecated_member_use_from_same_package final color = widget.backgroundColor; @@ -532,7 +618,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (pruneAfterLoad) _pruneIfAllTilesLoaded(coordinates); }, tileDisplay: widget.tileDisplay, - errorImage: widget.errorImage, ); } diff --git a/lib/src/layer/tile_layer/tile_scale_calculator.dart b/lib/src/layer/tile_layer/tile_scale_calculator.dart index 791d6d08b..e4f924f74 100644 --- a/lib/src/layer/tile_layer/tile_scale_calculator.dart +++ b/lib/src/layer/tile_layer/tile_scale_calculator.dart @@ -26,7 +26,7 @@ class TileScaleCalculator { _cachedCurrentZoom = currentZoom; } - /// Returns a scale value to transform a Tile coordainte to a Tile position. + /// Returns a scale value to transform a Tile coordinate to a Tile position. double scaledTileSize(double currentZoom, int tileZoom) { assert( _cachedCurrentZoom == currentZoom, diff --git a/lib/src/map/options.dart b/lib/src/map/options.dart index af5b48336..77cff5635 100644 --- a/lib/src/map/options.dart +++ b/lib/src/map/options.dart @@ -304,6 +304,7 @@ class MapOptions { boundsOptions == other.boundsOptions && minZoom == other.minZoom && maxZoom == other.maxZoom && + backgroundColor == other.backgroundColor && onTap == other.onTap && onSecondaryTap == other.onSecondaryTap && onLongPress == other.onLongPress && diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index 8a5498245..83473edda 100644 --- a/test/layer/tile_layer/tile_image_view_test.dart +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -59,7 +59,7 @@ void main() { test('ancestor tile is not stale if a tile has not loaded yet', () { final tileImages = tileImagesMappingFrom([ MockTileImage(0, 0, 0), - MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + MockTileImage(0, 0, 1, loadFinished: false, transitionComplete: false), ]); final removalState = TileImageView( tileImages: tileImages, @@ -75,8 +75,8 @@ void main() { test('descendant tile is not stale if there is no loaded tile obscuring it', () { final tileImages = tileImagesMappingFrom([ - MockTileImage(0, 0, 0, loadFinished: false, readyToDisplay: false), - MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + MockTileImage(0, 0, 0, loadFinished: false, transitionComplete: false), + MockTileImage(0, 0, 1, loadFinished: false, transitionComplete: false), MockTileImage(0, 0, 2), ]); final removalState = TileImageView( @@ -171,20 +171,22 @@ void main() { class MockTileImage extends TileImage { @override - final bool readyToDisplay; + final bool transitionComplete; + + @override + final bool loadError; MockTileImage( int x, int y, int zoom, { - this.readyToDisplay = true, + this.transitionComplete = true, bool loadFinished = true, - bool loadError = false, + this.loadError = false, void Function(TileCoordinates coordinates)? onLoadComplete, void Function(TileImage tile, Object error, StackTrace? stackTrace)? onLoadError, TileDisplay? tileDisplay, - super.errorImage, }) : super( coordinates: TileCoordinates(x, y, zoom), vsync: const MockTickerProvider(), @@ -194,7 +196,6 @@ class MockTileImage extends TileImage { tileDisplay: const TileDisplay.instantaneous(), ) { loadFinishedAt = loadFinished ? DateTime.now() : null; - this.loadError = loadError; } }