From 07ba5bdbd1611388b0dff44bbd448a8dfc33261f Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Fri, 14 Jul 2023 16:17:10 +0200 Subject: [PATCH 1/5] Replace TileLayer's error image option with a placeholder image option When a tile fails to load and there are no existing loaded tiles which overlay with it from another zoom level we are left with a blank portion on the map. When there are no loaded tiles visible, for example because of a network problem, there is no visual feedback when zooming/panning/rotating because the map is just a single solid color. This prompted me to add a placeholder image. We already have an error image implementation but the error image will not show up until loading fails which, in the case of a poor network connection, can take quite a while. The placeholder implementation gives visual feedback both whilst loading and when loading fails. Note that the current implementation just fills the viewport with the placeholder tile behind all other tiles. It would be more efficient to avoid adding placeholder tiles which are completely obscured by other visual tiles but my attempts to do this were unsuccessful due to the complex nature of tile pruning. --- example/lib/main.dart | 5 +- ...r_handle.dart => tile_error_handling.dart} | 60 +++++++++--- example/lib/widgets/drawer.dart | 7 +- lib/flutter_map.dart | 3 + .../controller/tile_layer_controller.dart | 30 ++++++ lib/src/layer/tile_layer/tile.dart | 9 +- lib/src/layer/tile_layer/tile_display.dart | 10 +- lib/src/layer/tile_layer/tile_image.dart | 29 ++---- .../layer/tile_layer/tile_image_manager.dart | 7 +- lib/src/layer/tile_layer/tile_layer.dart | 98 ++++++++++++++----- .../layer/tile_layer/tile_placeholder.dart | 35 +++++++ .../tile_layer/tile_placeholder_image.dart | 95 ++++++++++++++++++ .../tile_layer/tile_scale_calculator.dart | 2 +- .../tile_layer/tile_image_view_test.dart | 7 +- 14 files changed, 313 insertions(+), 84 deletions(-) rename example/lib/pages/{tile_loading_error_handle.dart => tile_error_handling.dart} (68%) create mode 100644 lib/src/layer/tile_layer/controller/tile_layer_controller.dart create mode 100644 lib/src/layer/tile_layer/tile_placeholder.dart create mode 100644 lib/src/layer/tile_layer/tile_placeholder_image.dart 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 68% rename from example/lib/pages/tile_loading_error_handle.dart rename to example/lib/pages/tile_error_handling.dart index 577b12704..b1b014c34 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_error_handling.dart @@ -6,25 +6,61 @@ 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); + static final _placeholderImage = TilePlaceholderImage.generate(); + + 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 +72,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 +86,8 @@ class _TileLoadingErrorHandleState extends State { ), children: [ TileLayer( + controller: _tileLayerController, + placeholderImage: _placeholderImage, 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..10121dbca 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -14,11 +14,14 @@ 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/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_placeholder_image.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_providers/tile_provider_stub.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/tile.dart b/lib/src/layer/tile_layer/tile.dart index 124125cb3..78a5dec0d 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -55,14 +55,7 @@ class _TileState extends State { } 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..bfd83a414 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -19,7 +19,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 +32,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 +54,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,6 +63,8 @@ class TileImage extends ChangeNotifier { ), ); + bool get loadError => _loadError; + double get opacity => _tileDisplay.when( instantaneous: (instantaneous) => _readyToDisplay ? instantaneous.opacity : 0.0, @@ -99,6 +97,9 @@ class TileImage extends ChangeNotifier { oldTileDisplay.when( instantaneous: (instantaneous) { newTileDisplay.when( + instantaneous: (instantaneous) { + // There was and is no animation. + }, fadeIn: (fadeIn) { // Became animated. _animationController = AnimationController( @@ -148,7 +149,7 @@ class TileImage extends ChangeNotifier { } void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { - loadError = false; + _loadError = false; this.imageInfo = imageInfo; if (!_disposed) { @@ -158,10 +159,9 @@ class TileImage extends ChangeNotifier { } void _onImageLoadError(Object exception, StackTrace? stackTrace) { - loadError = true; + _loadError = true; if (!_disposed) { - if (errorImage != null) _display(); onLoadError(this, exception, stackTrace); onLoadComplete(coordinates); } @@ -174,17 +174,6 @@ class TileImage extends ChangeNotifier { 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; diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index c3c7c2a7a..313d1166c 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -114,8 +114,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 +126,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_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 799048d58..7a312043d 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -5,10 +5,12 @@ 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/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_image_manager.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_placeholder.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'; @@ -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,9 @@ 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; + /// Tile image to show in place of a tile that has not yet loaded or fails to + /// load. + final ImageProvider? placeholderImage; /// Static information that should replace placeholders in the [urlTemplate]. /// Applying API keys is a good example on how to use this parameter. @@ -225,6 +231,7 @@ class TileLayer extends StatefulWidget { TileLayer({ super.key, + this.controller, this.urlTemplate, this.fallbackUrl, double tileSize = 256.0, @@ -239,7 +246,15 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor, - this.errorImage, + @Deprecated( + 'Prefer `placeholderImage` instead. ' + 'This option is now replaced by `placeholderImage` and the behaviour has ' + 'changed to show the placeholder both when tile loading errors and when ' + 'a tile is yet to load. ' + 'This option is deprecated since v6.', + ) + ImageProvider? errorImage, + ImageProvider? placeholderImage, final TileProvider? tileProvider, this.tms = false, this.wmsOptions, @@ -254,10 +269,13 @@ 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', + ), + placeholderImage = errorImage ?? placeholderImage, maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -306,10 +324,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 +334,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { void initState() { super.initState(); + _listenToTileLayerController(); + if (widget.reset != null) { _resetSub = widget.reset?.listen( (_) => _tileImageManager.removeAll( @@ -383,6 +400,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 +455,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(); @@ -486,22 +520,33 @@ class _TileLayerState extends State with TickerProviderStateMixin { return _addBackgroundColor( Stack( children: [ - ..._tileImageManager - .inRenderOrder(widget.maxZoom, tileZoom) - .map((tileImage) { - return Tile( - // 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), - scaledTileSize: _tileScaleCalculator.scaledTileSize( - map.zoom, - tileImage.coordinates.z, + if (widget.placeholderImage != null) + ...visibleTileRange.coordinates.map( + (tileCoordinates) => TilePlaceholder( + key: ValueKey('placeholder-${tileCoordinates.key}'), + tileCoordinates: tileCoordinates, + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tileZoom, + ), + currentPixelOrigin: currentPixelOrigin, + placeholderImage: widget.placeholderImage!, + ), + ), + ..._tileImageManager.inRenderOrder(widget.maxZoom, tileZoom).map( + (tileImage) => Tile( + // 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), + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tileImage.coordinates.z, + ), + currentPixelOrigin: currentPixelOrigin, + tileImage: tileImage, + tileBuilder: widget.tileBuilder, + ), ), - currentPixelOrigin: currentPixelOrigin, - tileImage: tileImage, - tileBuilder: widget.tileBuilder, - ); - }), ], ), ); @@ -532,7 +577,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_placeholder.dart b/lib/src/layer/tile_layer/tile_placeholder.dart new file mode 100644 index 000000000..d4eb412d7 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_placeholder.dart @@ -0,0 +1,35 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +class TilePlaceholder extends StatelessWidget { + final TileCoordinates tileCoordinates; + final double scaledTileSize; + final Point currentPixelOrigin; + final ImageProvider placeholderImage; + + const TilePlaceholder({ + super.key, + required this.tileCoordinates, + required this.scaledTileSize, + required this.currentPixelOrigin, + required this.placeholderImage, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: tileCoordinates.x * scaledTileSize - currentPixelOrigin.x, + top: tileCoordinates.y * scaledTileSize - currentPixelOrigin.y, + width: scaledTileSize, + height: scaledTileSize, + child: Image( + width: scaledTileSize, + height: scaledTileSize, + image: placeholderImage, + fit: BoxFit.fill, + ), + ); + } +} diff --git a/lib/src/layer/tile_layer/tile_placeholder_image.dart b/lib/src/layer/tile_layer/tile_placeholder_image.dart new file mode 100644 index 000000000..4d7e7c80f --- /dev/null +++ b/lib/src/layer/tile_layer/tile_placeholder_image.dart @@ -0,0 +1,95 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class TilePlaceholderImage { + const TilePlaceholderImage._(); + + /// Creates an [ImageProvider] which resolves to an image which is a grid of + /// [cellCount] transparent cells on both axis. The cells are transparent, + /// divided lines with the color [lineColor]. + /// + /// The [size] determines the width and height of the generated image and it + /// should match the tile size of the [TileLayer] in which this is used. + static ImageProvider generate({ + int size = 256, + Color lineColor = Colors.white, + int cellCount = 8, + }) { + final sizeDouble = size.toDouble(); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas( + recorder, + Rect.fromPoints( + Offset.zero, + Offset(sizeDouble, sizeDouble), + ), + ); + + final paint = Paint() + ..color = lineColor + ..style = PaintingStyle.stroke; + + final cellOffsetSize = sizeDouble / 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(sizeDouble, cellOffset), + paint, + ); + // Vertical lines. + canvas.drawLine( + Offset(cellOffset, 0), + Offset(cellOffset, sizeDouble), + paint, + ); + } + + final picture = recorder.endRecording(); + final img = picture.toImageSync(size, size); + + return _RawImageProvider(img); + } +} + +class _RawImageProvider extends ImageProvider<_RawImageProvider> { + final ui.Image _image; + + _RawImageProvider(this._image); + + @override + ImageStreamCompleter load( + _RawImageProvider key, + Future Function( + Uint8List, { + bool allowUpscaling, + int? cacheHeight, + int? cacheWidth, + }) decode, + ) => + // Clone is important here, otherwise the RawImageProvider might dispose + // the original image. + _RawImageStreamCompleter(_image.clone()); + + @override + Future<_RawImageProvider> obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<_RawImageProvider>(this); + } +} + +class _RawImageStreamCompleter extends ImageStreamCompleter { + _RawImageStreamCompleter(ui.Image image) { + setImage(ImageInfo(image: image)); + } +} 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/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index 8a5498245..ad1c4811d 100644 --- a/test/layer/tile_layer/tile_image_view_test.dart +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -173,18 +173,20 @@ class MockTileImage extends TileImage { @override final bool readyToDisplay; + @override + final bool loadError; + MockTileImage( int x, int y, int zoom, { this.readyToDisplay = 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; } } From 2c4456533c24cfd983fe3cba94411258f2058d2a Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Wed, 2 Aug 2023 18:16:50 +0200 Subject: [PATCH 2/5] Only create placeholder tiles where tiles are missing or not fully transitioned at the current zoom This reduces the number of placeholder widgets. The algorithm used to determine which coordinates to show placeholders for is a tradeoff between speed and the number of redundant placeholders created. It creates a placeholder for every tile at the current zoom which failed to load or is still transitioning. This means that if a tile from a lower zoom obscures the placeholder or multiple tiles from a higher zoom collectively obscure the placeholder it will be unnecessarily created. It would be possible to avoid this but it would require a more complex data structure or iterating all of the tiles for every potential placeholder. --- lib/src/layer/tile_layer/tile_image.dart | 39 ++++++------- .../layer/tile_layer/tile_image_manager.dart | 13 ++--- lib/src/layer/tile_layer/tile_image_view.dart | 6 +- lib/src/layer/tile_layer/tile_layer.dart | 57 ++++++++++++++----- .../tile_layer/tile_image_view_test.dart | 10 ++-- 5 files changed, 75 insertions(+), 50 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index bfd83a414..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. @@ -67,7 +68,7 @@ class TileImage extends ChangeNotifier { double get opacity => _tileDisplay.when( instantaneous: (instantaneous) => - _readyToDisplay ? instantaneous.opacity : 0.0, + _transitionComplete ? instantaneous.opacity : 0.0, fadeIn: (fadeIn) => _animationController!.value, )!; @@ -75,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) => @@ -105,7 +106,7 @@ class TileImage extends ChangeNotifier { _animationController = AnimationController( duration: fadeIn.duration, vsync: vsync, - value: _readyToDisplay ? 1.0 : 0.0, + value: _transitionComplete ? 1.0 : 0.0, ); }, ); @@ -153,7 +154,7 @@ class TileImage extends ChangeNotifier { this.imageInfo = imageInfo; if (!_disposed) { - _display(); + _initiateTransition(); onLoadComplete(coordinates); } } @@ -167,16 +168,16 @@ class TileImage extends ChangeNotifier { } } - // Initiates fading in and marks this TileImage as readyToDisplay when fading - // finishes. If fading is disabled or a loading error occurred this TileImage - // becomes readyToDisplay immediately. - 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(); _tileDisplay.when( instantaneous: (_) { - _readyToDisplay = true; + _transitionComplete = true; if (!_disposed) notifyListeners(); }, fadeIn: (fadeIn) { @@ -184,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(); }); } @@ -219,7 +220,7 @@ class TileImage extends ChangeNotifier { } } - _readyToDisplay = false; + _transitionComplete = false; _animationController?.stop(canceled: false); _animationController?.value = 0.0; notifyListeners(); @@ -239,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 313d1166c..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. 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 7a312043d..65e9a917d 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -517,11 +517,18 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom); + final tileImagesInRenderOrder = + _tileImageManager.inRenderOrder(widget.maxZoom, tileZoom); + return _addBackgroundColor( Stack( children: [ if (widget.placeholderImage != null) - ...visibleTileRange.coordinates.map( + ..._placeholderCoordinates( + tileImagesInRenderOrder.takeWhile( + (tileImage) => tileImage.coordinates.z == tileZoom), + visibleTileRange, + ).map( (tileCoordinates) => TilePlaceholder( key: ValueKey('placeholder-${tileCoordinates.key}'), tileCoordinates: tileCoordinates, @@ -533,26 +540,46 @@ class _TileLayerState extends State with TickerProviderStateMixin { placeholderImage: widget.placeholderImage!, ), ), - ..._tileImageManager.inRenderOrder(widget.maxZoom, tileZoom).map( - (tileImage) => Tile( - // 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), - scaledTileSize: _tileScaleCalculator.scaledTileSize( - map.zoom, - tileImage.coordinates.z, - ), - currentPixelOrigin: currentPixelOrigin, - tileImage: tileImage, - tileBuilder: widget.tileBuilder, - ), + ...tileImagesInRenderOrder.map( + (tileImage) => Tile( + // 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), + scaledTileSize: _tileScaleCalculator.scaledTileSize( + map.zoom, + tileImage.coordinates.z, ), + currentPixelOrigin: currentPixelOrigin, + 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 placeholder 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; diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index ad1c4811d..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,7 +171,7 @@ void main() { class MockTileImage extends TileImage { @override - final bool readyToDisplay; + final bool transitionComplete; @override final bool loadError; @@ -180,7 +180,7 @@ class MockTileImage extends TileImage { int x, int y, int zoom, { - this.readyToDisplay = true, + this.transitionComplete = true, bool loadFinished = true, this.loadError = false, void Function(TileCoordinates coordinates)? onLoadComplete, From 1c87fcc5bef480afdbc413904ac09fee7b4d6d6d Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Thu, 3 Aug 2023 17:53:46 +0200 Subject: [PATCH 3/5] Display tile placeholders by default and use a builder instead of ImageProvider for placeholders. --- example/lib/pages/tile_error_handling.dart | 3 +- ..._placeholder.dart => positioned_tile.dart} | 15 ++-- lib/src/layer/tile_layer/tile.dart | 18 +---- lib/src/layer/tile_layer/tile_layer.dart | 68 +++++++++++++------ .../tile_layer/tile_placeholder_image.dart | 8 ++- 5 files changed, 59 insertions(+), 53 deletions(-) rename lib/src/layer/tile_layer/{tile_placeholder.dart => positioned_tile.dart} (68%) diff --git a/example/lib/pages/tile_error_handling.dart b/example/lib/pages/tile_error_handling.dart index b1b014c34..3218a7fc2 100644 --- a/example/lib/pages/tile_error_handling.dart +++ b/example/lib/pages/tile_error_handling.dart @@ -17,7 +17,6 @@ class TileErrorHandling extends StatefulWidget { class _TileErrorHandlingState extends State { static const _showSnackBarDuration = Duration(seconds: 1); - static final _placeholderImage = TilePlaceholderImage.generate(); late final TileLayerController _tileLayerController; @@ -87,7 +86,7 @@ class _TileErrorHandlingState extends State { children: [ TileLayer( controller: _tileLayerController, - placeholderImage: _placeholderImage, + showPlaceholders: true, urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', diff --git a/lib/src/layer/tile_layer/tile_placeholder.dart b/lib/src/layer/tile_layer/positioned_tile.dart similarity index 68% rename from lib/src/layer/tile_layer/tile_placeholder.dart rename to lib/src/layer/tile_layer/positioned_tile.dart index d4eb412d7..42d31b708 100644 --- a/lib/src/layer/tile_layer/tile_placeholder.dart +++ b/lib/src/layer/tile_layer/positioned_tile.dart @@ -3,18 +3,18 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -class TilePlaceholder extends StatelessWidget { +class PositionedTile extends StatelessWidget { final TileCoordinates tileCoordinates; final double scaledTileSize; final Point currentPixelOrigin; - final ImageProvider placeholderImage; + final Widget child; - const TilePlaceholder({ + const PositionedTile({ super.key, required this.tileCoordinates, required this.scaledTileSize, required this.currentPixelOrigin, - required this.placeholderImage, + required this.child, }); @override @@ -24,12 +24,7 @@ class TilePlaceholder extends StatelessWidget { top: tileCoordinates.y * scaledTileSize - currentPixelOrigin.y, width: scaledTileSize, height: scaledTileSize, - child: Image( - width: scaledTileSize, - height: scaledTileSize, - image: placeholderImage, - fit: BoxFit.fill, - ), + child: child, ); } } diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 78a5dec0d..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,16 +36,8 @@ 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 { diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 65e9a917d..ab065d6fb 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -6,11 +6,11 @@ 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'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_placeholder.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'; @@ -158,9 +158,16 @@ class TileLayer extends StatefulWidget { /// or one. final int panBuffer; - /// Tile image to show in place of a tile that has not yet loaded or fails to - /// load. - final ImageProvider? placeholderImage; + /// Whether a placeholder should be 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. To customised the placeholder use [placeholderBuilder]. + final bool showPlaceholders; + + /// Builder used to create the widget which is shown in place of a tile that + /// has not yet loaded or fails to load. The default placeholder is a grid + /// image. + late final WidgetBuilder placeholderBuilder; /// Static information that should replace placeholders in the [urlTemplate]. /// Applying API keys is a good example on how to use this parameter. @@ -246,16 +253,17 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor, + this.showPlaceholders = true, + WidgetBuilder? placeholderBuilder, @Deprecated( - 'Prefer `placeholderImage` instead. ' - 'This option is now replaced by `placeholderImage` and the behaviour has ' - 'changed to show the placeholder both when tile loading errors and when ' - 'a tile is yet to load. ' + 'Prefer `placeholderBuilder` instead. ' + 'This option is now replaced by `placeholderBuilder` and the behaviour ' + 'has changed to show a placeholder when no tiles from any zoom fill the ' + 'area which the missing tile would cover. ' 'This option is deprecated since v6.', ) ImageProvider? errorImage, - ImageProvider? placeholderImage, - final TileProvider? tileProvider, + TileProvider? tileProvider, this.tms = false, this.wmsOptions, this.tileDisplay = const TileDisplay.fadeIn(), @@ -275,7 +283,6 @@ class TileLayer extends StatefulWidget { )!, 'The tile fade in duration needs to be bigger than zero', ), - placeholderImage = errorImage ?? placeholderImage, maxZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse ? maxZoom - 1.0 @@ -304,7 +311,21 @@ class TileLayer extends StatefulWidget { 'User-Agent': 'flutter_map ($userAgentPackageName)', }), tileUpdateTransformer = - tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents; + tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents { + if (errorImage != null) { + this.placeholderBuilder = + (context) => Image(image: errorImage, fit: BoxFit.fill); + } else if (placeholderBuilder != null) { + this.placeholderBuilder = placeholderBuilder; + } else { + final placeholderImage = + TilePlaceholderImage.generate(size: tileSize.toInt()); + this.placeholderBuilder = (context) => Image( + image: placeholderImage, + fit: BoxFit.fill, + ); + } + } @override State createState() => _TileLayerState(); @@ -523,13 +544,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { return _addBackgroundColor( Stack( children: [ - if (widget.placeholderImage != null) + if (widget.showPlaceholders) ..._placeholderCoordinates( tileImagesInRenderOrder.takeWhile( (tileImage) => tileImage.coordinates.z == tileZoom), visibleTileRange, ).map( - (tileCoordinates) => TilePlaceholder( + (tileCoordinates) => PositionedTile( key: ValueKey('placeholder-${tileCoordinates.key}'), tileCoordinates: tileCoordinates, scaledTileSize: _tileScaleCalculator.scaledTileSize( @@ -537,21 +558,24 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileZoom, ), currentPixelOrigin: currentPixelOrigin, - placeholderImage: widget.placeholderImage!, + child: widget.placeholderBuilder(context), ), ), ...tileImagesInRenderOrder.map( - (tileImage) => Tile( + (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, + ), ), ), ], @@ -561,10 +585,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { /// 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 placeholder 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. + /// 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, diff --git a/lib/src/layer/tile_layer/tile_placeholder_image.dart b/lib/src/layer/tile_layer/tile_placeholder_image.dart index 4d7e7c80f..d63403f02 100644 --- a/lib/src/layer/tile_layer/tile_placeholder_image.dart +++ b/lib/src/layer/tile_layer/tile_placeholder_image.dart @@ -4,14 +4,16 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class TilePlaceholderImage { - const TilePlaceholderImage._(); - /// Creates an [ImageProvider] which resolves to an image which is a grid of /// [cellCount] transparent cells on both axis. The cells are transparent, - /// divided lines with the color [lineColor]. + /// divided by lines with the color [lineColor]. /// /// The [size] determines the width and height of the generated image and it /// should match the tile size of the [TileLayer] in which this is used. + /// + /// The returned ImageProvider is intended to be stored in a static final + /// variable and passed in to placeholderBuilder to reduce the memory + /// footprint. static ImageProvider generate({ int size = 256, Color lineColor = Colors.white, From 6bb55883aefd39020b3ebdfe18dd3a7f0a1d1d6d Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Thu, 3 Aug 2023 18:33:20 +0200 Subject: [PATCH 4/5] Simplify placeholder creation Now that placeholder is a widget instead of an ImageProvider we can simplify the construction by making it a CustomPaint. --- lib/flutter_map.dart | 2 +- .../tile_layer/placeholder_grid_tile.dart | 88 +++++++++++++++++ lib/src/layer/tile_layer/tile_layer.dart | 43 ++++---- .../tile_layer/tile_placeholder_image.dart | 97 ------------------- lib/src/map/options.dart | 1 + 5 files changed, 107 insertions(+), 124 deletions(-) create mode 100644 lib/src/layer/tile_layer/placeholder_grid_tile.dart delete mode 100644 lib/src/layer/tile_layer/tile_placeholder_image.dart diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 10121dbca..8976af599 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -16,12 +16,12 @@ 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'; 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_placeholder_image.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_providers/tile_provider_stub.dart' 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/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index ab065d6fb..e43c8f025 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -161,13 +161,12 @@ class TileLayer extends StatefulWidget { /// Whether a placeholder should be 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. To customised the placeholder use [placeholderBuilder]. + /// a placeholder. To customised the placeholder use [placeholder]. final bool showPlaceholders; - /// Builder used to create the widget which is shown in place of a tile that - /// has not yet loaded or fails to load. The default placeholder is a grid - /// image. - late final WidgetBuilder placeholderBuilder; + /// The widget which is shown in place of a tile that has not yet loaded or + /// fails to load. The default placeholder is a grid image. + final Widget placeholder; /// Static information that should replace placeholders in the [urlTemplate]. /// Applying API keys is a good example on how to use this parameter. @@ -254,12 +253,12 @@ class TileLayer extends StatefulWidget { this.panBuffer = 0, this.backgroundColor, this.showPlaceholders = true, - WidgetBuilder? placeholderBuilder, + Widget placeholder = const PlaceholderGridTile(), @Deprecated( - 'Prefer `placeholderBuilder` instead. ' - 'This option is now replaced by `placeholderBuilder` and the behaviour ' - 'has changed to show a placeholder when no tiles from any zoom fill the ' - 'area which the missing tile would cover. ' + 'Prefer `placeholder` instead. ' + 'This option is now replaced by `placeholder` and the behaviour has ' + 'changed to show a placeholder when no tiles from any zoom fill the area ' + 'which the missing tile would cover. ' 'This option is deprecated since v6.', ) ImageProvider? errorImage, @@ -311,21 +310,13 @@ class TileLayer extends StatefulWidget { 'User-Agent': 'flutter_map ($userAgentPackageName)', }), tileUpdateTransformer = - tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents { - if (errorImage != null) { - this.placeholderBuilder = - (context) => Image(image: errorImage, fit: BoxFit.fill); - } else if (placeholderBuilder != null) { - this.placeholderBuilder = placeholderBuilder; - } else { - final placeholderImage = - TilePlaceholderImage.generate(size: tileSize.toInt()); - this.placeholderBuilder = (context) => Image( - image: placeholderImage, - fit: BoxFit.fill, - ); - } - } + tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents, + placeholder = errorImage != null + ? Image( + image: errorImage, + fit: BoxFit.fill, + ) + : placeholder; @override State createState() => _TileLayerState(); @@ -558,7 +549,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileZoom, ), currentPixelOrigin: currentPixelOrigin, - child: widget.placeholderBuilder(context), + child: widget.placeholder, ), ), ...tileImagesInRenderOrder.map( diff --git a/lib/src/layer/tile_layer/tile_placeholder_image.dart b/lib/src/layer/tile_layer/tile_placeholder_image.dart deleted file mode 100644 index d63403f02..000000000 --- a/lib/src/layer/tile_layer/tile_placeholder_image.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class TilePlaceholderImage { - /// Creates an [ImageProvider] which resolves to an image which is a grid of - /// [cellCount] transparent cells on both axis. The cells are transparent, - /// divided by lines with the color [lineColor]. - /// - /// The [size] determines the width and height of the generated image and it - /// should match the tile size of the [TileLayer] in which this is used. - /// - /// The returned ImageProvider is intended to be stored in a static final - /// variable and passed in to placeholderBuilder to reduce the memory - /// footprint. - static ImageProvider generate({ - int size = 256, - Color lineColor = Colors.white, - int cellCount = 8, - }) { - final sizeDouble = size.toDouble(); - - final recorder = ui.PictureRecorder(); - final canvas = Canvas( - recorder, - Rect.fromPoints( - Offset.zero, - Offset(sizeDouble, sizeDouble), - ), - ); - - final paint = Paint() - ..color = lineColor - ..style = PaintingStyle.stroke; - - final cellOffsetSize = sizeDouble / 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(sizeDouble, cellOffset), - paint, - ); - // Vertical lines. - canvas.drawLine( - Offset(cellOffset, 0), - Offset(cellOffset, sizeDouble), - paint, - ); - } - - final picture = recorder.endRecording(); - final img = picture.toImageSync(size, size); - - return _RawImageProvider(img); - } -} - -class _RawImageProvider extends ImageProvider<_RawImageProvider> { - final ui.Image _image; - - _RawImageProvider(this._image); - - @override - ImageStreamCompleter load( - _RawImageProvider key, - Future Function( - Uint8List, { - bool allowUpscaling, - int? cacheHeight, - int? cacheWidth, - }) decode, - ) => - // Clone is important here, otherwise the RawImageProvider might dispose - // the original image. - _RawImageStreamCompleter(_image.clone()); - - @override - Future<_RawImageProvider> obtainKey(ImageConfiguration configuration) { - return SynchronousFuture<_RawImageProvider>(this); - } -} - -class _RawImageStreamCompleter extends ImageStreamCompleter { - _RawImageStreamCompleter(ui.Image image) { - setImage(ImageInfo(image: image)); - } -} 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 && From 88cabd8068a7b5f99d976d345b1bc8a56dea6427 Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Thu, 3 Aug 2023 19:09:07 +0200 Subject: [PATCH 5/5] Simplify tile placeholder options --- example/lib/pages/tile_error_handling.dart | 1 - lib/src/layer/tile_layer/tile_layer.dart | 35 +++++++++++----------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/example/lib/pages/tile_error_handling.dart b/example/lib/pages/tile_error_handling.dart index 3218a7fc2..e6b18ca50 100644 --- a/example/lib/pages/tile_error_handling.dart +++ b/example/lib/pages/tile_error_handling.dart @@ -86,7 +86,6 @@ class _TileErrorHandlingState extends State { children: [ TileLayer( controller: _tileLayerController, - showPlaceholders: true, urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index e43c8f025..e0af079bb 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -158,15 +158,15 @@ class TileLayer extends StatefulWidget { /// or one. final int panBuffer; - /// Whether a placeholder should be 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. To customised the placeholder use [placeholder]. - final bool showPlaceholders; - /// The widget which is shown in place of a tile that has not yet loaded or - /// fails to load. The default placeholder is a grid image. - final Widget placeholder; + /// 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. @@ -252,13 +252,12 @@ class TileLayer extends StatefulWidget { this.keepBuffer = 2, this.panBuffer = 0, this.backgroundColor, - this.showPlaceholders = true, - Widget placeholder = const PlaceholderGridTile(), + Widget? tilePlaceholder = const PlaceholderGridTile(), @Deprecated( - 'Prefer `placeholder` instead. ' - 'This option is now replaced by `placeholder` and the behaviour has ' - 'changed to show a placeholder when no tiles from any zoom fill the area ' - 'which the missing tile would cover. ' + '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, @@ -311,12 +310,12 @@ class TileLayer extends StatefulWidget { }), tileUpdateTransformer = tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents, - placeholder = errorImage != null + tilePlaceholder = errorImage != null ? Image( image: errorImage, fit: BoxFit.fill, ) - : placeholder; + : tilePlaceholder; @override State createState() => _TileLayerState(); @@ -535,7 +534,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { return _addBackgroundColor( Stack( children: [ - if (widget.showPlaceholders) + if (widget.tilePlaceholder != null) ..._placeholderCoordinates( tileImagesInRenderOrder.takeWhile( (tileImage) => tileImage.coordinates.z == tileZoom), @@ -549,7 +548,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { tileZoom, ), currentPixelOrigin: currentPixelOrigin, - child: widget.placeholder, + child: widget.tilePlaceholder!, ), ), ...tileImagesInRenderOrder.map(