diff --git a/example/lib/main.dart b/example/lib/main.dart index c2ad69bd1..16fedacc4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; +import 'package:flutter_map_example/pages/cancellable_tile_provider/cancellable_tile_provider.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; @@ -47,6 +48,8 @@ class MyApp extends StatelessWidget { ), home: const HomePage(), routes: { + CancellableTileProviderPage.route: (context) => + const CancellableTileProviderPage(), PolylinePage.route: (context) => const PolylinePage(), MapControllerPage.route: (context) => const MapControllerPage(), AnimatedMapControllerPage.route: (context) => diff --git a/example/lib/pages/cancellable_tile_provider/cancellable_tile_provider.dart b/example/lib/pages/cancellable_tile_provider/cancellable_tile_provider.dart new file mode 100644 index 000000000..d1618f24f --- /dev/null +++ b/example/lib/pages/cancellable_tile_provider/cancellable_tile_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map_example/pages/cancellable_tile_provider/ctp_impl.dart'; +import 'package:flutter_map_example/widgets/drawer.dart'; +import 'package:latlong2/latlong.dart'; + +class CancellableTileProviderPage extends StatelessWidget { + static const String route = '/cancellable_tile_provider_page'; + + const CancellableTileProviderPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Cancellable Tile Provider')), + drawer: buildDrawer(context, CancellableTileProviderPage.route), + body: Column( + children: [ + const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'This map uses a custom `TileProvider` that cancels HTTP requests for unnecessary tiles. This should help speed up tile loading and reduce unneccessary costly tile requests, mainly on the web!', + ), + ), + Expanded( + child: FlutterMap( + options: MapOptions( + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, + cameraConstraint: CameraConstraint.contain( + bounds: LatLngBounds( + const LatLng(-90, -180), + const LatLng(90, 180), + ), + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'dev.fleaflet.flutter_map.example', + tileProvider: CancellableNetworkTileProvider(), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/pages/cancellable_tile_provider/ctp_impl.dart b/example/lib/pages/cancellable_tile_provider/ctp_impl.dart new file mode 100644 index 000000000..b291a9674 --- /dev/null +++ b/example/lib/pages/cancellable_tile_provider/ctp_impl.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:http/http.dart'; +import 'package:http/retry.dart'; + +class CancellableNetworkTileProvider extends TileProvider { + CancellableNetworkTileProvider({ + super.headers, + BaseClient? httpClient, + }) : httpClient = httpClient ?? RetryClient(Client()); + + final BaseClient httpClient; + + @override + bool get supportsCancelLoading => true; + + @override + ImageProvider getImageWithCancelLoadingSupport( + TileCoordinates coordinates, + TileLayer options, + Future cancelLoading, + ) => + CancellableNetworkImageProvider( + url: getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), + headers: headers, + httpClient: httpClient, + cancelLoading: cancelLoading, + ); +} + +class CancellableNetworkImageProvider + extends ImageProvider { + final String url; + final String? fallbackUrl; + final BaseClient httpClient; + final Map headers; + final Future cancelLoading; + + const CancellableNetworkImageProvider({ + required this.url, + required this.fallbackUrl, + required this.headers, + required this.httpClient, + required this.cancelLoading, + }); + + @override + ImageStreamCompleter loadImage( + CancellableNetworkImageProvider key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, chunkEvents, decode), + chunkEvents: chunkEvents.stream, + scale: 1, + debugLabel: url, + informationCollector: () => [ + DiagnosticsProperty('URL', url), + DiagnosticsProperty('Fallback URL', fallbackUrl), + DiagnosticsProperty('Current provider', key), + ], + ); + } + + @override + Future obtainKey( + ImageConfiguration configuration, + ) => + SynchronousFuture(this); + + Future _loadAsync( + CancellableNetworkImageProvider key, + StreamController chunkEvents, + ImageDecoderCallback decode, { + bool useFallback = false, + }) async { + final cancelToken = CancelToken(); + cancelLoading.then((_) => cancelToken.cancel()); + + final Uint8List bytes; + try { + final dio = Dio(); + final response = await dio.get( + useFallback ? fallbackUrl ?? '' : url, + cancelToken: cancelToken, + options: Options( + headers: headers, + responseType: ResponseType.bytes, + ), + ); + bytes = response.data!; + } on DioException catch (err) { + if (CancelToken.isCancel(err)) { + return decode( + await ImmutableBuffer.fromUint8List(TileProvider.transparentImage), + ); + } + if (useFallback || fallbackUrl == null) rethrow; + return _loadAsync(key, chunkEvents, decode, useFallback: true); + } catch (_) { + if (useFallback || fallbackUrl == null) rethrow; + return _loadAsync(key, chunkEvents, decode, useFallback: true); + } + + return decode(await ImmutableBuffer.fromUint8List(bytes)); + } +} diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index 63527f99d..ac8562b81 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; +import 'package:flutter_map_example/pages/cancellable_tile_provider/cancellable_tile_provider.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; @@ -153,6 +154,12 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { FallbackUrlNetworkPage.route, currentRoute, ), + _buildMenuItem( + context, + const Text('Cancellable Tile Provider'), + CancellableTileProviderPage.route, + currentRoute, + ), const Divider(), _buildMenuItem( context, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 2e50967ea..dfba65897 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: url_launcher: ^6.1.10 shared_preferences: ^2.1.1 url_strategy: ^0.2.0 + http: ^1.1.0 + dio: ^5.3.2 dev_dependencies: flutter_lints: ^2.0.1 diff --git a/lib/src/layer/tile_layer/tile_error_evict_callback.dart b/lib/src/layer/tile_layer/tile_error_evict_callback.dart new file mode 100644 index 000000000..e5a88c264 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_error_evict_callback.dart @@ -0,0 +1,36 @@ +part of 'tile_layer.dart'; + +@Deprecated( + 'Prefer creating a custom `TileProvider` instead. ' + 'This option has been deprecated as it is out of scope for the `TileLayer`. ' + 'This option is deprecated since v6.', +) +typedef TemplateFunction = String Function( + String str, + Map data, +); + +enum EvictErrorTileStrategy { + /// Never evict images for tiles which failed to load. + none, + + /// Evict images for tiles which failed to load when they are pruned. + dispose, + + /// Evict images for tiles which failed to load and: + /// - do not belong to the current zoom level AND/OR + /// - are not visible, respecting the pruning buffer (the maximum of the + /// [keepBuffer] and [panBuffer]. + notVisibleRespectMargin, + + /// Evict images for tiles which failed to load and: + /// - do not belong to the current zoom level AND/OR + /// - are not visible + notVisible, +} + +typedef ErrorTileCallBack = void Function( + TileImage tile, + Object error, + StackTrace? stackTrace, +); diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 9ae41b72a..8aeeb44f9 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; @@ -35,6 +37,11 @@ class TileImage extends ChangeNotifier { /// An optional image to show when a loading error occurs. final ImageProvider? errorImage; + /// Completer that is completed when this object is disposed + /// + /// Intended to allow [TileProvider]s to cancel unneccessary HTTP requests. + final Completer cancelLoading; + ImageProvider imageProvider; /// True if an error occurred during loading. @@ -58,6 +65,7 @@ class TileImage extends ChangeNotifier { required this.onLoadError, required TileDisplay tileDisplay, required this.errorImage, + required this.cancelLoading, }) : _tileDisplay = tileDisplay, _animationController = tileDisplay.when( instantaneous: (_) => null, @@ -126,6 +134,8 @@ class TileImage extends ChangeNotifier { /// Initiate loading of the image. void load() { + if (cancelLoading.isCompleted) return; + loadStarted = DateTime.now(); try { @@ -230,6 +240,8 @@ class TileImage extends ChangeNotifier { } } + cancelLoading.complete(); + _readyToDisplay = false; _animationController?.stop(canceled: false); _animationController?.value = 0.0; diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index c3c7c2a7a..bc7130895 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -125,10 +125,16 @@ class TileImageManager { final tilesToReload = List.from(_tiles.values); for (final tile in tilesToReload) { - tile.imageProvider = layer.tileProvider.getImage( - tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates), - layer, - ); + tile.imageProvider = layer.tileProvider.supportsCancelLoading + ? layer.tileProvider.getImageWithCancelLoadingSupport( + tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates), + layer, + tile.cancelLoading.future, + ) + : layer.tileProvider.getImage( + tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates), + layer, + ); tile.load(); } } diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index be7f97b25..bc20f5355 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'dart:math' as math hide Point; -import 'dart:math' show Point; +import 'dart:math'; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; @@ -12,10 +11,10 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; -import 'package:flutter_map/src/misc/private/util.dart' as util; import 'package:http/retry.dart'; -part 'tile_layer_options.dart'; +part 'tile_error_evict_callback.dart'; +part 'wms_tile_layer_options.dart'; /// Describes the needed properties to create a tile-based layer. A tile is an /// image bound to a specific geographical position. @@ -195,7 +194,12 @@ class TileLayer extends StatefulWidget { /// This callback will be executed if an error occurs when fetching tiles. final ErrorTileCallBack? errorTileCallback; - final TemplateFunction templateFunction; + @Deprecated( + 'Prefer creating a custom `TileProvider` instead. ' + 'This option has been deprecated as it is out of scope for the `TileLayer`. ' + 'This option is deprecated since v6.', + ) + final TemplateFunction? templateFunction; /// Function which may Wrap Tile with custom Widget /// There are predefined examples in 'tile_builder.dart' @@ -234,7 +238,7 @@ class TileLayer extends StatefulWidget { this.maxNativeZoom, this.zoomReverse = false, double zoomOffset = 0.0, - Map? additionalOptions, + this.additionalOptions = const {}, this.subdomains = const [], this.keepBuffer = 2, this.panBuffer = 0, @@ -246,7 +250,12 @@ class TileLayer extends StatefulWidget { this.tileDisplay = const TileDisplay.fadeIn(), this.retinaMode = false, this.errorTileCallback, - this.templateFunction = util.template, + @Deprecated( + 'Prefer creating a custom `TileProvider` instead. ' + 'This option has been deprecated as it is out of scope for the `TileLayer`. ' + 'This option is deprecated since v6.', + ) + this.templateFunction, this.tileBuilder, this.evictErrorTileStrategy = EvictErrorTileStrategy.none, this.reset, @@ -254,17 +263,19 @@ 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 : maxZoom, minZoom = wmsOptions == null && retinaMode && maxZoom > 0.0 && zoomReverse - ? math.max(minZoom + 1.0, 0) + ? max(minZoom + 1.0, 0) : minZoom, zoomOffset = wmsOptions == null && retinaMode && maxZoom > 0.0 ? (zoomReverse ? zoomOffset - 1.0 : zoomOffset + 1.0) @@ -272,19 +283,9 @@ class TileLayer extends StatefulWidget { tileSize = wmsOptions == null && retinaMode && maxZoom > 0.0 ? (tileSize / 2.0).floorToDouble() : tileSize, - additionalOptions = additionalOptions == null - ? const {} - : Map.from(additionalOptions), - tileProvider = tileProvider == null - ? NetworkTileProvider( - headers: {'User-Agent': 'flutter_map ($userAgentPackageName)'}, - ) - : (tileProvider - ..headers = { - ...tileProvider.headers, - if (!tileProvider.headers.containsKey('User-Agent')) - 'User-Agent': 'flutter_map ($userAgentPackageName)', - }), + tileProvider = (tileProvider ?? NetworkTileProvider()) + ..headers.putIfAbsent( + 'User-Agent', () => 'flutter_map ($userAgentPackageName)'), tileUpdateTransformer = tileUpdateTransformer ?? TileUpdateTransformers.ignoreTapEvents; @@ -519,19 +520,30 @@ class _TileLayerState extends State with TickerProviderStateMixin { required TileBoundsAtZoom tileBoundsAtZoom, required bool pruneAfterLoad, }) { + final cancelLoading = Completer(); + + final imageProvider = widget.tileProvider.supportsCancelLoading + ? widget.tileProvider.getImageWithCancelLoadingSupport( + tileBoundsAtZoom.wrap(coordinates), + widget, + cancelLoading.future, + ) + : widget.tileProvider.getImage( + tileBoundsAtZoom.wrap(coordinates), + widget, + ); + return TileImage( vsync: this, coordinates: coordinates, - imageProvider: widget.tileProvider.getImage( - tileBoundsAtZoom.wrap(coordinates), - widget, - ), + imageProvider: imageProvider, onLoadError: _onTileLoadError, onLoadComplete: (coordinates) { if (pruneAfterLoad) _pruneIfAllTilesLoaded(coordinates); }, tileDisplay: widget.tileDisplay, errorImage: widget.errorImage, + cancelLoading: cancelLoading, ); } @@ -576,7 +588,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileImageManager.evictAndPrune( visibleRange: visibleTileRange, - pruneBuffer: math.max(widget.panBuffer, widget.keepBuffer), + pruneBuffer: max(widget.panBuffer, widget.keepBuffer), evictStrategy: widget.evictErrorTileStrategy, ); } @@ -631,10 +643,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { var result = zoom.round(); if (widget.minNativeZoom != null) { - result = math.max(result, widget.minNativeZoom!); + result = max(result, widget.minNativeZoom!); } if (widget.maxNativeZoom != null) { - result = math.min(result, widget.maxNativeZoom!); + result = min(result, widget.maxNativeZoom!); } return result; @@ -674,7 +686,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { ); _tileImageManager.prune( visibleRange: visibleTileRange, - pruneBuffer: math.max(widget.panBuffer, widget.keepBuffer), + pruneBuffer: max(widget.panBuffer, widget.keepBuffer), evictStrategy: widget.evictErrorTileStrategy, ); } diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 4667aacc8..48af0df01 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -1,84 +1,311 @@ +import 'dart:typed_data'; +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_tile_provider.dart'; +import 'package:meta/meta.dart'; -/// The base tile provider implementation, extended by other classes such as [NetworkTileProvider] +/// The base tile provider, extended by other classes with more specialised +/// purposes and/or requirements +/// +/// Prefer extending over implementing. /// -/// Visit the online documentation at https://docs.fleaflet.dev/usage/layers/tile-layer/tile-providers for more information. +/// For more information, see +/// , and +/// . For an example +/// extension (with custom [ImageProvider]), see [NetworkTileProvider]. abstract class TileProvider { - /// Custom headers that may be sent with each tile request, if the specific - /// implementation supports it - Map headers; - - /// The base tile provider implementation, extended by other classes such - /// as [NetworkTileProvider]. - /// Visit the online documentation at - /// https://docs.fleaflet.dev/usage/layers/tile-layer/tile-providers for - /// more information. - TileProvider({this.headers = const {}}); - - /// Retrieve a tile as an image, based on it's coordinates and the - /// current [TileLayerOptions] - ImageProvider getImage(TileCoordinates coordinates, TileLayer options); - - /// Called when the [TileLayerWidget] is disposed + /// Custom HTTP headers that may be sent with each tile request + /// + /// Non-networking implementations may ignore this property. + /// + /// [TileLayer] will usually automatically set the 'User-Agent' header, based + /// on the `userAgentPackageName`, but this can be overridden. On the web, this + /// header cannot be changed, as specified in [TileLayer.tileProvider]'s + /// documentation, due to a Dart/browser limitation. + final Map headers; + + /// Indicates to flutter_map internals whether to call [getImage] (when + /// `false`) or [getImageWithCancelLoadingSupport] + /// + /// The appropriate method must be overriden, else an [UnimplementedError] + /// will be thrown. + /// + /// [getImageWithCancelLoadingSupport] is designed to allow for implementations + /// that can cancel HTTP requests or other processing in-flight, when the + /// underlying tile is disposed before it is loaded. This may increase + /// performance, and may decrease unnecessary tile requests. Note that this + /// only applies to the web platform. For more information, and detailed + /// implementation expectations, see documentation on + /// [getImageWithCancelLoadingSupport]. + /// + /// [getImage] does not support cancellation. + /// + /// Defaults to `false`. Only needs to be overridden where + /// [getImageWithCancelLoadingSupport] is in use. + bool get supportsCancelLoading => false; + + /// Construct the base tile provider and initialise the [headers] property + /// + /// This is not a constant constructor, and does not use an initialising + /// formal, intentionally. To enable [TileLayer] to efficiently (without + /// [headers] being non-final or unstable `late`) inject the appropriate + /// 'User-Agent' (based on what is specified by the user), the [headers] [Map] + /// must not be constant. + /// + /// Extenders should add `super.headers` to their constructors if they support + /// custom HTTP headers. However, they should not provide a constant default + /// value. + TileProvider({Map? headers}) : headers = headers ?? {}; + + /// Retrieve a tile as an image, based on its coordinates and the [TileLayer] + /// + /// Usually redirects to a custom [ImageProvider], with one input depending on + /// [getTileUrl]. + /// + /// For many implementations, this is the only method that will need + /// implementing. + /// + /// --- + /// + /// Does not support cancelling loading tiles, unlike + /// [getImageWithCancelLoadingSupport]. For this method to be called instead of + /// that, [supportsCancelLoading] must be `false` (default). + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + throw UnimplementedError( + 'A `TileProvider` that does not override `supportsCancelLoading` to `true` ' + 'must override `getImage`', + ); + } + + /// Retrieve a tile as an image, based on its coordinates and the [TileLayer] + /// + /// For this method to be called instead of [getImage], [supportsCancelLoading] + /// must be overriden to `true`. + /// + /// Usually redirects to a custom [ImageProvider], with one parameter using + /// [getTileUrl], and one using [cancelLoading]. + /// + /// For many implementations, this is the only method that will need + /// implementing. + /// + /// --- + /// + /// Supports cancelling loading tiles, which is designed to allow for + /// implementations that can cancel HTTP requests or other processing + /// in-flight, when the underlying tile is disposed before it is loaded. This + /// may increase performance, and may decrease unnecessary tile requests. Note + /// that this only applies to the web platform. + /// + /// The [cancelLoading] future will complete when the underlying tile is + /// disposed/pruned. The implementation should therefore listen for its + /// completion, then cancel the loading. If an image [Codec] is required, + /// decode [transparentImage] - it will never be visible anyway. Note that + /// [cancelLoading] will always be completed on disposal, even if the tile has + /// been fully loaded, but this side effect is not usually an issue. + /// + /// See this example with 'package:dio's `CancelToken`: + /// + /// ```dart + /// final cancelToken = CancelToken(); + /// cancelLoading.then((_) => cancelToken.cancel()); + /// ``` + ImageProvider getImageWithCancelLoadingSupport( + TileCoordinates coordinates, + TileLayer options, + Future cancelLoading, + ) { + throw UnimplementedError( + 'A `TileProvider` that overrides `supportsCancelLoading` to `true` must ' + 'override `getImageWithCancelLoadingSupport`', + ); + } + + /// Called when the [TileLayer] is disposed void dispose() {} - String _getTileUrl( + /// Regex that describes the format of placeholders in a `urlTemplate` + /// + /// Used internally by [populateTemplatePlaceholders], but may also be used + /// externally. + /// + /// --- + /// + /// The regex used prior to v6 originated from leaflet.js, specifically from + /// commit [dc79b10683d2](https://github.com/Leaflet/Leaflet/commit/dc79b10683d232b9637cbe4d65567631f4fa5a0b). + /// Prior to that, a more permissive regex was used, starting from commit + /// [70339807ed6b](https://github.com/Leaflet/Leaflet/commit/70339807ed6bec630ee9c2e96a9cb8356fa6bd86). + /// It is never mentioned why this regex was used or changed in Leaflet. + /// This regex is more permissive of the characters it allows. + static final templatePlaceholderElement = RegExp(r'{([^{}]*)}'); + + /// Replaces placeholders in the form [templatePlaceholderElement] with their + /// corresponding values + /// + /// Avoid using this externally, instead use [getTileUrl] (which uses this) to + /// automatically handle WMS usage. + /// + /// {@macro tile_provider-override_url_gen} + @visibleForOverriding + String populateTemplatePlaceholders( String urlTemplate, TileCoordinates coordinates, TileLayer options, ) { - final z = _getZoomForUrl(coordinates, options); + final replacementMap = + generateReplacementMap(urlTemplate, coordinates, options); - return options.templateFunction(urlTemplate, { + return urlTemplate.replaceAllMapped( + templatePlaceholderElement, + (match) { + final value = replacementMap[match.group(1)!]; + if (value != null) return value; + throw ArgumentError( + 'Missing value for placeholder: {${match.group(1)}}', + ); + }, + ); + } + + /// Generate the [Map] of placeholders to replacements, to be used in + /// [populateTemplatePlaceholders] + /// + /// Instead of overriding this directly, consider using + /// [TileLayer.additionalOptions] to inject additional placeholders. + /// + /// {@macro tile_provider-override_url_gen} + @visibleForOverriding + Map generateReplacementMap( + String urlTemplate, + TileCoordinates coordinates, + TileLayer options, + ) { + final zoom = (options.zoomOffset + + (options.zoomReverse + ? options.maxZoom - coordinates.z.toDouble() + : coordinates.z.toDouble())) + .round(); + + return { 'x': coordinates.x.toString(), - 'y': (options.tms ? invertY(coordinates.y, z) : coordinates.y).toString(), - 'z': z.toString(), - 's': getSubdomain(coordinates, options), + 'y': (options.tms ? ((1 << zoom) - 1) - coordinates.y : coordinates.y) + .toString(), + 'z': zoom.toString(), + 's': options.subdomains.isEmpty + ? '' + : options.subdomains[ + (coordinates.x + coordinates.y) % options.subdomains.length], 'r': '@2x', ...options.additionalOptions, - }); + }; } - /// Generate a valid URL for a tile, based on it's coordinates and the current - /// [TileLayerOptions] + /// Generate a primary URL for a tile, based on its coordinates and the + /// [TileLayer] + /// + /// {@template tile_provider-override_url_gen} + /// --- + /// + /// When creating a specialized [TileProvider], prefer overriding URL + /// generation related methods in the following order: + /// + /// 1. [populateTemplatePlaceholders] + /// 2. [generateReplacementMap] + /// 3. [getTileUrl] and/or [getTileFallbackUrl] + /// {@endtemplate} String getTileUrl(TileCoordinates coordinates, TileLayer options) { final urlTemplate = (options.wmsOptions != null) ? options.wmsOptions! .getUrl(coordinates, options.tileSize.toInt(), options.retinaMode) : options.urlTemplate; - return _getTileUrl(urlTemplate!, coordinates, options); + return populateTemplatePlaceholders(urlTemplate!, coordinates, options); } - /// Generates a valid URL for the [fallbackUrl]. + /// Generate a fallback URL for a tile, based on its coordinates and the + /// [TileLayer] + /// + /// {@macro tile_provider-override_url_gen} String? getTileFallbackUrl(TileCoordinates coordinates, TileLayer options) { final urlTemplate = options.fallbackUrl; if (urlTemplate == null) return null; - return _getTileUrl(urlTemplate, coordinates, options); + return populateTemplatePlaceholders(urlTemplate, coordinates, options); } - int _getZoomForUrl(TileCoordinates coordinates, TileLayer options) { - var zoom = coordinates.z.toDouble(); - - if (options.zoomReverse) { - zoom = options.maxZoom - zoom; - } - - return (zoom += options.zoomOffset).round(); - } - - int invertY(int y, int z) { - return ((1 << z) - 1) - y; - } - - /// Get a subdomain value for a tile, based on it's coordinates and the current [TileLayerOptions] - String getSubdomain(TileCoordinates coordinates, TileLayer options) { - if (options.subdomains.isEmpty) { - return ''; - } - final index = (coordinates.x + coordinates.y) % options.subdomains.length; - return options.subdomains[index]; - } + /// [Uint8List] that forms a fully transparent image + /// + /// Intended to be used with [getImageWithCancelLoadingSupport], so that a + /// cancelled tile load returns this. It will not be displayed. An error cannot + /// be thrown from a custom [ImageProvider]. + static final transparentImage = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, + ]); } diff --git a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart index 2bf1c62ec..208892787 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart @@ -13,16 +13,26 @@ import 'package:http/retry.dart'; /// /// On the web, the 'User-Agent' header cannot be changed as specified in /// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. +/// +/// Does not support cancellation of tile loading via +/// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight +/// HTTP requests on the web is +/// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). class NetworkTileProvider extends TileProvider { /// [TileProvider] to fetch tiles from the network /// /// By default, a [RetryClient] is used to retry failed requests. 'dart:http' /// or 'dart:io' might be needed to override this. /// - /// On the web, the 'User-Agent' header cannot be changed as specified in + /// On the web, the 'User-Agent' header cannot be changed, as specified in /// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. + /// + /// Does not support cancellation of tile loading via + /// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight + /// HTTP requests on the web is + /// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). NetworkTileProvider({ - super.headers = const {}, + super.headers, BaseClient? httpClient, }) : httpClient = httpClient ?? RetryClient(Client()); @@ -37,4 +47,10 @@ class NetworkTileProvider extends TileProvider { headers: headers, httpClient: httpClient, ); + + @override + void dispose() { + httpClient.close(); + super.dispose(); + } } diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/wms_tile_layer_options.dart similarity index 79% rename from lib/src/layer/tile_layer/tile_layer_options.dart rename to lib/src/layer/tile_layer/wms_tile_layer_options.dart index f5f8e685f..2fb33ddf7 100644 --- a/lib/src/layer/tile_layer/tile_layer_options.dart +++ b/lib/src/layer/tile_layer/wms_tile_layer_options.dart @@ -1,33 +1,5 @@ part of 'tile_layer.dart'; -typedef TemplateFunction = String Function( - String str, Map data); - -enum EvictErrorTileStrategy { - /// Never evict images for tiles which failed to load. - none, - - /// Evict images for tiles which failed to load when they are pruned. - dispose, - - /// Evict images for tiles which failed to load and: - /// - do not belong to the current zoom level AND/OR - /// - are not visible, respecting the pruning buffer (the maximum of the - /// [keepBuffer] and [panBuffer]. - notVisibleRespectMargin, - - /// Evict images for tiles which failed to load and: - /// - do not belong to the current zoom level AND/OR - /// - are not visible - notVisible, -} - -typedef ErrorTileCallBack = void Function( - TileImage tile, - Object error, - StackTrace? stackTrace, -); - @immutable class WMSTileLayerOptions { final service = 'WMS'; diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index 8a5498245..b78ab4428 100644 --- a/test/layer/tile_layer/tile_image_view_test.dart +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/src/scheduler/ticker.dart'; @@ -192,6 +193,7 @@ class MockTileImage extends TileImage { onLoadComplete: onLoadComplete ?? (_) {}, onLoadError: onLoadError ?? (_, __, ___) {}, tileDisplay: const TileDisplay.instantaneous(), + cancelLoading: Completer(), ) { loadFinishedAt = loadFinished ? DateTime.now() : null; this.loadError = loadError;