From f0f23489b529e6e43ec87b793d5e96b373694faa Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 23:03:42 -0700 Subject: [PATCH] Fix map jitter when touching map during follow-me animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancel in-progress follow-me animations on pointer-down and suppress new ones while any pointer is on the map. Without this, GPS position updates trigger 600ms animateTo() calls that fight with the user's stationary finger, causing visible wiggle — especially at low zoom where small geographic shifts cover more pixels. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/map/gps_controller.dart | 9 +++- lib/widgets/map_view.dart | 70 ++++++++++++++++++----------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index 33e7c04..a40fdf4 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -32,6 +32,7 @@ class GpsController { List Function()? _getNearbyNodes; List Function()? _getEnabledProfiles; VoidCallback? _onMapMovedProgrammatically; + bool Function()? _isUserInteracting; /// Get the current GPS location (if available) LatLng? get currentLocation => _currentLocation; @@ -49,6 +50,7 @@ class GpsController { required List Function() getNearbyNodes, required List Function() getEnabledProfiles, VoidCallback? onMapMovedProgrammatically, + bool Function()? isUserInteracting, }) async { debugPrint('[GpsController] Initializing GPS controller'); @@ -61,7 +63,8 @@ class GpsController { _getNearbyNodes = getNearbyNodes; _getEnabledProfiles = getEnabledProfiles; _onMapMovedProgrammatically = onMapMovedProgrammatically; - + _isUserInteracting = isUserInteracting; + // Start location tracking await _startLocationTracking(); } @@ -235,9 +238,10 @@ class GpsController { if (followMeMode == FollowMeMode.off || _mapController == null) { return; } - WidgetsBinding.instance.addPostFrameCallback((_) { try { + if (_isUserInteracting?.call() == true) return; + if (followMeMode == FollowMeMode.follow) { // Follow position, preserve rotation _mapController!.animateTo( @@ -352,5 +356,6 @@ class GpsController { _getNearbyNodes = null; _getEnabledProfiles = null; _onMapMovedProgrammatically = null; + _isUserInteracting = null; } } \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 9407569..96ef2b3 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -80,6 +80,9 @@ class MapViewState extends State { // State for proximity alert banner bool _showProximityBanner = false; + + // Track active pointers to suppress follow-me animations during touch + int _activePointers = 0; @@ -189,6 +192,7 @@ class MapViewState extends State { // Refresh nodes when GPS controller moves the map _refreshNodesFromProvider(); }, + isUserInteracting: () => _activePointers > 0, ); // Fetch initial cameras @@ -380,10 +384,21 @@ class MapViewState extends State { children: [ SheetAwareMap( sheetHeight: widget.sheetHeight, - child: FlutterMap( - key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), - mapController: _controller.mapController, - options: MapOptions( + child: Listener( + onPointerDown: (_) { + _activePointers++; + _controller.stopAnimations(); + }, + onPointerUp: (_) { + if (_activePointers > 0) _activePointers--; + }, + onPointerCancel: (_) { + if (_activePointers > 0) _activePointers--; + }, + child: FlutterMap( + key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), + mapController: _controller.mapController, + options: MapOptions( initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), initialZoom: _positionManager.initialZoom ?? 15, minZoom: 1.0, @@ -486,30 +501,31 @@ class MapViewState extends State { _dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode); } }, + ), + children: [ + _tileManager.buildTileLayer( + selectedProvider: appState.selectedTileProvider, + selectedTileType: appState.selectedTileType, + ), + cameraLayers, + // Custom scale bar that respects user's distance unit preference + Builder( + builder: (context) { + final safeArea = MediaQuery.of(context).padding; + return CustomScaleBar( + alignment: Alignment.bottomLeft, + padding: EdgeInsets.only( + left: leftPositionWithSafeArea(8, safeArea), + bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom), + ), + maxWidthPx: 120, + barHeight: 8, + ); + }, + ), + ], + ), ), - children: [ - _tileManager.buildTileLayer( - selectedProvider: appState.selectedTileProvider, - selectedTileType: appState.selectedTileType, - ), - cameraLayers, - // Custom scale bar that respects user's distance unit preference - Builder( - builder: (context) { - final safeArea = MediaQuery.of(context).padding; - return CustomScaleBar( - alignment: Alignment.bottomLeft, - padding: EdgeInsets.only( - left: leftPositionWithSafeArea(8, safeArea), - bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom) - ), - maxWidthPx: 120, - barHeight: 8, - ); - }, - ), - ], - ), ), // All map overlays (mode indicator, zoom, attribution, add pin)