From 26aba5cba5ad8b9cdfb4efd4a732ce3896d3bca6 Mon Sep 17 00:00:00 2001 From: vinlet Date: Mon, 13 Apr 2026 20:48:02 +0300 Subject: [PATCH 1/8] test: add interactionOptions param to TestApp --- test/test_utils/test_app.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_utils/test_app.dart b/test/test_utils/test_app.dart index 6e56d9646..5459783c6 100644 --- a/test/test_utils/test_app.dart +++ b/test/test_utils/test_app.dart @@ -8,6 +8,7 @@ class TestApp extends StatelessWidget { const TestApp({ super.key, this.controller, + this.interactionOptions = const InteractionOptions(), this.markers = const [], this.polygons = const [], this.polylines = const [], @@ -15,6 +16,7 @@ class TestApp extends StatelessWidget { }); final MapController? controller; + final InteractionOptions interactionOptions; final List markers; final List polygons; final List polylines; @@ -31,14 +33,12 @@ class TestApp extends StatelessWidget { height: 200, child: FlutterMap( mapController: controller, - options: const MapOptions( - initialCenter: LatLng(45.5231, -122.6765), + options: MapOptions( + initialCenter: const LatLng(45.5231, -122.6765), + interactionOptions: interactionOptions, ), children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - tileProvider: TestTileProvider(), - ), + TileLayer(tileProvider: TestTileProvider()), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), if (circles.isNotEmpty) CircleLayer(circles: circles), From 2c9a434147c83444e5f11c5a71386d39db3acb8b Mon Sep 17 00:00:00 2001 From: vinlet Date: Fri, 10 Apr 2026 15:50:30 +0300 Subject: [PATCH 2/8] feat!: smooth scroll zooming based on MapLibre --- lib/flutter_map.dart | 1 + lib/src/gestures/map_interactive_viewer.dart | 31 +- lib/src/gestures/scroll_zoom.dart | 393 +++++++++++++++++++ lib/src/map/options/interaction.dart | 13 + lib/src/map/options/scroll_zoom.dart | 86 ++++ test/gestures/scroll_wheel_zoom_test.dart | 368 +++++++++++++++++ 6 files changed, 872 insertions(+), 20 deletions(-) create mode 100644 lib/src/gestures/scroll_zoom.dart create mode 100644 lib/src/map/options/scroll_zoom.dart create mode 100644 test/gestures/scroll_wheel_zoom_test.dart diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 3ae100b98..21fa967b3 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -66,4 +66,5 @@ export 'package:flutter_map/src/map/options/cursor_keyboard_rotation.dart'; export 'package:flutter_map/src/map/options/interaction.dart'; export 'package:flutter_map/src/map/options/keyboard.dart'; export 'package:flutter_map/src/map/options/options.dart'; +export 'package:flutter_map/src/map/options/scroll_zoom.dart'; export 'package:flutter_map/src/map/widget.dart'; diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 34a9c4bf2..0baaf6eee 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/gestures/scroll_zoom.dart'; import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; @@ -94,6 +95,8 @@ class MapInteractiveViewerState extends State late Animation _doubleTapZoomAnimation; late Animation _doubleTapCenterAnimation; + late final ScrollZoomHandler _scrollZoomHandler; + // 'ckr' = cursor/keyboard rotation final _ckrTriggered = ValueNotifier(false); double _ckrClickDegrees = 0; @@ -140,6 +143,11 @@ class MapInteractiveViewerState extends State ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); + _scrollZoomHandler = ScrollZoomHandler( + controller: widget.controller, + vsync: this, + ); + ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); @@ -164,6 +172,7 @@ class MapInteractiveViewerState extends State widget.controller.removeListener(onMapStateChange); _flingController.dispose(); _doubleTapController.dispose(); + _scrollZoomHandler.dispose(); _ckrTriggered.dispose(); ServicesBinding.instance.keyboard @@ -455,28 +464,10 @@ class MapInteractiveViewerState extends State GestureBinding.instance.pointerSignalResolver.register( pointerSignal, (pointerSignal) { - pointerSignal as PointerScrollEvent; - final minZoom = _options.minZoom ?? 0.0; - final maxZoom = _options.maxZoom ?? double.infinity; - final newZoom = (_camera.zoom - - pointerSignal.scrollDelta.dy * - _interactionOptions.scrollWheelVelocity) - .clamp(minZoom, maxZoom); - // Calculate offset of mouse cursor from viewport center - final newCenter = _camera.focusedZoomCenter( - pointerSignal.localPosition, - newZoom, - ); - _closeFlingAnimationController(MapEventSource.scrollWheel); _closeDoubleTapController(MapEventSource.scrollWheel); - - widget.controller.moveRaw( - newCenter, - newZoom, - hasGesture: true, - source: MapEventSource.scrollWheel, - ); + _scrollZoomHandler + .onPointerSignal(pointerSignal as PointerScrollEvent); }, ); } diff --git a/lib/src/gestures/scroll_zoom.dart b/lib/src/gestures/scroll_zoom.dart new file mode 100644 index 000000000..a4848a292 --- /dev/null +++ b/lib/src/gestures/scroll_zoom.dart @@ -0,0 +1,393 @@ +// The smooth scroll zoom algorithm (sigmoid scaling, device detection, +// C1-continuous easing) is adapted from MapLibre GL JS's ScrollZoomHandler. +// Most of the original comments are kept. +// Original source: https://github.com/maplibre/maplibre-gl-js +// File: src/ui/handler/scroll_zoom.ts +// +// Copyright (c) 2023, MapLibre contributors. +// Licensed under the BSD-3-Clause License. +// See https://github.com/maplibre/maplibre-gl-js/blob/main/LICENSE.txt +// +// _UnitBezier is adapted from https://github.com/mapbox/unitbezier, which is in +// turn adapted from WebKit. +// +// Copyright (C) 2008 Apple Inc. +// Licensed under the BSD-2-Clause License. +// See https://github.com/mapbox/unitbezier/blob/master/LICENSE + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +/// Cubic bezier curve, implicit first and last control points are (0,0) and (1,1). +class _UnitBezier { + final double _cx; + final double _bx; + final double _ax; + final double _cy; + final double _by; + final double _ay; + + _UnitBezier(double p1x, double p1y, double p2x, double p2y) + : _cx = 3.0 * p1x, + _bx = 3.0 * (p2x - p1x) - 3.0 * p1x, + _ax = 1.0 + 3.0 * p1x - 3.0 * p2x, + _cy = 3.0 * p1y, + _by = 3.0 * (p2y - p1y) - 3.0 * p1y, + _ay = 1.0 + 3.0 * p1y - 3.0 * p2y; + + // `ax t^3 + bx t^2 + cx t` expanded using Horner's rule. + double _sampleCurveX(double t) => ((_ax * t + _bx) * t + _cx) * t; + double _sampleCurveY(double t) => ((_ay * t + _by) * t + _cy) * t; + double _sampleCurveDerivativeX(double t) => + (3.0 * _ax * t + 2.0 * _bx) * t + _cx; + + double _solveCurveX(double x) { + const epsilon = 1e-6; + + var t = x; + + // First try a few iterations of Newton's method - normally very fast. + for (var i = 0; i < 8; i++) { + final x2 = _sampleCurveX(t) - x; + if (x2.abs() < epsilon) return t; + + final d2 = _sampleCurveDerivativeX(t); + if (d2.abs() < epsilon) break; + + t -= x2 / d2; + } + + // Fall back to the bisection method for reliability. + var t0 = 0.0; + var t1 = 1.0; + t = x; + + for (var i = 0; i < 20; i++) { + final x2 = _sampleCurveX(t); + if ((x2 - x).abs() < epsilon) return t; + + if (x > x2) { + t0 = t; + } else { + t1 = t; + } + + t = (t0 - t1) * 0.5 + t0; + } + return t; + } + + double solve(double x) => _sampleCurveY(_solveCurveX(x)); +} + +typedef _EasingFn = double Function(double t); + +_EasingFn _bezier(double p1x, double p1y, double p2x, double p2y) { + final b = _UnitBezier(p1x, p1y, p2x, p2y); + return b.solve; +} + +final _EasingFn _defaultEasing = _bezier(0.25, 0.1, 0.25, 1); + +enum _ScrollType { wheel, trackpad } + +/// Scroll zoom handler ported from MapLibre GL JS. +/// +/// Mouse wheel events produce discrete ticks that get smoothed with +/// C¹-continuous bezier easing curves. Trackpad events are applied directly +/// since the hardware already provides fine-grained continuous input. +/// +/// When [ScrollZoomOptions.smoothZooming] is `false`, falls back to +/// snapping immediately to the new zoom level. +class ScrollZoomHandler { + final MapControllerImpl _controller; + final TickerProvider _vsync; + + Ticker? _ticker; + bool _tickerActive = false; + + // deltaY value for mouse wheel identification + static const double _wheelZoomDelta = 4.000244140625; + _ScrollType? _type; + double _lastValue = 0; + int _lastWheelEventTime = 0; + Timer? _timeout; + Offset? _pendingEventPosition; + + double _delta = 0; + + double? _startZoom; + double? _targetZoom; + Offset _cursorPosition = Offset.zero; + + _EasingFn? _easing; + _PrevEase? _prevEase; + + // upper bound on how much we scale the map in any single render frame; this + // is used to limit zoom rate in the case of very fast scrolling + static const double _maxScalePerFrame = 2; + + // Minimum time difference value to be used for calculating zoom easing in renderFrame(); + // this is used to normalise very fast (typically 0 to 0.3ms) repeating lastWheelEventTimeDiff + // values generated by Chromium based browsers during fast scrolling wheel events. + static const int _wheelEventTimeDiffAdjustment = 5; + + /// Create a new [ScrollZoomHandler]. + ScrollZoomHandler({ + required MapControllerImpl controller, + required TickerProvider vsync, + }) : _controller = controller, + _vsync = vsync; + + MapCamera get _camera => _controller.camera; + MapOptions get _options => _controller.options; + InteractionOptions get _interactionOptions => _options.interactionOptions; + ScrollZoomOptions get _zoomOptions => _interactionOptions.scrollZoomOptions; + int get _animationDurationMs => _zoomOptions.animationDuration.inMilliseconds; + + /// Dispose animations and timers. + void dispose() { + _timeout?.cancel(); + _ticker?.dispose(); + _ticker = null; + } + + /// Handle a pointer scroll event. + void onPointerSignal(PointerScrollEvent event) { + if (!InteractiveFlag.hasScrollWheelZoom(_interactionOptions.flags)) { + return; + } + if (event.scrollDelta.dy == 0) return; + + if (_zoomOptions.smoothZooming) { + _doSmoothZoom(event); + } else { + _doSnapZoom(event); + } + } + + /// Snap zoom: apply zoom change immediately. + void _doSnapZoom(PointerScrollEvent event) { + final minZoom = _options.minZoom ?? 0.0; + final maxZoom = _options.maxZoom ?? double.infinity; + final newZoom = (_camera.zoom - + event.scrollDelta.dy * _interactionOptions.scrollWheelVelocity) + .clamp(minZoom, maxZoom); + final newCenter = _camera.focusedZoomCenter( + event.localPosition, + newZoom, + ); + _controller.moveRaw( + newCenter, + newZoom, + hasGesture: true, + source: MapEventSource.scrollWheel, + ); + } + + /// Smooth zoom: accumulate delta and animate. + void _doSmoothZoom(PointerScrollEvent event) { + final value = event.scrollDelta.dy; + final currentTime = currentTimestamp().millisecondsSinceEpoch; + final timeDelta = currentTime - _lastWheelEventTime; + + _lastWheelEventTime = currentTime; + _cursorPosition = event.localPosition; + + if (value != 0 && (value % _wheelZoomDelta) == 0) { + // This one is definitely a mouse wheel event. + _type = _ScrollType.wheel; + } else if (value != 0 && value.abs() < 4) { + // This one is definitely a trackpad event because it is so small. + _type = _ScrollType.trackpad; + if (_timeout != null) { + _timeout!.cancel(); + _timeout = null; + _delta -= _lastValue; + } + } else if (timeDelta > 400) { + // New scroll action, unknown device type. Delay 40ms to see if more + // events arrive (which would indicate trackpad). + _type = null; + _lastValue = value; + _pendingEventPosition = event.localPosition; + _timeout = Timer(const Duration(milliseconds: 40), () { + _timeout = null; + _type = _ScrollType.wheel; + _lastWheelEventTime = currentTimestamp().millisecondsSinceEpoch; + _delta -= _lastValue; + _cursorPosition = _pendingEventPosition!; + _ensureAnimating(); + }); + return; + } else if (_type == null) { + // This is a repeating event, but we don't know the type of event just yet. + // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode. + _type = ((timeDelta * value).abs() < 200) + ? _ScrollType.trackpad + : _ScrollType.wheel; + + // Make sure our delayed event isn't fired again, because we accumulate + // the previous event (which was less than 40ms ago) into this event. + if (_timeout != null) { + _timeout!.cancel(); + _timeout = null; + _delta -= _lastValue; + } + } + + // Only fire the callback if we actually know what type of scrolling device the user uses. + if (_type != null) { + _delta -= value; + _ensureAnimating(); + } + } + + void _ensureAnimating() { + if (_tickerActive) return; + + _ticker ??= _vsync.createTicker(_onTick); + if (!_ticker!.isActive) { + _ticker!.start(); + } + _tickerActive = true; + } + + void _onTick(Duration elapsed) { + _renderFrame(); + } + + void _renderFrame() { + final minZoom = _options.minZoom ?? 0.0; + final maxZoom = _options.maxZoom ?? double.infinity; + + // if we've had scroll events since the last render frame, consume the + // accumulated delta, and update the target zoom level accordingly + if (_delta != 0) { + // For trackpad events and single mouse wheel ticks, use the default zoom rate + final zoomRate = + (_type == _ScrollType.wheel && _delta.abs() > _wheelZoomDelta) + ? _zoomOptions.wheelZoomRate + : _zoomOptions.trackpadZoomRate; + + // Scale by sigmoid of scroll wheel delta so the map responds to small scrolls and compresses large scrolls + var scale = + _maxScalePerFrame / (1 + math.exp(-(_delta * zoomRate).abs())); + + if (_delta < 0 && scale != 0) { + scale = 1 / scale; + } + + final fromZoom = _targetZoom ?? _camera.zoom; + final fromScale = math.pow(2, fromZoom); + final newZoom = math.log(fromScale * scale) / math.ln2; + _targetZoom = newZoom.clamp(minZoom, maxZoom); + + // if this is a mouse wheel, refresh the starting zoom and easing + // function we're using to smooth out the zooming between wheel events + if (_type == _ScrollType.wheel) { + _startZoom = _camera.zoom; + _easing = _smoothOutEasing(_animationDurationMs); + } + + _delta = 0; + } + + final targetZoom = _targetZoom ?? _camera.zoom; + double zoom; + bool finished; + + if (_type == _ScrollType.wheel && _startZoom != null && _easing != null) { + // Smooth interpolation for mouse wheel + final timeSinceLastWheel = + currentTimestamp().millisecondsSinceEpoch - _lastWheelEventTime; + final t = ((timeSinceLastWheel + _wheelEventTimeDiffAdjustment) / + _animationDurationMs) + .clamp(0.0, 1.0); + final k = _easing!(t); + zoom = (1.0 - k) * _startZoom! + k * targetZoom; + finished = t >= 1.0; + } else { + // Trackpad: apply directly (hardware provides smooth input) + zoom = targetZoom; + finished = true; + } + + zoom = zoom.clamp(minZoom, maxZoom); + + if (zoom != _camera.zoom) { + final newCenter = _camera.focusedZoomCenter( + _cursorPosition, + zoom, + ); + _controller.moveRaw( + newCenter, + zoom, + hasGesture: true, + source: MapEventSource.scrollWheel, + ); + } + + if (finished) { + _ticker?.stop(); + _tickerActive = false; + _resetState(); + } + } + + void _resetState() { + _startZoom = null; + _targetZoom = null; + _prevEase = null; + _easing = null; + } + + /// Create a C¹-continuous bezier easing function. When a new wheel event + /// arrives during an ongoing animation, the new curve starts at the same + /// velocity the previous curve had, producing smooth chaining. + _EasingFn _smoothOutEasing(int duration) { + var easing = _defaultEasing; + + if (_prevEase != null) { + final currentTime = currentTimestamp().millisecondsSinceEpoch; + final t = (currentTime - _prevEase!.start) / _prevEase!.duration; + final prevEasing = _prevEase!.easing; + final speed = prevEasing((t + 0.01).clamp(0.0, 1.0)) - + prevEasing(t.clamp(0.0, 1.0)); + + // Quick hack to make new bezier that is continuous with last + final x = 0.27 / math.sqrt(speed * speed + 0.0001) * 0.01; + final y = math.sqrt((0.27 * 0.27 - x * x).clamp(0.0, double.infinity)); + easing = _bezier(x, y, 0.25, 1); + } + + _prevEase = _PrevEase( + start: currentTimestamp().millisecondsSinceEpoch, + duration: duration.toDouble(), + easing: easing, + ); + + return easing; + } + + /// Get the current time. Made public to make timing-dependent code testable. + @visibleForTesting + static DateTime Function() currentTimestamp = DateTime.now; +} + +class _PrevEase { + final int start; + final double duration; + final _EasingFn easing; + + _PrevEase({ + required this.start, + required this.duration, + required this.easing, + }); +} diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index d0e46d995..0f750c3a8 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -65,8 +65,18 @@ class InteractionOptions { /// The used velocity how fast the map should zoom in or out by scrolling /// with the scroll wheel of a mouse. + /// + /// Only used when [scrollZoomOptions] has + /// [ScrollZoomOptions.smoothZooming] set to `false`. In smooth zoom + /// mode, use [ScrollZoomOptions.wheelZoomRate] and + /// [ScrollZoomOptions.trackpadZoomRate] instead. final double scrollWheelVelocity; + /// Options to configure scroll wheel/trackpad zoom behavior. + /// + /// By default, scroll wheel zoom uses smooth animated zooming. + final ScrollZoomOptions scrollZoomOptions; + /// Calculates the zoom difference to apply to the initial zoom level when a /// user is performing a double-tap drag zoom gesture /// @@ -134,6 +144,7 @@ class InteractionOptions { this.pinchMoveWinGestures = MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, this.scrollWheelVelocity = 0.005, + this.scrollZoomOptions = const ScrollZoomOptions(), this.doubleTapDragZoomChangeCalculator = defaultDoubleTapDragZoomChangeCalculator, this.doubleTapZoomDuration = const Duration(milliseconds: 200), @@ -181,6 +192,7 @@ class InteractionOptions { pinchMoveThreshold == other.pinchMoveThreshold && pinchMoveWinGestures == other.pinchMoveWinGestures && scrollWheelVelocity == other.scrollWheelVelocity && + scrollZoomOptions == other.scrollZoomOptions && doubleTapDragZoomChangeCalculator == other.doubleTapDragZoomChangeCalculator && doubleTapZoomDuration == other.doubleTapZoomDuration && @@ -200,6 +212,7 @@ class InteractionOptions { pinchMoveThreshold, pinchMoveWinGestures, scrollWheelVelocity, + scrollZoomOptions, doubleTapDragZoomChangeCalculator, doubleTapZoomDuration, doubleTapZoomCurve, diff --git a/lib/src/map/options/scroll_zoom.dart b/lib/src/map/options/scroll_zoom.dart new file mode 100644 index 000000000..d425b13c3 --- /dev/null +++ b/lib/src/map/options/scroll_zoom.dart @@ -0,0 +1,86 @@ +import 'package:meta/meta.dart'; + +/// Options to configure scroll zoom behavior. +/// +/// By default, scroll zoom uses smooth animated zooming inspired by +/// MapLibre GL JS. This can be disabled by setting [smoothZooming] to `false`, +/// which reverts to the old behavior of snapping immediately to the new +/// zoom level. +@immutable +class ScrollZoomOptions { + /// Whether to use smooth animated zooming for mouse wheel events. + /// + /// When `true` (default), each mouse wheel tick triggers a short eased + /// animation to the new zoom level. Rapid successive wheel ticks chain + /// smoothly with velocity-continuous bezier curves. + /// + /// When `false`, zooming snaps immediately to the new zoom level on each + /// wheel event, matching the pre-v8 behavior. + /// + /// Trackpad events are always applied directly regardless of this setting, + /// since trackpad hardware already provides fine-grained continuous input. + final bool smoothZooming; + + /// Controls zoom sensitivity for mouse wheel events in smooth mode. + /// + /// Lower values = slower zoom per wheel tick. Higher values = faster. + /// + /// Only used when [smoothZooming] is `true`. + /// + /// Defaults to `1 / 450`. + final double wheelZoomRate; + + /// Controls zoom sensitivity for trackpad events. + /// + /// Lower values = slower zoom per trackpad gesture unit. Higher values = + /// faster. + /// + /// Only used when [smoothZooming] is `true`. + /// + /// Defaults to `1 / 100`. + final double trackpadZoomRate; + + /// Duration of the easing animation for each mouse wheel tick. + /// + /// Each wheel tick triggers an animation of this duration. When multiple + /// ticks arrive before the animation completes, the animations chain + /// smoothly. + /// + /// Only used when [smoothZooming] is `true`. + /// + /// Defaults to 200ms. + final Duration animationDuration; + + /// Create scroll zoom options. + const ScrollZoomOptions({ + this.smoothZooming = true, + this.wheelZoomRate = 1 / 450, + this.trackpadZoomRate = 1 / 100, + this.animationDuration = const Duration(milliseconds: 200), + }) : assert(wheelZoomRate > 0, '`wheelZoomRate` must be positive'), + assert(trackpadZoomRate > 0, '`trackpadZoomRate` must be positive'); + + /// Options that disable smooth zooming, reverting to the legacy snap + /// behavior. + const ScrollZoomOptions.snapping() + : smoothZooming = false, + wheelZoomRate = 1 / 450, + trackpadZoomRate = 1 / 100, + animationDuration = const Duration(milliseconds: 200); + + @override + bool operator ==(Object other) => + other is ScrollZoomOptions && + smoothZooming == other.smoothZooming && + wheelZoomRate == other.wheelZoomRate && + trackpadZoomRate == other.trackpadZoomRate && + animationDuration == other.animationDuration; + + @override + int get hashCode => Object.hash( + smoothZooming, + wheelZoomRate, + trackpadZoomRate, + animationDuration, + ); +} diff --git a/test/gestures/scroll_wheel_zoom_test.dart b/test/gestures/scroll_wheel_zoom_test.dart new file mode 100644 index 000000000..909492dea --- /dev/null +++ b/test/gestures/scroll_wheel_zoom_test.dart @@ -0,0 +1,368 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/gestures/scroll_zoom.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../test_utils/test_app.dart'; +import '../test_utils/test_tile_provider.dart'; + +/// Sends a scroll event to the center of the FlutterMap widget. +Future _scroll(WidgetTester tester, {required double dy}) async { + final center = tester.getCenter(find.byType(FlutterMap)); + await tester.sendEventToBinding( + PointerScrollEvent(position: center, scrollDelta: Offset(0, dy)), + ); +} + +/// Some of these tests inject `TestWidgetsFlutterBinding.instance.clock.now` +/// into [ScrollZoomHandler]. [ScrollZoomHandler] uses +/// `DateTime.now()` in normal operation, which advances in real-time and +/// doesn't care about pumps, which would make testing impossible here. +/// Also, you can't put it into `setUp()` or `setUpAll()` because you can't +/// access `TestWidgetsFlutterBinding.instance.clock` there. +void main() { + group('ScrollZoomOptions', () { + test('default values', () { + const options = ScrollZoomOptions(); + expect(options.smoothZooming, isTrue); + expect(options.wheelZoomRate, 1 / 450); + expect(options.trackpadZoomRate, 1 / 100); + expect(options.animationDuration, const Duration(milliseconds: 200)); + }); + + test('snapping constructor disables smooth zooming', () { + const options = ScrollZoomOptions.snapping(); + expect(options.smoothZooming, isFalse); + }); + + test('equality', () { + const a = ScrollZoomOptions(); + const b = ScrollZoomOptions(); + const c = ScrollZoomOptions(wheelZoomRate: 1 / 200); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + group('assertions', () { + test('rejects non-positive wheelZoomRate', () { + expect( + () => ScrollZoomOptions(wheelZoomRate: 0), + throwsA(isA()), + ); + expect( + () => ScrollZoomOptions(wheelZoomRate: -1), + throwsA(isA()), + ); + }); + + test('rejects non-positive trackpadZoomRate', () { + expect( + () => ScrollZoomOptions(trackpadZoomRate: 0), + throwsA(isA()), + ); + }); + + test('rejects non-positive trackpadZoomRate (negative)', () { + expect( + () => ScrollZoomOptions(trackpadZoomRate: -0.5), + throwsA(isA()), + ); + }); + }); + }); + + group('Scroll zoom - snap mode', () { + testWidgets('zooms in immediately on scroll up', (tester) async { + final controller = MapController(); + await tester.pumpWidget(TestApp( + controller: controller, + interactionOptions: const InteractionOptions( + scrollZoomOptions: ScrollZoomOptions.snapping(), + ), + )); + + final initialZoom = controller.camera.zoom; + + await _scroll(tester, dy: -100); + await tester.pump(); + final newZoom = controller.camera.zoom; + expect(newZoom, greaterThan(initialZoom)); + + // Make sure zoom doesn't change after 1 more frame + await tester.pump(); + expect(controller.camera.zoom, equals(newZoom)); + }); + + testWidgets('zooms out immediately on scroll down', (tester) async { + final controller = MapController(); + await tester.pumpWidget(TestApp( + controller: controller, + interactionOptions: const InteractionOptions( + scrollZoomOptions: ScrollZoomOptions.snapping(), + ), + )); + + final initialZoom = controller.camera.zoom; + + await _scroll(tester, dy: 100); + await tester.pump(); + final newZoom = controller.camera.zoom; + expect(newZoom, lessThan(initialZoom)); + + // Make sure zoom doesn't change after 1 more frame + await tester.pump(); + expect(controller.camera.zoom, equals(newZoom)); + }); + }); + + group('Scroll zoom - smooth mode', () { + testWidgets('zooms in with animation on single mouse wheel scroll up', + (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp(controller: controller)); + + final initialZoom = controller.camera.zoom; + await _scroll(tester, dy: -100); + + await tester.pump(const Duration(milliseconds: 20)); + expect(controller.camera.zoom, equals(initialZoom)); + + // After 40 milliseconds, this scroll should be detected as a scroll wheel + // and the animation should have started. + await tester.pump(const Duration(milliseconds: 20)); + final midZoom = controller.camera.zoom; + expect(midZoom, greaterThan(initialZoom)); + + // Animation should end, zoom should be greater still. + await tester.pumpAndSettle(); + expect(controller.camera.zoom, greaterThan(midZoom)); + }); + + testWidgets('zooms out with animation on single mouse wheel scroll down', + (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp(controller: controller)); + + final initialZoom = controller.camera.zoom; + await _scroll(tester, dy: 100); + + await tester.pump(const Duration(milliseconds: 20)); + expect(controller.camera.zoom, equals(initialZoom)); + + // After 40 milliseconds, this scroll should be detected as a scroll wheel + // and the animation should have started. + await tester.pump(const Duration(milliseconds: 20)); + final midZoom = controller.camera.zoom; + expect(midZoom, lessThan(initialZoom)); + + // Animation should end, zoom should be lesser still. + await tester.pumpAndSettle(); + expect(controller.camera.zoom, lessThan(midZoom)); + }); + + testWidgets('zooms in without animation on single trackpad scroll up', + (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp(controller: controller)); + + final initialZoom = controller.camera.zoom; + await _scroll(tester, dy: -3.99); + + // This scroll should have been immediately detected as a trackpad and + // should zoom without animation. + await tester.pump(); + final newZoom = controller.camera.zoom; + expect(newZoom, greaterThan(initialZoom)); + + await tester.pump(const Duration(milliseconds: 1000)); + expect(controller.camera.zoom, equals(newZoom)); + }); + + testWidgets('zooms out without animation on multiple trackpad scroll up', + (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp(controller: controller)); + + final initialZoom = controller.camera.zoom; + + // The first scroll has high delta, but the next one comes sufficiently + // quick so it should still be detected as a trackpad. Honestly, this + // is kind of an extreme scenario that will most likely never happen, but + // a test still needs to cover this case (and adapted later if necessary) + await _scroll(tester, dy: 120); + await tester.pump(const Duration(milliseconds: 39)); + await _scroll(tester, dy: 3); + await tester.pump(); + + final newZoom = controller.camera.zoom; + expect(newZoom, lessThan(initialZoom)); + + // This scroll should have been detected as a trackpad and there should be + // no animation. + await tester.pump(const Duration(milliseconds: 1000)); + expect(controller.camera.zoom, equals(newZoom)); + }); + + testWidgets('respects min/max zoom', (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + // TODO: consider modifying TestApp to accept a child (FlutterMap) as parameter + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 200, + child: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: LatLng(45.5231, -122.6765), + initialZoom: 4, + minZoom: 2, + maxZoom: 10.5, + ), + children: [TileLayer(tileProvider: TestTileProvider())], + ), + ), + ), + ), + )); + + // Scroll up a lot + for (var i = 0; i < 100; i++) { + await _scroll(tester, dy: -100); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + + // Should not exceed maxZoom + expect(controller.camera.zoom, lessThanOrEqualTo(10.5)); + + // Scroll down a lot + for (var i = 0; i < 100; i++) { + await _scroll(tester, dy: 100); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + + // Should not go below minZoom + expect(controller.camera.zoom, greaterThanOrEqualTo(2)); + }); + + testWidgets('custom animation duration is respected', (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp( + controller: controller, + interactionOptions: const InteractionOptions( + scrollZoomOptions: ScrollZoomOptions( + animationDuration: Duration(milliseconds: 1000), + ), + ), + )); + + final initialZoom = controller.camera.zoom; + await _scroll(tester, dy: -100); + + // Wait for scroll wheel detection + less than animation duration + await tester.pump(const Duration(milliseconds: 40 + 400)); + final midZoom = controller.camera.zoom; + expect(midZoom, greaterThan(initialZoom)); + + // Wait until exactly the animation's end + await tester.pump(const Duration(milliseconds: 600)); + final finalZoom = controller.camera.zoom; + expect(finalZoom, greaterThan(midZoom)); + + // Zoom should not change anymore + await tester.pump(const Duration(milliseconds: 1000)); + final zoom = controller.camera.zoom; + expect(zoom, equals(finalZoom)); + }); + }); + + group('Scroll zoom - zoom anchor', () { + testWidgets('zooms toward cursor position', (tester) async { + ScrollZoomHandler.currentTimestamp = + TestWidgetsFlutterBinding.instance.clock.now; + final controller = MapController(); + await tester.pumpWidget(TestApp(controller: controller)); + + final initialZoom = controller.camera.zoom; + + final mapRect = tester.getRect(find.byType(FlutterMap)); + // Put cursor at 1/4 the size of the map + final screenPoint = (mapRect.topLeft * 3 + mapRect.bottomRight) / 4; + final mapScreenPoint = screenPoint - mapRect.topLeft; + final focusLatLng = + controller.camera.screenOffsetToLatLng(mapScreenPoint); + + await tester.sendEventToBinding( + PointerScrollEvent( + position: screenPoint, + scrollDelta: const Offset(0, -100), + ), + ); + await tester.pump(const Duration(milliseconds: 1000)); + + expect(controller.camera.zoom, greaterThan(initialZoom)); + + final newFocusLatLng = + controller.camera.screenOffsetToLatLng(mapScreenPoint); + + expect(focusLatLng.latitude, moreOrLessEquals(newFocusLatLng.latitude)); + expect(focusLatLng.longitude, moreOrLessEquals(newFocusLatLng.longitude)); + }); + }); + + group('Scroll zoom - events', () { + testWidgets('emits MapEventScrollZoom', (tester) async { + final controller = MapController(); + final events = []; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 200, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5231, -122.6765), + initialZoom: 10, + onMapEvent: events.add, + interactionOptions: const InteractionOptions( + scrollZoomOptions: ScrollZoomOptions.snapping(), + ), + ), + children: [TileLayer(tileProvider: TestTileProvider())], + ), + ), + ), + ), + )); + + await _scroll(tester, dy: -100); + await tester.pump(); + + expect( + events.whereType(), + isNotEmpty, + reason: 'Should emit MapEventScrollZoom on scroll', + ); + }); + }); +} From 38eef5797c290788829f0a54e09258c51f26712a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 1 Jul 2026 17:39:22 +0100 Subject: [PATCH 3/8] Change default `wheelZoomRate` Add smooth scrolling toggle to demo page --- example/lib/pages/interactive_test_page.dart | 455 ++++++++++--------- lib/src/map/options/scroll_zoom.dart | 6 +- 2 files changed, 245 insertions(+), 216 deletions(-) diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart index 75a378138..994732caa 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/interactive_test_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; class InteractiveFlagsPage extends StatefulWidget { - static const String route = '/interactive_flags_page'; + static const String route = '/interactive_flags'; const InteractiveFlagsPage({super.key}); @@ -14,8 +14,11 @@ class InteractiveFlagsPage extends StatefulWidget { } class _InteractiveFlagsPageState extends State { - final flagsSet = - ValueNotifier(InteractiveFlag.drag | InteractiveFlag.pinchZoom); + final flagsSet = ValueNotifier( + InteractiveFlag.drag | + InteractiveFlag.pinchZoom | + InteractiveFlag.scrollWheelZoom, + ); bool keyboardCursorRotate = false; bool keyboardArrowsMove = false; @@ -23,240 +26,266 @@ class _InteractiveFlagsPageState extends State { bool keyboardQERotate = false; bool keyboardRFZoom = false; + bool useSmoothScrollZooming = true; + MapEvent? _latestEvent; + @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; return Scaffold( appBar: AppBar(title: const Text('Interactive Flags')), drawer: const MenuDrawer(InteractiveFlagsPage.route), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Flex( - direction: screenWidth >= 600 ? Axis.horizontal : Axis.vertical, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - const Text( - 'Move/Pan', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InteractiveFlagCheckbox( - name: 'Drag', - flag: InteractiveFlag.drag, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - InteractiveFlagCheckbox( - name: 'Fling', - flag: InteractiveFlag.flingAnimation, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - InteractiveFlagCheckbox( - name: 'Pinch', - flag: InteractiveFlag.pinchMove, - flagsSet: flagsSet, + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: double.infinity, + child: Wrap( + spacing: 32, + runSpacing: 16, + alignment: WrapAlignment.spaceEvenly, + runAlignment: WrapAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Text( + 'Move/Pan', + style: TextStyle( + fontWeight: FontWeight.bold, ), - const SizedBox(width: 8), - Column( - children: [ - Checkbox.adaptive( - value: keyboardArrowsMove, - onChanged: (enabled) => setState( - () => keyboardArrowsMove = enabled!, + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + InteractiveFlagCheckbox( + name: 'Drag', + flag: InteractiveFlag.drag, + flagsSet: flagsSet, + ), + InteractiveFlagCheckbox( + name: 'Fling', + flag: InteractiveFlag.flingAnimation, + flagsSet: flagsSet, + ), + InteractiveFlagCheckbox( + name: 'Pinch', + flag: InteractiveFlag.pinchMove, + flagsSet: flagsSet, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: keyboardArrowsMove, + onChanged: (enabled) => setState( + () => keyboardArrowsMove = enabled!, + ), ), - ), - const Text( - 'Keyboard\nArrows', - textAlign: TextAlign.center, - ), - ], - ), - const SizedBox(width: 8), - Column( - children: [ - Checkbox.adaptive( - value: keyboardWASDMove, - onChanged: (enabled) => setState( - () => keyboardWASDMove = enabled!, + const Text( + 'Keyboard\nArrows', + textAlign: TextAlign.center, + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: keyboardWASDMove, + onChanged: (enabled) => setState( + () => keyboardWASDMove = enabled!, + ), ), - ), - const Text( - 'Keyboard\nW/A/S/D', - textAlign: TextAlign.center, - ), - ], + const Text( + 'Keyboard\nW/A/S/D', + textAlign: TextAlign.center, + ), + ], + ), + ], + ) + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Text( + 'Zoom', + style: TextStyle( + fontWeight: FontWeight.bold, ), - ], - ) - ], - ), - const SizedBox(width: 12), - Column( - children: [ - const Text( - 'Zoom', - style: TextStyle( - fontWeight: FontWeight.bold, ), - ), - const SizedBox(height: 6), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InteractiveFlagCheckbox( - name: 'Pinch', - flag: InteractiveFlag.pinchZoom, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - InteractiveFlagCheckbox( - name: 'Scroll', - flag: InteractiveFlag.scrollWheelZoom, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - InteractiveFlagCheckbox( - name: 'Double tap', - flag: InteractiveFlag.doubleTapZoom, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - InteractiveFlagCheckbox( - name: '+ drag', - flag: InteractiveFlag.doubleTapDragZoom, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - Column( - children: [ - Checkbox.adaptive( - value: keyboardRFZoom, - onChanged: (enabled) => setState( - () => keyboardRFZoom = enabled!, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + InteractiveFlagCheckbox( + name: 'Pinch', + flag: InteractiveFlag.pinchZoom, + flagsSet: flagsSet, + ), + InteractiveFlagCheckbox( + name: 'Scroll', + flag: InteractiveFlag.scrollWheelZoom, + flagsSet: flagsSet, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: useSmoothScrollZooming, + onChanged: (enabled) => setState( + () => useSmoothScrollZooming = enabled!, + ), + ), + const Text( + '(smooth)', + textAlign: TextAlign.center, + ), + ], + ), + InteractiveFlagCheckbox( + name: 'Double tap', + flag: InteractiveFlag.doubleTapZoom, + flagsSet: flagsSet, + ), + InteractiveFlagCheckbox( + name: '+ drag', + flag: InteractiveFlag.doubleTapDragZoom, + flagsSet: flagsSet, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: keyboardRFZoom, + onChanged: (enabled) => setState( + () => keyboardRFZoom = enabled!, + ), ), - ), - const Text( - 'Keyboard\nR/F', - textAlign: TextAlign.center, - ), - ], + const Text( + 'Keyboard\nR/F', + textAlign: TextAlign.center, + ), + ], + ), + ], + ) + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Text( + 'Rotate', + style: TextStyle( + fontWeight: FontWeight.bold, ), - ], - ) - ], - ), - const SizedBox(width: 12), - Column( - children: [ - const Text( - 'Rotate', - style: TextStyle( - fontWeight: FontWeight.bold, ), - ), - const SizedBox(height: 6), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InteractiveFlagCheckbox( - name: 'Twist', - flag: InteractiveFlag.rotate, - flagsSet: flagsSet, - ), - const SizedBox(width: 8), - Column( - children: [ - Checkbox.adaptive( - value: keyboardCursorRotate, - onChanged: (enabled) => setState( - () => keyboardCursorRotate = enabled!, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + InteractiveFlagCheckbox( + name: 'Twist', + flag: InteractiveFlag.rotate, + flagsSet: flagsSet, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: keyboardCursorRotate, + onChanged: (enabled) => setState( + () => keyboardCursorRotate = enabled!, + ), ), - ), - const Text( - 'Cursor\n& CTRL', - textAlign: TextAlign.center, - ), - ], - ), - const SizedBox(width: 8), - Column( - children: [ - Checkbox.adaptive( - value: keyboardQERotate, - onChanged: (enabled) => setState( - () => keyboardQERotate = enabled!, + const Text( + 'Cursor\n& CTRL', + textAlign: TextAlign.center, ), - ), - const Text( - 'Keyboard\nQ/E', - textAlign: TextAlign.center, - ), - ], - ), - ], - ) - ], - ), - ], + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: keyboardQERotate, + onChanged: (enabled) => setState( + () => keyboardQERotate = enabled!, + ), + ), + const Text( + 'Keyboard\nQ/E', + textAlign: TextAlign.center, + ), + ], + ), + ], + ) + ], + ), + ], + ), ), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Center( - child: Text( - 'Current event: ${_eventName(_latestEvent)}\n' - 'Source: ${_latestEvent?.source.name ?? "none"}', - textAlign: TextAlign.center, - ), + ), + const Divider( + indent: 16, + endIndent: 16, + ), + Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: Text( + 'Current event: ${_eventName(_latestEvent)}\n' + 'Source: ${_latestEvent?.source.name ?? "none"}', + textAlign: TextAlign.center, ), ), - Expanded( - child: ValueListenableBuilder( - valueListenable: flagsSet, - builder: (context, value, child) => FlutterMap( - options: MapOptions( - onMapEvent: (evt) => setState(() => _latestEvent = evt), - initialCenter: const LatLng(51.5, -0.09), - initialZoom: 11, - interactionOptions: InteractionOptions( - flags: value, - cursorKeyboardRotationOptions: - CursorKeyboardRotationOptions( - isKeyTrigger: (key) => - keyboardCursorRotate && - CursorKeyboardRotationOptions.defaultTriggerKeys - .contains(key), - ), - keyboardOptions: KeyboardOptions( - enableArrowKeysPanning: keyboardArrowsMove, - enableWASDPanning: keyboardWASDMove, - enableQERotating: keyboardQERotate, - enableRFZooming: keyboardRFZoom, - ), + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: flagsSet, + builder: (context, value, child) => FlutterMap( + options: MapOptions( + onMapEvent: (evt) => setState(() => _latestEvent = evt), + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 11, + interactionOptions: InteractionOptions( + flags: value, + scrollZoomOptions: ScrollZoomOptions( + smoothZooming: useSmoothScrollZooming, + ), + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions( + isKeyTrigger: (key) => + keyboardCursorRotate && + CursorKeyboardRotationOptions.defaultTriggerKeys + .contains(key), + ), + keyboardOptions: KeyboardOptions( + enableArrowKeysPanning: keyboardArrowsMove, + enableWASDPanning: keyboardWASDMove, + enableQERotating: keyboardQERotate, + enableRFZooming: keyboardRFZoom, ), ), - children: [child!], ), - child: openStreetMapTileLayer, + children: [child!], ), + child: openStreetMapTileLayer, ), - ], - ), + ), + ], ), ); } diff --git a/lib/src/map/options/scroll_zoom.dart b/lib/src/map/options/scroll_zoom.dart index d425b13c3..02858a166 100644 --- a/lib/src/map/options/scroll_zoom.dart +++ b/lib/src/map/options/scroll_zoom.dart @@ -15,7 +15,7 @@ class ScrollZoomOptions { /// smoothly with velocity-continuous bezier curves. /// /// When `false`, zooming snaps immediately to the new zoom level on each - /// wheel event, matching the pre-v8 behavior. + /// wheel event, matching the pre-v8.4 behavior. /// /// Trackpad events are always applied directly regardless of this setting, /// since trackpad hardware already provides fine-grained continuous input. @@ -27,7 +27,7 @@ class ScrollZoomOptions { /// /// Only used when [smoothZooming] is `true`. /// - /// Defaults to `1 / 450`. + /// Defaults to `1 / 350`. final double wheelZoomRate; /// Controls zoom sensitivity for trackpad events. @@ -54,7 +54,7 @@ class ScrollZoomOptions { /// Create scroll zoom options. const ScrollZoomOptions({ this.smoothZooming = true, - this.wheelZoomRate = 1 / 450, + this.wheelZoomRate = 1 / 350, this.trackpadZoomRate = 1 / 100, this.animationDuration = const Duration(milliseconds: 200), }) : assert(wheelZoomRate > 0, '`wheelZoomRate` must be positive'), From 03598505dcc0c97472aea5770a19b9d64ffed4c9 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 1 Jul 2026 18:23:32 +0100 Subject: [PATCH 4/8] Deprecated `scrollWheelVelocity` and consolidated API into `ScrollZoomOptions` Removed redundant tests --- example/lib/pages/interactive_test_page.dart | 6 +- lib/src/gestures/scroll_zoom.dart | 14 ++- lib/src/map/options/interaction.dart | 26 +++-- lib/src/map/options/scroll_zoom.dart | 110 +++++++++++-------- test/gestures/scroll_wheel_zoom_test.dart | 52 +-------- 5 files changed, 95 insertions(+), 113 deletions(-) diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart index 994732caa..366f22d54 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/interactive_test_page.dart @@ -262,9 +262,9 @@ class _InteractiveFlagsPageState extends State { initialZoom: 11, interactionOptions: InteractionOptions( flags: value, - scrollZoomOptions: ScrollZoomOptions( - smoothZooming: useSmoothScrollZooming, - ), + scrollZoomOptions: useSmoothScrollZooming + ? const ScrollZoomOptions.smooth() + : const ScrollZoomOptions.snapping(), cursorKeyboardRotationOptions: CursorKeyboardRotationOptions( isKeyTrigger: (key) => diff --git a/lib/src/gestures/scroll_zoom.dart b/lib/src/gestures/scroll_zoom.dart index a4848a292..706a34d75 100644 --- a/lib/src/gestures/scroll_zoom.dart +++ b/lib/src/gestures/scroll_zoom.dart @@ -102,8 +102,8 @@ enum _ScrollType { wheel, trackpad } /// C¹-continuous bezier easing curves. Trackpad events are applied directly /// since the hardware already provides fine-grained continuous input. /// -/// When [ScrollZoomOptions.smoothZooming] is `false`, falls back to -/// snapping immediately to the new zoom level. +/// When smooth zooming is disabled, falls back to snapping immediately to the +/// new zoom level. class ScrollZoomHandler { final MapControllerImpl _controller; final TickerProvider _vsync; @@ -164,7 +164,7 @@ class ScrollZoomHandler { } if (event.scrollDelta.dy == 0) return; - if (_zoomOptions.smoothZooming) { + if (_zoomOptions.wheelSmoothZoomRate != null) { _doSmoothZoom(event); } else { _doSnapZoom(event); @@ -176,7 +176,9 @@ class ScrollZoomHandler { final minZoom = _options.minZoom ?? 0.0; final maxZoom = _options.maxZoom ?? double.infinity; final newZoom = (_camera.zoom - - event.scrollDelta.dy * _interactionOptions.scrollWheelVelocity) + event.scrollDelta.dy * + (_zoomOptions.snapZoomRate ?? + _interactionOptions.scrollWheelVelocity)) .clamp(minZoom, maxZoom); final newCenter = _camera.focusedZoomCenter( event.localPosition, @@ -272,8 +274,8 @@ class ScrollZoomHandler { // For trackpad events and single mouse wheel ticks, use the default zoom rate final zoomRate = (_type == _ScrollType.wheel && _delta.abs() > _wheelZoomDelta) - ? _zoomOptions.wheelZoomRate - : _zoomOptions.trackpadZoomRate; + ? _zoomOptions.wheelSmoothZoomRate! + : _zoomOptions.trackpadSmoothZoomRate; // Scale by sigmoid of scroll wheel delta so the map responds to small scrolls and compresses large scrolls var scale = diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index 0f750c3a8..97b1f8331 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -63,13 +63,19 @@ class InteractionOptions { /// gestures will take effect see [MultiFingerGesture] for custom settings final int pinchMoveWinGestures; - /// The used velocity how fast the map should zoom in or out by scrolling - /// with the scroll wheel of a mouse. - /// - /// Only used when [scrollZoomOptions] has - /// [ScrollZoomOptions.smoothZooming] set to `false`. In smooth zoom - /// mode, use [ScrollZoomOptions.wheelZoomRate] and - /// [ScrollZoomOptions.trackpadZoomRate] instead. + /// The multipler applied to the scroll offset to calculate the zoom offset, + /// when smooth zooming is disabled. + /// + /// This has been deprecated in favour of + /// [ScrollZoomOptions.snapZoomRate]. See documentation on that property + /// for more information. + /// + /// Defaults to `1 / 200`. Overriden by [ScrollZoomOptions.snapZoomRate] + /// if set. + @Deprecated( + 'Prefer `ScrollZoomOptions.snappingZoomRate` (and disabling smooth ' + 'zooming). Will be removed in an upcoming major release.', + ) final double scrollWheelVelocity; /// Options to configure scroll wheel/trackpad zoom behavior. @@ -143,8 +149,12 @@ class InteractionOptions { this.pinchMoveThreshold = 40.0, this.pinchMoveWinGestures = MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, + @Deprecated( + 'Prefer `ScrollZoomOptions.snappingZoomRate` (and disabling smooth ' + 'zooming). Will be removed in an upcoming major release.', + ) this.scrollWheelVelocity = 0.005, - this.scrollZoomOptions = const ScrollZoomOptions(), + this.scrollZoomOptions = const ScrollZoomOptions.smooth(), this.doubleTapDragZoomChangeCalculator = defaultDoubleTapDragZoomChangeCalculator, this.doubleTapZoomDuration = const Duration(milliseconds: 200), diff --git a/lib/src/map/options/scroll_zoom.dart b/lib/src/map/options/scroll_zoom.dart index 02858a166..ef314603c 100644 --- a/lib/src/map/options/scroll_zoom.dart +++ b/lib/src/map/options/scroll_zoom.dart @@ -1,86 +1,106 @@ +import 'package:flutter_map/src/map/options/interaction.dart'; import 'package:meta/meta.dart'; /// Options to configure scroll zoom behavior. /// -/// By default, scroll zoom uses smooth animated zooming inspired by -/// MapLibre GL JS. This can be disabled by setting [smoothZooming] to `false`, -/// which reverts to the old behavior of snapping immediately to the new -/// zoom level. +/// Two behaviours are available: +/// * Smooth (default, available since v8.4) +/// * Snap +/// +/// When smooth zooming (inspired by MapLibre GL JS), each mouse wheel tick +/// triggers a short eased animation to the new zoom level. Rapid successive +/// wheel ticks chain smoothly with velocity-continuous bezier curves. +/// +/// When snapping, each mouse wheel tick jumps immediately to the new zoom level +/// on each wheel event. +/// +/// Trackpad events are always applied directly regardless of this setting, +/// since trackpad hardware already provides fine-grained continuous input. +/// +/// To use snapping, set [wheelSmoothZoomRate] to `null`: then, [snapZoomRate] +/// is the sensitivity used for both mouse and trackpad scroll events (since the +/// two methods are not differentiated when snapping). Otherwise, +/// [wheelSmoothZoomRate] and [trackpadSmoothZoomRate] are used seperately for +/// their respective devices. +/// +/// Note that on some platforms, the trackpad behaves differently - for example, +/// scrolling may scroll on some, or pan on others. @immutable class ScrollZoomOptions { - /// Whether to use smooth animated zooming for mouse wheel events. + /// The multipler applied to the scroll offset to calculate the zoom offset, + /// when smooth zooming is disabled. /// - /// When `true` (default), each mouse wheel tick triggers a short eased - /// animation to the new zoom level. Rapid successive wheel ticks chain - /// smoothly with velocity-continuous bezier curves. + /// Closer to zero = lower zoom offset per scroll event. /// - /// When `false`, zooming snaps immediately to the new zoom level on each - /// wheel event, matching the pre-v8.4 behavior. + /// Smooth zooming is enabled by default. To disable, and use snapping zoom, + /// set [wheelSmoothZoomRate] to `null`. /// - /// Trackpad events are always applied directly regardless of this setting, - /// since trackpad hardware already provides fine-grained continuous input. - final bool smoothZooming; + /// Defaults to [InteractionOptions.scrollWheelVelocity], which defaults + /// to `1 / 200`. + /// + /// Should not be explicitly set to `null`. + final double? snapZoomRate; - /// Controls zoom sensitivity for mouse wheel events in smooth mode. + /// Controls zoom sensitivity for mouse wheel events, when smooth zooming + /// is enabled (as by default). /// - /// Lower values = slower zoom per wheel tick. Higher values = faster. + /// Closer to zero = lower zoom offset per wheel tick. /// - /// Only used when [smoothZooming] is `true`. + /// When `null`, smooth scrolling is disabled, and snapping zooming (old + /// behaviour) is used instead. /// /// Defaults to `1 / 350`. - final double wheelZoomRate; + final double? wheelSmoothZoomRate; - /// Controls zoom sensitivity for trackpad events. - /// - /// Lower values = slower zoom per trackpad gesture unit. Higher values = - /// faster. + /// Controls zoom sensitivity for trackpad events, when smooth zooming is + /// enabled (as by default). /// - /// Only used when [smoothZooming] is `true`. + /// Closer to zero = lower zoom offset per trackpad gesture unit. /// /// Defaults to `1 / 100`. - final double trackpadZoomRate; + final double trackpadSmoothZoomRate; - /// Duration of the easing animation for each mouse wheel tick. + /// Duration of the easing animation for each mouse wheel tick, when smooth + /// zooming is enabled (as by default). /// /// Each wheel tick triggers an animation of this duration. When multiple /// ticks arrive before the animation completes, the animations chain /// smoothly. /// - /// Only used when [smoothZooming] is `true`. - /// /// Defaults to 200ms. final Duration animationDuration; - /// Create scroll zoom options. - const ScrollZoomOptions({ - this.smoothZooming = true, - this.wheelZoomRate = 1 / 350, - this.trackpadZoomRate = 1 / 100, + /// Use smooth zooming, as by default. + /// + /// For more information, see [ScrollZoomOptions]. + const ScrollZoomOptions.smooth({ + this.wheelSmoothZoomRate = 1 / 350, + this.trackpadSmoothZoomRate = 1 / 100, this.animationDuration = const Duration(milliseconds: 200), - }) : assert(wheelZoomRate > 0, '`wheelZoomRate` must be positive'), - assert(trackpadZoomRate > 0, '`trackpadZoomRate` must be positive'); + }) : snapZoomRate = 0.005; - /// Options that disable smooth zooming, reverting to the legacy snap - /// behavior. - const ScrollZoomOptions.snapping() - : smoothZooming = false, - wheelZoomRate = 1 / 450, - trackpadZoomRate = 1 / 100, + /// Use snap zooming. + /// + /// For more information, see [ScrollZoomOptions]. + const ScrollZoomOptions.snapping({ + this.snapZoomRate, + }) : wheelSmoothZoomRate = null, + trackpadSmoothZoomRate = 1 / 100, animationDuration = const Duration(milliseconds: 200); @override bool operator ==(Object other) => other is ScrollZoomOptions && - smoothZooming == other.smoothZooming && - wheelZoomRate == other.wheelZoomRate && - trackpadZoomRate == other.trackpadZoomRate && + snapZoomRate == other.snapZoomRate && + wheelSmoothZoomRate == other.wheelSmoothZoomRate && + trackpadSmoothZoomRate == other.trackpadSmoothZoomRate && animationDuration == other.animationDuration; @override int get hashCode => Object.hash( - smoothZooming, - wheelZoomRate, - trackpadZoomRate, + snapZoomRate, + wheelSmoothZoomRate, + trackpadSmoothZoomRate, animationDuration, ); } diff --git a/test/gestures/scroll_wheel_zoom_test.dart b/test/gestures/scroll_wheel_zoom_test.dart index 909492dea..0cf613df6 100644 --- a/test/gestures/scroll_wheel_zoom_test.dart +++ b/test/gestures/scroll_wheel_zoom_test.dart @@ -23,56 +23,6 @@ Future _scroll(WidgetTester tester, {required double dy}) async { /// Also, you can't put it into `setUp()` or `setUpAll()` because you can't /// access `TestWidgetsFlutterBinding.instance.clock` there. void main() { - group('ScrollZoomOptions', () { - test('default values', () { - const options = ScrollZoomOptions(); - expect(options.smoothZooming, isTrue); - expect(options.wheelZoomRate, 1 / 450); - expect(options.trackpadZoomRate, 1 / 100); - expect(options.animationDuration, const Duration(milliseconds: 200)); - }); - - test('snapping constructor disables smooth zooming', () { - const options = ScrollZoomOptions.snapping(); - expect(options.smoothZooming, isFalse); - }); - - test('equality', () { - const a = ScrollZoomOptions(); - const b = ScrollZoomOptions(); - const c = ScrollZoomOptions(wheelZoomRate: 1 / 200); - expect(a, equals(b)); - expect(a, isNot(equals(c))); - }); - - group('assertions', () { - test('rejects non-positive wheelZoomRate', () { - expect( - () => ScrollZoomOptions(wheelZoomRate: 0), - throwsA(isA()), - ); - expect( - () => ScrollZoomOptions(wheelZoomRate: -1), - throwsA(isA()), - ); - }); - - test('rejects non-positive trackpadZoomRate', () { - expect( - () => ScrollZoomOptions(trackpadZoomRate: 0), - throwsA(isA()), - ); - }); - - test('rejects non-positive trackpadZoomRate (negative)', () { - expect( - () => ScrollZoomOptions(trackpadZoomRate: -0.5), - throwsA(isA()), - ); - }); - }); - }); - group('Scroll zoom - snap mode', () { testWidgets('zooms in immediately on scroll up', (tester) async { final controller = MapController(); @@ -267,7 +217,7 @@ void main() { await tester.pumpWidget(TestApp( controller: controller, interactionOptions: const InteractionOptions( - scrollZoomOptions: ScrollZoomOptions( + scrollZoomOptions: ScrollZoomOptions.smooth( animationDuration: Duration(milliseconds: 1000), ), ), From c5635851b030a78f7881a7ac7fc30ed63b381049 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 2 Sep 2026 01:21:39 +0100 Subject: [PATCH 5/8] Improved API and documentation Improved deprecation and transition behaviour Adjusted smooth scroll wheel zoom rate --- example/lib/pages/interactive_test_page.dart | 2 +- example/pubspec.lock | 2 +- lib/src/gestures/scroll_zoom.dart | 111 +++++++++------ lib/src/map/options/interaction.dart | 30 ++-- lib/src/map/options/scroll_zoom.dart | 139 +++++++++++-------- test/gestures/scroll_wheel_zoom_test.dart | 6 +- 6 files changed, 178 insertions(+), 112 deletions(-) diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart index 366f22d54..56eaf8425 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/interactive_test_page.dart @@ -264,7 +264,7 @@ class _InteractiveFlagsPageState extends State { flags: value, scrollZoomOptions: useSmoothScrollZooming ? const ScrollZoomOptions.smooth() - : const ScrollZoomOptions.snapping(), + : const ScrollZoomOptions.snap(), cursorKeyboardRotationOptions: CursorKeyboardRotationOptions( isKeyTrigger: (key) => diff --git a/example/pubspec.lock b/example/pubspec.lock index 50adcec9a..17d3a4303 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -124,7 +124,7 @@ packages: path: ".." relative: true source: path - version: "8.3.0" + version: "8.3.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/lib/src/gestures/scroll_zoom.dart b/lib/src/gestures/scroll_zoom.dart index 706a34d75..f9107e83c 100644 --- a/lib/src/gestures/scroll_zoom.dart +++ b/lib/src/gestures/scroll_zoom.dart @@ -23,7 +23,8 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -/// Cubic bezier curve, implicit first and last control points are (0,0) and (1,1). +/// Cubic bezier curve, implicit first and last control points are (0,0) and +/// (1,1). class _UnitBezier { final double _cx; final double _bx; @@ -111,8 +112,6 @@ class ScrollZoomHandler { Ticker? _ticker; bool _tickerActive = false; - // deltaY value for mouse wheel identification - static const double _wheelZoomDelta = 4.000244140625; _ScrollType? _type; double _lastValue = 0; int _lastWheelEventTime = 0; @@ -128,13 +127,22 @@ class ScrollZoomHandler { _EasingFn? _easing; _PrevEase? _prevEase; - // upper bound on how much we scale the map in any single render frame; this + // deltaY value for mouse wheel identification + // + // See also: + // * https://github.com/mapbox/mapbox-gl-js/issues/7572 + // * https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js (although parts of this seem to be redundant in modern browsers) + // * https://archive.fo/ZV8gz + static const double _wheelZoomDelta = 4.000244140625; + + // Upper bound on how much we scale the map in any single render frame; this // is used to limit zoom rate in the case of very fast scrolling static const double _maxScalePerFrame = 2; - // Minimum time difference value to be used for calculating zoom easing in renderFrame(); - // this is used to normalise very fast (typically 0 to 0.3ms) repeating lastWheelEventTimeDiff - // values generated by Chromium based browsers during fast scrolling wheel events. + // Minimum time difference value to be used for calculating zoom easing in + // renderFrame(); this is used to normalise very fast (typically 0 to 0.3ms) + // repeating lastWheelEventTimeDiff values generated by Chromium based + // browsers during fast scrolling wheel events. static const int _wheelEventTimeDiffAdjustment = 5; /// Create a new [ScrollZoomHandler]. @@ -146,9 +154,6 @@ class ScrollZoomHandler { MapCamera get _camera => _controller.camera; MapOptions get _options => _controller.options; - InteractionOptions get _interactionOptions => _options.interactionOptions; - ScrollZoomOptions get _zoomOptions => _interactionOptions.scrollZoomOptions; - int get _animationDurationMs => _zoomOptions.animationDuration.inMilliseconds; /// Dispose animations and timers. void dispose() { @@ -159,26 +164,44 @@ class ScrollZoomHandler { /// Handle a pointer scroll event. void onPointerSignal(PointerScrollEvent event) { - if (!InteractiveFlag.hasScrollWheelZoom(_interactionOptions.flags)) { - return; - } + final interactionOptions = _options.interactionOptions; + + if (!InteractiveFlag.hasScrollWheelZoom(interactionOptions.flags)) return; if (event.scrollDelta.dy == 0) return; - if (_zoomOptions.wheelSmoothZoomRate != null) { - _doSmoothZoom(event); - } else { - _doSnapZoom(event); + // During the transition period to avoid unexpected changes for most + // end-users: + // * We want to upgrade users to the new, better behaviour automatically + // without a breaking change... + // * ...but if the old scroll wheel velocity param was changed from its + // default, stick to the old behaviour... + // * ...unless the new zoom options have also been changed at all from + // their default (explicitly used) + if (interactionOptions.scrollWheelVelocity != 0.005 && + interactionOptions.scrollZoomOptions == + const ScrollZoomOptions.smooth()) { + return _doSnapZoom( + event, + SnapScrollZoomOptions(zoomRate: interactionOptions.scrollWheelVelocity), + ); + } + + switch (interactionOptions.scrollZoomOptions) { + case final SmoothScrollZoomOptions o: + _doSmoothZoom(event, o); + case final SnapScrollZoomOptions o: + _doSnapZoom(event, o); } } /// Snap zoom: apply zoom change immediately. - void _doSnapZoom(PointerScrollEvent event) { + void _doSnapZoom( + PointerScrollEvent event, + SnapScrollZoomOptions zoomOptions, + ) { final minZoom = _options.minZoom ?? 0.0; final maxZoom = _options.maxZoom ?? double.infinity; - final newZoom = (_camera.zoom - - event.scrollDelta.dy * - (_zoomOptions.snapZoomRate ?? - _interactionOptions.scrollWheelVelocity)) + final newZoom = (_camera.zoom - event.scrollDelta.dy * zoomOptions.zoomRate) .clamp(minZoom, maxZoom); final newCenter = _camera.focusedZoomCenter( event.localPosition, @@ -192,8 +215,13 @@ class ScrollZoomHandler { ); } + //! SMOOTH ZOOMING + /// Smooth zoom: accumulate delta and animate. - void _doSmoothZoom(PointerScrollEvent event) { + void _doSmoothZoom( + PointerScrollEvent event, + SmoothScrollZoomOptions zoomOptions, + ) { final value = event.scrollDelta.dy; final currentTime = currentTimestamp().millisecondsSinceEpoch; final timeDelta = currentTime - _lastWheelEventTime; @@ -224,12 +252,13 @@ class ScrollZoomHandler { _lastWheelEventTime = currentTimestamp().millisecondsSinceEpoch; _delta -= _lastValue; _cursorPosition = _pendingEventPosition!; - _ensureAnimating(); + _ensureAnimating(zoomOptions); }); return; } else if (_type == null) { - // This is a repeating event, but we don't know the type of event just yet. - // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode. + // This is a repeating event, but we don't know the type of event just + // yet. If the delta per time is small, we assume it's a fast trackpad; + // otherwise we switch into wheel mode. _type = ((timeDelta * value).abs() < 200) ? _ScrollType.trackpad : _ScrollType.wheel; @@ -243,41 +272,40 @@ class ScrollZoomHandler { } } - // Only fire the callback if we actually know what type of scrolling device the user uses. + // Only fire the callback if we actually know what type of scrolling device + // the user uses. if (_type != null) { _delta -= value; - _ensureAnimating(); + _ensureAnimating(zoomOptions); } } - void _ensureAnimating() { + void _ensureAnimating(SmoothScrollZoomOptions zoomOptions) { if (_tickerActive) return; - _ticker ??= _vsync.createTicker(_onTick); + _ticker ??= _vsync.createTicker((_) => _renderFrame(zoomOptions)); if (!_ticker!.isActive) { _ticker!.start(); } _tickerActive = true; } - void _onTick(Duration elapsed) { - _renderFrame(); - } - - void _renderFrame() { + void _renderFrame(SmoothScrollZoomOptions zoomOptions) { final minZoom = _options.minZoom ?? 0.0; final maxZoom = _options.maxZoom ?? double.infinity; // if we've had scroll events since the last render frame, consume the // accumulated delta, and update the target zoom level accordingly if (_delta != 0) { - // For trackpad events and single mouse wheel ticks, use the default zoom rate + // For trackpad events and single mouse wheel ticks, use the default zoom + // rate final zoomRate = (_type == _ScrollType.wheel && _delta.abs() > _wheelZoomDelta) - ? _zoomOptions.wheelSmoothZoomRate! - : _zoomOptions.trackpadSmoothZoomRate; + ? zoomOptions.wheelZoomRate + : zoomOptions.trackpadZoomRate; - // Scale by sigmoid of scroll wheel delta so the map responds to small scrolls and compresses large scrolls + // Scale by sigmoid of scroll wheel delta so the map responds to small + // scrolls and compresses large scrolls var scale = _maxScalePerFrame / (1 + math.exp(-(_delta * zoomRate).abs())); @@ -294,7 +322,8 @@ class ScrollZoomHandler { // function we're using to smooth out the zooming between wheel events if (_type == _ScrollType.wheel) { _startZoom = _camera.zoom; - _easing = _smoothOutEasing(_animationDurationMs); + _easing = + _smoothOutEasing(zoomOptions.animationDuration.inMilliseconds); } _delta = 0; @@ -309,7 +338,7 @@ class ScrollZoomHandler { final timeSinceLastWheel = currentTimestamp().millisecondsSinceEpoch - _lastWheelEventTime; final t = ((timeSinceLastWheel + _wheelEventTimeDiffAdjustment) / - _animationDurationMs) + zoomOptions.animationDuration.inMilliseconds) .clamp(0.0, 1.0); final k = _easing!(t); zoom = (1.0 - k) * _startZoom! + k * targetZoom; diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index 97b1f8331..da28cc3e3 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -66,15 +66,28 @@ class InteractionOptions { /// The multipler applied to the scroll offset to calculate the zoom offset, /// when smooth zooming is disabled. /// - /// This has been deprecated in favour of - /// [ScrollZoomOptions.snapZoomRate]. See documentation on that property - /// for more information. + /// --- /// - /// Defaults to `1 / 200`. Overriden by [ScrollZoomOptions.snapZoomRate] + /// Since v8.4, this has been deprecated in favour of + /// [SnapScrollZoomOptions.zoomRate]. The new scroll zoom options allow for + /// the new smooth scrolling functionality to also be customised. + /// + /// To improve the end-user experience for more users, maps will use smooth + /// scrolling since v8.4 by default - **unless this property has been changed + /// from its default of 0.005** and the [scrollZoomOptions] have not been + /// changed from their default, in which case it is respected and the snap + /// behaviour will be used. + /// + /// To migrate, this argument should be removed (left to default), and the + /// desired value instead used by setting [scrollZoomOptions] to + /// [ScrollZoomOptions.snap] and setting the argument in the constructor. + /// + /// Defaults to `1 / 200`. Overriden by [SnapScrollZoomOptions.zoomRate] /// if set. @Deprecated( - 'Prefer `ScrollZoomOptions.snappingZoomRate` (and disabling smooth ' - 'zooming). Will be removed in an upcoming major release.', + 'Prefer `SnapScrollZoomOptions.zoomRate`. See documentation on this ' + 'property for more information. Will be removed in an upcoming major ' + 'release.', ) final double scrollWheelVelocity; @@ -150,8 +163,9 @@ class InteractionOptions { this.pinchMoveWinGestures = MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, @Deprecated( - 'Prefer `ScrollZoomOptions.snappingZoomRate` (and disabling smooth ' - 'zooming). Will be removed in an upcoming major release.', + 'Prefer `SnapScrollZoomOptions.zoomRate`. See documentation on this ' + 'property for more information. Will be removed in an upcoming major ' + 'release.', ) this.scrollWheelVelocity = 0.005, this.scrollZoomOptions = const ScrollZoomOptions.smooth(), diff --git a/lib/src/map/options/scroll_zoom.dart b/lib/src/map/options/scroll_zoom.dart index ef314603c..5b4a36931 100644 --- a/lib/src/map/options/scroll_zoom.dart +++ b/lib/src/map/options/scroll_zoom.dart @@ -4,53 +4,65 @@ import 'package:meta/meta.dart'; /// Options to configure scroll zoom behavior. /// /// Two behaviours are available: -/// * Smooth (default, available since v8.4) -/// * Snap +/// * Smooth (default, available since v8.4): [ScrollZoomOptions.smooth] +/// * Snap: [ScrollZoomOptions.snap] /// -/// When smooth zooming (inspired by MapLibre GL JS), each mouse wheel tick -/// triggers a short eased animation to the new zoom level. Rapid successive -/// wheel ticks chain smoothly with velocity-continuous bezier curves. +/// Remember when customising zoom rates that the behaviour will differ between +/// platform and hardware, which is very difficult to disambiguate accurately to +/// use to inform the zoom rate. Therefore, values should be used which are +/// likely to work well for many users across many environments. /// -/// When snapping, each mouse wheel tick jumps immediately to the new zoom level -/// on each wheel event. -/// -/// Trackpad events are always applied directly regardless of this setting, -/// since trackpad hardware already provides fine-grained continuous input. -/// -/// To use snapping, set [wheelSmoothZoomRate] to `null`: then, [snapZoomRate] -/// is the sensitivity used for both mouse and trackpad scroll events (since the -/// two methods are not differentiated when snapping). Otherwise, -/// [wheelSmoothZoomRate] and [trackpadSmoothZoomRate] are used seperately for -/// their respective devices. +/// The behaviour difference only applies to mouse wheel inputs. Trackpad events +/// are always applied without smoothing regardless of the behaviour, since +/// trackpad hardware drivers usually already provide fine-grained continuous +/// input. /// /// Note that on some platforms, the trackpad behaves differently - for example, -/// scrolling may scroll on some, or pan on others. +/// scrolling may scroll on some, or pan on others. This cannot be overcome +/// without changes to the Flutter engine. @immutable -class ScrollZoomOptions { - /// The multipler applied to the scroll offset to calculate the zoom offset, - /// when smooth zooming is disabled. - /// - /// Closer to zero = lower zoom offset per scroll event. - /// - /// Smooth zooming is enabled by default. To disable, and use snapping zoom, - /// set [wheelSmoothZoomRate] to `null`. +sealed class ScrollZoomOptions { + const ScrollZoomOptions(); + + /// Use and configure smooth scroll zooming. /// - /// Defaults to [InteractionOptions.scrollWheelVelocity], which defaults - /// to `1 / 200`. + /// See [SmoothScrollZoomOptions] for more information. + const factory ScrollZoomOptions.smooth({ + double wheelZoomRate, + double trackpadZoomRate, + Duration animationDuration, + }) = SmoothScrollZoomOptions; + + /// Use and configure snap scroll zooming. /// - /// Should not be explicitly set to `null`. - final double? snapZoomRate; + /// See [SnapScrollZoomOptions] for more information. + const factory ScrollZoomOptions.snap({double zoomRate}) = + SnapScrollZoomOptions; +} +/// When smooth scroll zooming, each mouse wheel tick triggers a short eased +/// animation to the new zoom level. Rapid successive wheel ticks chain smoothly +/// with velocity-continuous bezier curves. +/// +/// The algorithm to implement this is based off the MapLibre GL JS algorithm. +/// +/// Although trackpad events are not smoothed, they are disambiguated from mouse +/// wheel events, and therefore a different sensitivity rate can be applied to +/// each input method. +/// +/// --- +/// +/// This is available since v8.4, and is the default behaviour - except in +/// certain edge cases before v9: see [InteractionOptions.scrollWheelVelocity] +/// for more information. +class SmoothScrollZoomOptions extends ScrollZoomOptions { /// Controls zoom sensitivity for mouse wheel events, when smooth zooming /// is enabled (as by default). /// /// Closer to zero = lower zoom offset per wheel tick. /// - /// When `null`, smooth scrolling is disabled, and snapping zooming (old - /// behaviour) is used instead. - /// - /// Defaults to `1 / 350`. - final double? wheelSmoothZoomRate; + /// Defaults to `1 / 180`. + final double wheelZoomRate; /// Controls zoom sensitivity for trackpad events, when smooth zooming is /// enabled (as by default). @@ -58,7 +70,7 @@ class ScrollZoomOptions { /// Closer to zero = lower zoom offset per trackpad gesture unit. /// /// Defaults to `1 / 100`. - final double trackpadSmoothZoomRate; + final double trackpadZoomRate; /// Duration of the easing animation for each mouse wheel tick, when smooth /// zooming is enabled (as by default). @@ -70,37 +82,48 @@ class ScrollZoomOptions { /// Defaults to 200ms. final Duration animationDuration; - /// Use smooth zooming, as by default. - /// - /// For more information, see [ScrollZoomOptions]. - const ScrollZoomOptions.smooth({ - this.wheelSmoothZoomRate = 1 / 350, - this.trackpadSmoothZoomRate = 1 / 100, + /// Use and configure smooth scroll zooming. + const SmoothScrollZoomOptions({ + this.wheelZoomRate = 1 / 180, + this.trackpadZoomRate = 1 / 100, this.animationDuration = const Duration(milliseconds: 200), - }) : snapZoomRate = 0.005; - - /// Use snap zooming. - /// - /// For more information, see [ScrollZoomOptions]. - const ScrollZoomOptions.snapping({ - this.snapZoomRate, - }) : wheelSmoothZoomRate = null, - trackpadSmoothZoomRate = 1 / 100, - animationDuration = const Duration(milliseconds: 200); + }); @override bool operator ==(Object other) => - other is ScrollZoomOptions && - snapZoomRate == other.snapZoomRate && - wheelSmoothZoomRate == other.wheelSmoothZoomRate && - trackpadSmoothZoomRate == other.trackpadSmoothZoomRate && + other is SmoothScrollZoomOptions && + wheelZoomRate == other.wheelZoomRate && + trackpadZoomRate == other.trackpadZoomRate && animationDuration == other.animationDuration; @override int get hashCode => Object.hash( - snapZoomRate, - wheelSmoothZoomRate, - trackpadSmoothZoomRate, + wheelZoomRate, + trackpadZoomRate, animationDuration, ); } + +/// When snap scroll zooming, each mouse wheel tick jumps immediately to the new +/// zoom level on each wheel event. +class SnapScrollZoomOptions extends ScrollZoomOptions { + /// The multipler applied to the scroll offset to calculate the zoom offset, + /// when smooth zooming is disabled. + /// + /// Closer to zero = lower zoom offset per scroll event. + /// + /// Defaults to `1 / 200`. + final double zoomRate; + + /// Use and configure snap scroll zooming. + const SnapScrollZoomOptions({ + this.zoomRate = 1 / 200, + }); + + @override + bool operator ==(Object other) => + other is SnapScrollZoomOptions && zoomRate == other.zoomRate; + + @override + int get hashCode => zoomRate.hashCode; +} diff --git a/test/gestures/scroll_wheel_zoom_test.dart b/test/gestures/scroll_wheel_zoom_test.dart index 0cf613df6..d27fd3748 100644 --- a/test/gestures/scroll_wheel_zoom_test.dart +++ b/test/gestures/scroll_wheel_zoom_test.dart @@ -29,7 +29,7 @@ void main() { await tester.pumpWidget(TestApp( controller: controller, interactionOptions: const InteractionOptions( - scrollZoomOptions: ScrollZoomOptions.snapping(), + scrollZoomOptions: ScrollZoomOptions.snap(), ), )); @@ -50,7 +50,7 @@ void main() { await tester.pumpWidget(TestApp( controller: controller, interactionOptions: const InteractionOptions( - scrollZoomOptions: ScrollZoomOptions.snapping(), + scrollZoomOptions: ScrollZoomOptions.snap(), ), )); @@ -295,7 +295,7 @@ void main() { initialZoom: 10, onMapEvent: events.add, interactionOptions: const InteractionOptions( - scrollZoomOptions: ScrollZoomOptions.snapping(), + scrollZoomOptions: ScrollZoomOptions.snap(), ), ), children: [TileLayer(tileProvider: TestTileProvider())], From 13f1a40fafbba7d27af9e0c5c4722784e4d6abe9 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 2 Sep 2026 22:11:33 +0100 Subject: [PATCH 6/8] Use publish workflow to create GH release with built demo app assets automatically on tag creation Extend publish workflow to work on prereleases Remove attestation steps from master workflow --- .github/workflows/branch.yml | 4 -- .github/workflows/master.yml | 31 +------- .github/workflows/publish.yml | 130 ++++++++++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 38 deletions(-) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 4bf4bae0c..e6161abf4 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -126,10 +126,6 @@ jobs: cache: true - name: Build Windows app run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} - # No longer necessary: https://github.com/actions/runner-images/pull/13090/changes - #- name: Install Inno Setup - # if: ${{ matrix.target.sdk == '' }} - # run: choco install innosetup --yes --no-progress - name: Generate app installer if: ${{ matrix.target.sdk == '' }} run: iscc "windows/installer-config.iss" diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e4f50fad0..641ef5947 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -10,6 +10,7 @@ concurrency: cancel-in-progress: true jobs: + # Not sure this is necessary, maybe for Codecov? But no harm to keep run-tests: name: "Run Tests" runs-on: ubuntu-latest @@ -32,14 +33,9 @@ jobs: name: "Build Demo (Android)" runs-on: ubuntu-latest needs: [run-tests] - if: github.repository == 'fleaflet/flutter_map' defaults: run: working-directory: ./example - permissions: - id-token: write - contents: read - attestations: write steps: - name: Checkout uses: actions/checkout@v7 @@ -63,23 +59,14 @@ jobs: path: example/build/app/outputs/apk/release/android-demo.apk if-no-files-found: error archive: false - - name: Generate artifact attestation - uses: actions/attest@v4 - with: - subject-path: example/build/app/outputs/apk/release/android-demo.apk build-windows: name: "Build Demo (Windows)" runs-on: windows-latest needs: [run-tests] - if: github.repository == 'fleaflet/flutter_map' defaults: run: working-directory: ./example - permissions: - id-token: write - contents: read - attestations: write steps: - name: Checkout uses: actions/checkout@v7 @@ -89,9 +76,6 @@ jobs: cache: true - name: Build Windows app run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} - # No longer necessary: https://github.com/actions/runner-images/pull/13090/changes - #- name: Install Inno Setup - # run: choco install innosetup --yes --no-progress - name: Generate app installer run: iscc "windows/installer-config.iss" - name: Upload as artifact @@ -100,23 +84,14 @@ jobs: path: example/build/windows/output/windows-demo.exe if-no-files-found: error archive: false - - name: Generate artifact attestation - uses: actions/attest@v4 - with: - subject-path: example/build/windows/output/windows-demo.exe build-web: name: "Build & Deploy Demo (Web)" runs-on: ubuntu-latest needs: [run-tests] - if: github.repository == 'fleaflet/flutter_map' defaults: run: working-directory: ./example - permissions: - id-token: write - contents: read - attestations: write steps: - name: Checkout uses: actions/checkout@v7 @@ -134,10 +109,6 @@ jobs: path: example/web-demo.zip if-no-files-found: error archive: false - - name: Generate artifact attestation - uses: actions/attest@v4 - with: - subject-path: example/web-demo.zip - name: Deploy app to Firebase Hosting uses: FirebaseExtended/action-hosting-deploy@v0 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c55e11c01..134e3137d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,15 +1,137 @@ name: Publish Package - on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-dev.[0-9]+" jobs: - publish: + build-android: + name: "Build Demo (Android)" + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./example + permissions: + id-token: write + contents: read + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Java 21 + uses: actions/setup-java@v6 + with: + distribution: "temurin" + java-version: "21" + cache: "gradle" + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + cache: true + - name: Build Android app + run: flutter build apk --dart-define=COMMIT_SHA=${{ github.sha }} + - name: Rename output APK + run: mv build/app/outputs/apk/release/app-release.apk build/app/outputs/apk/release/android-demo.apk + - name: Upload as artifact + uses: actions/upload-artifact@v7 + with: + path: example/build/app/outputs/apk/release/android-demo.apk + if-no-files-found: error + archive: false + - name: Generate artifact attestation + uses: actions/attest@v4 + with: + subject-path: example/build/app/outputs/apk/release/android-demo.apk + + build-windows: + name: "Build Demo (Windows)" + runs-on: windows-latest + defaults: + run: + working-directory: ./example + permissions: + id-token: write + contents: read + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + cache: true + - name: Build Windows app + run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} + - name: Generate app installer + run: iscc "windows/installer-config.iss" + - name: Upload as artifact + uses: actions/upload-artifact@v7 + with: + path: example/build/windows/output/windows-demo.exe + if-no-files-found: error + archive: false + - name: Generate artifact attestation + uses: actions/attest@v4 + with: + subject-path: example/build/windows/output/windows-demo.exe + + build-web: + name: "Build & Deploy Demo (Web)" + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./example + permissions: + id-token: write + contents: read + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + cache: true + - name: Build web app + run: flutter build web --wasm --dart-define=COMMIT_SHA=${{ github.sha }} + - name: Compress web build + run: zip -r web-demo.zip build/web + - name: Upload as artifact + uses: actions/upload-artifact@v7 + with: + path: example/web-demo.zip + if-no-files-found: error + archive: false + - name: Generate artifact attestation + uses: actions/attest@v4 + with: + subject-path: example/web-demo.zip + + gh-release: + name: "Create GitHub Release" + runs-on: ubuntu-latest + needs: [build-android, build-windows, build-web] + permissions: + contents: write + steps: + - name: Fetch build artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts/ + merge-multiple: true + - name: Create GitHub release + uses: softprops/action-gh-release@v3 + with: + files: artifacts/* + prerelease: ${{contains(github.ref_name, '-dev')}} + generate_release_notes: true + + pub-publish: name: "Publish To pub.dev" + needs: [build-android, build-windows, build-web] permissions: id-token: write uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 with: - environment: 'pub.dev' \ No newline at end of file + environment: "pub.dev" From 7db392c0f1e54223aa2dcc0d78e09e793f9f14b4 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 2 Sep 2026 22:11:56 +0100 Subject: [PATCH 7/8] Prepare for v8.4.0 prerelease --- CHANGELOG.md | 13 +++++++++++++ example/pubspec.yaml | 2 +- example/windows/installer-config.iss | 2 +- pubspec.yaml | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7aed700e..11e81bf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ Please consider [donating](https://github.com/sponsors/fleaflet) or [contributin This CHANGELOG does not include every commit and/or PR - it is a hand picked selection of the ones that have an effect on most users. For a full list of changes, please check the GitHub repository releases/tags. We also release highlights for some releases on the docs site. +## [8.4.0-dev.1] - 2026/09/02 + +Contains the following user-affecting changes: + +- Added smooth scrolling when using mouse wheel - [#2198](https://github.com/fleaflet/flutter_map/pull/2198) +- Added ability to use meters to size `Marker`s - [#2106](https://github.com/fleaflet/flutter_map/pull/2106) + +Many thanks to these contributors (in no particular order): + +- @vinlet +- @LeonTenorio +- ... and all the maintainers + ## [8.3.2] - 2026/08/27 Contains the following user-affecting bug fixes: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6775d3aa2..7af89a361 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_example description: Example application for 'flutter_map' package publish_to: "none" -version: 8.3.2 +version: 8.4.0 environment: sdk: ">=3.6.0 <4.0.0" diff --git a/example/windows/installer-config.iss b/example/windows/installer-config.iss index 44526d095..49037f085 100644 --- a/example/windows/installer-config.iss +++ b/example/windows/installer-config.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "flutter_map Demo" -#define MyAppVersion "for 8.3.2" +#define MyAppVersion "for 8.4.0" #define MyAppPublisher "fleaflet" #define MyAppURL "https://github.com/fleaflet/flutter_map" #define MyAppSupportURL "https://github.com/fleaflet/flutter_map/issues" diff --git a/pubspec.yaml b/pubspec.yaml index b8424ecc7..aeeb341f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_map description: "Flutter's №1 non-commercially aimed map client: it's easy-to-use, versatile, vendor-free, fully cross-platform, and 100% pure-Flutter" -version: 8.3.2 +version: 8.4.0-dev.1 repository: https://github.com/fleaflet/flutter_map issue_tracker: https://github.com/fleaflet/flutter_map/issues From f54b594e473ea1081f01cd7dba94bc3879f27c20 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 2 Sep 2026 22:29:16 +0100 Subject: [PATCH 8/8] Add #2209 to CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e81bf56..778c0b98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,15 @@ Contains the following user-affecting changes: - Added smooth scrolling when using mouse wheel - [#2198](https://github.com/fleaflet/flutter_map/pull/2198) - Added ability to use meters to size `Marker`s - [#2106](https://github.com/fleaflet/flutter_map/pull/2106) +Contains the following user-affecting bug fixes: + +- Prevent `RangeError` in `Proj4Crs.scale`/`zoom` for out-of-range values - [#2209](https://github.com/fleaflet/flutter_map/pull/2209) for [#1358](https://github.com/fleaflet/flutter_map/issues/1358) & [#1223](https://github.com/fleaflet/flutter_map/issues/1223) + Many thanks to these contributors (in no particular order): - @vinlet - @LeonTenorio +- @AlexLaroche - ... and all the maintainers ## [8.3.2] - 2026/08/27