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', + ); + }); + }); +} 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),