diff --git a/example/lib/main.dart b/example/lib/main.dart index 703143575..93f2ce9e7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter_map_example/pages/bundled_offline_map.dart'; import 'package:flutter_map_example/pages/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/debouncing_tile_update_transformer.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; @@ -86,6 +87,8 @@ class MyApp extends StatelessWidget { FallbackUrlPage.route: (context) => const FallbackUrlPage(), SecondaryTapPage.route: (context) => const SecondaryTapPage(), RetinaPage.route: (context) => const RetinaPage(), + DebouncingTileUpdateTransformerPage.route: (context) => + const DebouncingTileUpdateTransformerPage(), }, ); } diff --git a/example/lib/pages/debouncing_tile_update_transformer.dart b/example/lib/pages/debouncing_tile_update_transformer.dart new file mode 100644 index 000000000..cf0147d2e --- /dev/null +++ b/example/lib/pages/debouncing_tile_update_transformer.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; +import 'package:flutter_map_example/widgets/notice_banner.dart'; +import 'package:latlong2/latlong.dart'; + +class DebouncingTileUpdateTransformerPage extends StatefulWidget { + static const String route = '/debouncing_tile_update_transformer_page'; + + const DebouncingTileUpdateTransformerPage({super.key}); + + @override + State createState() => + _DebouncingTileUpdateTransformerPageState(); +} + +class _DebouncingTileUpdateTransformerPageState + extends State { + int _changeEndKeyRefresher = 0; + double _durationInMilliseconds = 20; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Debouncing Tile Update Transformer')), + drawer: const MenuDrawer(DebouncingTileUpdateTransformerPage.route), + body: Column( + children: [ + const NoticeBanner.informational( + text: + 'This TileUpdateTransformer debounces TileUpdateEvents so they ' + "don't occur too frequently, which can improve performance and " + 'reduce tile requests.\nHowever, this does lead to reduced UX, ' + 'as tiles will not be loaded during long movements or ' + 'animations, resulting in the background grey breaking the ' + 'illusion of a seamless map.', + url: + 'https://docs.fleaflet.dev/layers/tile-layer#tile-update-transformers', + sizeTransition: 1360, + ), + Expanded( + child: Stack( + children: [ + 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( + key: ValueKey('TileLayer-$_changeEndKeyRefresher'), + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'dev.fleaflet.flutter_map.example', + tileUpdateTransformer: TileUpdateTransformers.debounce( + Duration(milliseconds: _durationInMilliseconds.toInt()), + ), + ), + ], + ), + Positioned( + left: 16, + top: 16, + right: 16, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(32), + ), + child: Padding( + padding: const EdgeInsets.only( + left: 16, right: 8, top: 4, bottom: 4), + child: Row( + children: [ + const Tooltip( + message: 'Adjust Duration', + child: Icon(Icons.timer), + ), + Expanded( + child: Slider.adaptive( + value: _durationInMilliseconds, + onChanged: (v) => + setState(() => _durationInMilliseconds = v), + onChangeEnd: (v) => + setState(() => _changeEndKeyRefresher++), + min: 0, + max: 500, + divisions: 100, + label: _durationInMilliseconds == 0 + ? 'Instant/No Debounce' + : '${_durationInMilliseconds.toInt()} ms', + ), + ), + ], + ), + ), + ), + ) + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index 96700e5fb..b66ad2acb 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -4,6 +4,7 @@ import 'package:flutter_map_example/pages/bundled_offline_map.dart'; import 'package:flutter_map_example/pages/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/debouncing_tile_update_transformer.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; @@ -137,6 +138,11 @@ class MenuDrawer extends StatelessWidget { routeName: CancellableTileProviderPage.route, currentRoute: currentRoute, ), + MenuItemWidget( + caption: 'Debouncing Tile Update Transformer', + routeName: DebouncingTileUpdateTransformerPage.route, + currentRoute: currentRoute, + ), const Divider(), MenuItemWidget( caption: 'Polygon Stress Test', diff --git a/example/lib/widgets/notice_banner.dart b/example/lib/widgets/notice_banner.dart index 3285be85b..703623af3 100644 --- a/example/lib/widgets/notice_banner.dart +++ b/example/lib/widgets/notice_banner.dart @@ -20,6 +20,15 @@ class NoticeBanner extends StatelessWidget { foregroundColor = const Color(0xFF072100), backgroundColor = const Color(0xFFB8F397); + const NoticeBanner.informational({ + super.key, + required this.text, + required this.url, + required this.sizeTransition, + }) : icon = Icons.info_outline, + foregroundColor = const Color(0xFF072100), + backgroundColor = Colors.lightBlueAccent; + final String text; final String? url; final double sizeTransition; @@ -33,10 +42,7 @@ class NoticeBanner extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { return Container( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: constraints.maxWidth <= sizeTransition ? 8 : 0, - ), + padding: const EdgeInsets.all(12), width: double.infinity, color: backgroundColor, child: Flex( @@ -46,14 +52,14 @@ class NoticeBanner extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: foregroundColor, size: 32), - const SizedBox(height: 12, width: 16), + const SizedBox(height: 8, width: 12), Text( text, style: TextStyle(color: foregroundColor), textAlign: TextAlign.center, ), if (url != null) ...[ - const SizedBox(height: 0, width: 16), + const SizedBox(height: 8, width: 12), TextButton.icon( icon: const Icon(Icons.open_in_new), label: const Text('Learn more'), diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index db93df67c..c0b8e19e2 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -197,35 +197,19 @@ class TileLayer extends StatefulWidget { /// Only load tiles that are within these bounds final LatLngBounds? tileBounds; - /// This transformer modifies how/when tile updates and pruning are triggered - /// based on [MapEvent]s. It is a StreamTransformer and therefore it is - /// possible to filter/modify/throttle the [TileUpdateEvent]s. Defaults to - /// [TileUpdateTransformers.ignoreTapEvents] which disables loading/pruning - /// for map taps, secondary taps and long presses. See TileUpdateTransformers - /// for more transformer presets or implement your own. + /// Restricts and limits [TileUpdateEvent]s (which are emitted 'by' + /// [MapEvent]s), which cause tiles to update. /// - /// Note: Changing the [tileUpdateTransformer] after TileLayer is created has - /// no affect. - final TileUpdateTransformer tileUpdateTransformer; - - /// Defines the minimum delay time from last map event before the tile layers - /// are updated. This delay acts as a debounce period to prevent frequent - /// reloading of tile layers in response to rapid, successive events - /// (e.g., zooming or panning). - /// - /// 16ms could be a good starting point for most applications. - /// This at 60fps this will wait one frame after the last event. + /// For more information, see [TileUpdateTransformer]. /// - /// By setting this delay, we ensure that map layer updates are performed - /// only after a period of inactivity, enhancing performance and user - /// experience on lower performance devices. + /// Defaults to [TileUpdateTransformers.ignoreTapEvents], which disables + /// updates for map taps, secondary taps and long presses, which alone should + /// not cause the camera to change position. /// - /// - If multiple events occur within this delay period, only the last event - /// triggers the tile layer update, reducing unnecessary processing and - /// network requests. - /// - If the [loadingDelay] is `Duration.zero`, the delay is completely - /// disabled and the tile layer will update as soon as possible. - final Duration loadingDelay; + /// Note that changing this after the layer has already been built will have + /// no effect. If necessary, force a rebuild of the entire layer by changing + /// the [key]. + final TileUpdateTransformer tileUpdateTransformer; /// Create a new [TileLayer] for the [FlutterMap] widget. TileLayer({ @@ -258,7 +242,6 @@ class TileLayer extends StatefulWidget { this.evictErrorTileStrategy = EvictErrorTileStrategy.none, this.reset, this.tileBounds, - this.loadingDelay = Duration.zero, TileUpdateTransformer? tileUpdateTransformer, String userAgentPackageName = 'unknown', }) : assert( @@ -353,9 +336,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { TileRangeCalculator(tileSize: widget.tileSize); late TileScaleCalculator _tileScaleCalculator; - /// Delay Timer for [TileLayer.loadingDelay] - Timer? _delayTimer; - // We have to hold on to the mapController hashCode to determine whether we // need to reinitialize the listeners. didChangeDependencies is called on // every map movement and if we unsubscribe and resubscribe every time we @@ -370,26 +350,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { _loadAndPruneInVisibleBounds(MapCamera.of(context)); }); - /// This method is used to delay the execution of a function by the specified - /// [TileLayer.loadingDelay]. This is useful to prevent frequent reloading - /// of tile layers in response to rapid, successive events (e.g., zooming - /// or panning). - void _loadingDelay(VoidCallback action) { - //execute immediately if delay is zero. - if (widget.loadingDelay == Duration.zero) { - action(); - return; - } - - // Cancel the previous timer if it is still active. - _delayTimer?.cancel(); - - // Reset the timer to wait for the debounce duration - _delayTimer = Timer(widget.loadingDelay, action); - } - // This is called on every map movement so we should avoid expensive logic - // where possible. + // where possible, or filter as necessary @override void didChangeDependencies() { super.didChangeDependencies(); @@ -404,7 +366,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileUpdateSubscription = mapController.mapEventStream .map((mapEvent) => TileUpdateEvent(mapEvent: mapEvent)) .transform(widget.tileUpdateTransformer) - .listen((event) => _loadingDelay(() => _onTileUpdateEvent(event))); + .listen(_onTileUpdateEvent); } var reloadTiles = false; @@ -499,7 +461,6 @@ class _TileLayerState extends State with TickerProviderStateMixin { _resetSub?.cancel(); _pruneLater?.cancel(); widget.tileProvider.dispose(); - _delayTimer?.cancel(); super.dispose(); } diff --git a/lib/src/layer/tile_layer/tile_update_transformer.dart b/lib/src/layer/tile_layer/tile_update_transformer.dart index 26c3d7f08..2b1fb565e 100644 --- a/lib/src/layer/tile_layer/tile_update_transformer.dart +++ b/lib/src/layer/tile_layer/tile_update_transformer.dart @@ -3,18 +3,37 @@ import 'dart:async'; import 'package:flutter_map/flutter_map.dart'; import 'package:meta/meta.dart'; -/// Defines which [TileUpdateEvent]s should cause which [TileUpdateEvent]s and -/// when +/// Restricts and limits [TileUpdateEvent]s (which are emitted 'by' [MapEvent]s), +/// which cause the tiles of the [TileLayer] to update (see below). /// -/// [TileUpdateTransformers] defines a default set of transformers. +/// When a [MapEvent] occurs, a [TileUpdateEvent] is also emitted (containing +/// that event) by the internals. However, it is sometimes unnecessary for all +/// [MapEvent]s to result in a [TileUpdateEvent], which can be expensive and +/// time-consuming. Alternatively, some [TileUpdateEvent]s may be grouped +/// together to reduce the rate at which tiles are updates. /// -/// If needed, build your own using [StreamTransformer.fromHandlers], adding -/// [TileUpdateEvent]s to the exposed [EventSink] if the event should cause an -/// update. +/// By default, [TileUpdateEvent]s both prune old tiles and load new tiles, as +/// necessary. However, this may not also be required. +/// +/// A [TileUpdateTransformer] transforms/converts the incoming stream of +/// [TileUpdateEvent]s (one per every [MapEvent]) into a 'new' stream of +/// [TileUpdateEvent]s, at any rate, with any desired pruning/loading +/// configuration. +/// +/// [TileUpdateTransformers] defines a built-in set of transformers. [TileLayer] +/// uses [TileUpdateTransformers.ignoreTapEvents] by default. +/// +/// If neccessary, you can build your own using [StreamTransformer], usually +/// [StreamTransformer.fromHandlers], adding events to the exposed [EventSink] +/// if the incoming event should cause an update. Most implementations should +/// check [TileUpdateEvent.wasTriggeredByTap] before emitting an event, and +/// avoid emitting an event if this is `true`. typedef TileUpdateTransformer = StreamTransformer; -/// Set of default [TileUpdateTransformer]s +/// Contains a set of built-in [TileUpdateTransformer]s +/// +/// See [TileUpdateTransformer] for more information. @immutable abstract class TileUpdateTransformers { /// Always* load/update/prune tiles on events @@ -25,8 +44,8 @@ abstract class TileUpdateTransformers { /// - [MapEventSecondaryTap] /// - [MapEventLongPress] /// - /// It is assumed (/guaranteed) that these events should not cause the map to - /// move, and therefore, tile changes are not required. + /// These events alone will not cause the camera to change position, and + /// therefore tile updates are necessary. /// {@endtemplate} /// /// Default transformer for [TileLayer]. @@ -38,6 +57,10 @@ abstract class TileUpdateTransformers { /// Throttle loading/updating/pruning tiles such that it only occurs once per /// [duration] /// + /// Also see [debounce]. + /// + /// --- + /// /// {@macro tut-ignore_tap} static TileUpdateTransformer throttle(Duration duration) { Timer? timer; @@ -75,4 +98,58 @@ abstract class TileUpdateTransformers { }, ); } + + /// Suppresses tile updates with less inter-event spacing than [duration] + /// + /// This may improve performance, and reduce the number of tile requests, but + /// at the expense of UX: new tiles will not be loaded until [duration] after + /// the final tile load event in a series. For example, a fling gesture will + /// not load new tiles during its animation, only at the end. Best used in + /// combination with the cancellable tile provider, for even more fine-tuned + /// optimization. + /// + /// Implementation follows that in + /// ['package:stream_transform'](https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/debounce.html). + /// + /// Also see [throttle]. + /// + /// --- + /// + /// {@macro tut-ignore_tap} + static TileUpdateTransformer debounce(Duration duration) { + Timer? timer; + TileUpdateEvent? soFar; + var hasPending = false; + var shouldClose = false; + + return StreamTransformer.fromHandlers( + handleData: (event, sink) { + if (event.wasTriggeredByTap()) return; + + void emit() { + sink.add(soFar!); + soFar = null; + hasPending = false; + } + + timer?.cancel(); + soFar = event; + hasPending = true; + + timer = Timer(duration, () { + emit(); + if (shouldClose) sink.close(); + timer = null; + }); + }, + handleDone: (sink) { + if (hasPending) { + shouldClose = true; + } else { + timer?.cancel(); + sink.close(); + } + }, + ); + } }