diff --git a/example/lib/main.dart b/example/lib/main.dart index d468e0d90..0e794885e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dar import 'package:flutter_map_example/pages/epsg3996_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; +import 'package:flutter_map_example/pages/fling_animation_damping.dart'; import 'package:flutter_map_example/pages/home.dart'; import 'package:flutter_map_example/pages/interactive_test_page.dart'; import 'package:flutter_map_example/pages/latlng_to_screen_point.dart'; @@ -79,6 +80,8 @@ class MyApp extends StatelessWidget { TileBuilderPage.route: (context) => const TileBuilderPage(), ErrorTileBuilder.route: (context) => const ErrorTileBuilder(), InteractiveFlagsPage.route: (context) => const InteractiveFlagsPage(), + FlingAnimationDampingPage.route: (context) => + const FlingAnimationDampingPage(), ManyMarkersPage.route: (context) => const ManyMarkersPage(), MapInsideListViewPage.route: (context) => const MapInsideListViewPage(), ResetTileLayerPage.route: (context) => const ResetTileLayerPage(), diff --git a/example/lib/pages/fling_animation_damping.dart b/example/lib/pages/fling_animation_damping.dart new file mode 100644 index 000000000..eb213aaa9 --- /dev/null +++ b/example/lib/pages/fling_animation_damping.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/tile_providers.dart'; +import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; +import 'package:latlong2/latlong.dart'; + +class FlingAnimationDampingPage extends StatefulWidget { + static const String route = '/fling_animation_damping'; + + const FlingAnimationDampingPage({super.key}); + + @override + State createState() => + _FlingAnimationDampingPageState(); +} + +class _FlingAnimationDampingPageState extends State { + double _dampingRatio = 2; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Fling Animation Damping')), + drawer: const MenuDrawer(FlingAnimationDampingPage.route), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Damping Ratio: ${_dampingRatio.toStringAsFixed(1)}', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Drag the map and release to see the fling animation. ' + 'Lower values = less momentum, stops quicker. ' + 'Higher values = more momentum, bouncier feel. ', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 8), + Row( + children: [ + const Text('Damped (1)'), + Expanded( + child: Slider( + value: _dampingRatio, + min: 1, + max: 10, + divisions: 19, + label: _dampingRatio.toStringAsFixed(1), + onChanged: (value) { + setState(() { + _dampingRatio = value; + }); + }, + ), + ), + const Text('Damped (10)'), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: () => setState(() => _dampingRatio = 1), + child: const Text('Very Damped (1)'), + ), + ElevatedButton( + onPressed: () => setState(() => _dampingRatio = 2), + child: const Text('Damped (2)'), + ), + ElevatedButton( + onPressed: () => setState(() => _dampingRatio = 5), + child: const Text('Default (5)'), + ), + ElevatedButton( + onPressed: () => setState(() => _dampingRatio = 7), + child: const Text('Bouncy (4)'), + ), + ElevatedButton( + onPressed: () => setState(() => _dampingRatio = 10), + child: const Text('Very Bouncy (10)'), + ), + ], + ), + ], + ), + ), + Expanded( + child: FlutterMap( + options: MapOptions( + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 11, + interactionOptions: InteractionOptions( + flags: InteractiveFlag.all, + flingAnimationDampingRatio: _dampingRatio, + ), + ), + children: [ + openStreetMapTileLayer, + Center( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Drag and release to see the fling effect!', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index cb55795a0..45c170832 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -11,6 +11,7 @@ import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dar import 'package:flutter_map_example/pages/epsg3996_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; +import 'package:flutter_map_example/pages/fling_animation_damping.dart'; import 'package:flutter_map_example/pages/home.dart'; import 'package:flutter_map_example/pages/interactive_test_page.dart'; import 'package:flutter_map_example/pages/latlng_to_screen_point.dart'; @@ -190,6 +191,11 @@ class MenuDrawer extends StatelessWidget { routeName: InteractiveFlagsPage.route, currentRoute: currentRoute, ), + MenuItemWidget( + caption: 'Fling Animation Damping', + routeName: FlingAnimationDampingPage.route, + currentRoute: currentRoute, + ), const Divider(), MenuItemWidget( caption: 'WMS Sourced Map', diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index ab9f8f07f..697682a24 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -78,6 +78,7 @@ class MapInteractiveViewerState extends State late double _lastRotation; late double _lastScale; late Offset _lastFocalLocal; + late Offset _prevFocalLocal; late LatLng _mapCenterStart; late double _mapZoomStart; late Offset _focalStartLocal; @@ -539,7 +540,8 @@ class MapInteractiveViewerState extends State _mapZoomStart = _camera.zoom; _mapCenterStart = _camera.center; - _focalStartLocal = _lastFocalLocal = details.localFocalPoint; + _focalStartLocal = + _lastFocalLocal = _prevFocalLocal = details.localFocalPoint; _focalStartLatLng = _camera.offsetToCrs(_focalStartLocal); _dragStarted = false; @@ -567,6 +569,7 @@ class MapInteractiveViewerState extends State _lastRotation = currentRotation; _lastScale = details.scale; + _prevFocalLocal = _lastFocalLocal; _lastFocalLocal = details.localFocalPoint; } @@ -789,13 +792,28 @@ class MapInteractiveViewerState extends State return; } - final direction = details.velocity.pixelsPerSecond / magnitude; + // Use the tracked offset to determine direction instead of the velocity + // direction, which can be incorrect on web when the pointer leaves the + // window. Use the final segment direction to correctly handle curved + // gestures where the user changes direction during the drag. + final flingOffset = _focalStartLocal - _lastFocalLocal; + final finalSegment = _prevFocalLocal - _lastFocalLocal; + final finalSegmentDistance = finalSegment.distance; + + // Use final segment direction if available, otherwise fall back to overall + // direction for edge cases where the final segment has no movement. + final Offset direction; + if (finalSegmentDistance > 0) { + direction = finalSegment / finalSegmentDistance; + } else { + final flingDistance = flingOffset.distance; + direction = flingOffset / flingDistance; + } final distance = (Offset.zero & _camera.nonRotatedSize).shortestSide; - final flingOffset = _focalStartLocal - _lastFocalLocal; _flingAnimation = Tween( begin: flingOffset, - end: flingOffset - direction * distance, + end: flingOffset + direction * distance, ).animate(_flingController); _flingController @@ -805,7 +823,7 @@ class MapInteractiveViewerState extends State springDescription: SpringDescription.withDampingRatio( mass: 1, stiffness: 1000, - ratio: 5, + ratio: _interactionOptions.flingAnimationDampingRatio, )); } diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index 8908f30a8..d0e46d995 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -88,6 +88,15 @@ class InteractionOptions { /// Defaults to [Curves.fastOutSlowIn]. final Curve doubleTapZoomCurve; + /// The damping ratio for the fling animation spring simulation + /// + /// This controls how the fling animation decelerates after a drag gesture. + /// Lower values result in less damping (more momentum, bouncier). + /// Higher values result in more damping (stops quicker, less bouncy). + /// + /// Defaults to 5.0. + final double flingAnimationDampingRatio; + /// Options to configure cursor/keyboard rotation /// /// Cursor/keyboard rotation is designed for desktop platforms, and allows the @@ -129,6 +138,7 @@ class InteractionOptions { defaultDoubleTapDragZoomChangeCalculator, this.doubleTapZoomDuration = const Duration(milliseconds: 200), this.doubleTapZoomCurve = Curves.fastOutSlowIn, + this.flingAnimationDampingRatio = 5.0, this.cursorKeyboardRotationOptions = const CursorKeyboardRotationOptions(), this.keyboardOptions = const KeyboardOptions(), }) : assert( @@ -142,6 +152,10 @@ class InteractionOptions { assert( pinchMoveThreshold >= 0.0, '`pinchMoveThreshold` must be positive', + ), + assert( + flingAnimationDampingRatio > 0.0, + '`flingAnimationDampingRatio` must be positive', ); /// Default calculator function for [doubleTapDragZoomChangeCalculator] @@ -171,6 +185,7 @@ class InteractionOptions { other.doubleTapDragZoomChangeCalculator && doubleTapZoomDuration == other.doubleTapZoomDuration && doubleTapZoomCurve == other.doubleTapZoomCurve && + flingAnimationDampingRatio == other.flingAnimationDampingRatio && keyboardOptions == other.keyboardOptions; @override @@ -188,6 +203,7 @@ class InteractionOptions { doubleTapDragZoomChangeCalculator, doubleTapZoomDuration, doubleTapZoomCurve, + flingAnimationDampingRatio, keyboardOptions, ); }