Maps and live location: Flutter best practices explained
Building a ride-tracking client teaches you quickly that maps and live location aren't just about dropping a pin on a screen. We've learned that the difference between a smooth, responsive tracking experience and a battery-draining, janky mess comes down to how you handle GPS data, animation, permissions, and map updates. In this article, we share the patterns we've refined through production experience with location-heavy features.
Choosing your map widget: Google Maps vs. Mapbox
Your first decision is whether to use Google Maps or Mapbox. Both have solid Flutter plugins, but they differ in cost structure, customization, and offline capabilities.
Google Maps is the default choice for most teams. The google_maps_flutter plugin is mature, well-documented, and handles both Android and iOS without surprises. You get real-time traffic, satellite imagery, and a familiar API. The downside is cost: Google charges per request at scale, and you need API keys for both platforms.
Mapbox excels when you need offline maps, custom styling, or more granular control. Its vector-based approach means smaller tile sizes and snappier interactions. Mapbox is also price-competitive at high volumes. The trade-off is that the Flutter integration is slightly less mature, though still production-ready.
For a ride-tracking scenario, we typically go with Google Maps because traffic data is valuable and the integration cost is lower. Here's a minimal setup:
import 'package:google_maps_flutter/google_maps_flutter.dart';
class RideMapScreen extends StatefulWidget {
@override
State<RideMapScreen> createState() => _RideMapScreenState();
}
class _RideMapScreenState extends State<RideMapScreen> {
late GoogleMapController _mapController;
final Set<Marker> _markers = {};
void _onMapCreated(GoogleMapController controller) {
_mapController = controller;
}
@override
Widget build(BuildContext context) {
return GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: CameraPosition(
target: LatLng(40.7128, -74.0060),
zoom: 15,
),
markers: _markers,
compassEnabled: true,
myLocationEnabled: true,
);
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
}Set up your API keys in Android's AndroidManifest.xml and iOS's Info.plist before building. Test on physical devices; the emulator's map rendering can hide real performance issues.
Smooth markers: Animating GPS points instead of jumping
Raw GPS updates jump. Your rider appears at one location, then instantly teleports to the next. Users notice this immediately and it feels broken. The solution is to interpolate between GPS points over time, creating smooth motion.
We use two strategies: linear interpolation for frequent updates and Tween animation for less frequent ones.
With linear interpolation, you calculate a path between the current position and the next GPS fix, then update the marker incrementally:
class LocationAnimator {
final Duration _duration;
late Timer _animationTimer;
LatLng _currentPosition;
late LatLng _targetPosition;
LocationAnimator({Duration duration = const Duration(milliseconds: 800)})
: _duration = duration,
_currentPosition = LatLng(40.7128, -74.0060),
_targetPosition = LatLng(40.7128, -74.0060);
void animateToNewLocation(LatLng newLocation,
Function(LatLng) onUpdate) {
_targetPosition = newLocation;
_animationTimer?.cancel();
final startPosition = _currentPosition;
final startTime = DateTime.now();
_animationTimer = Timer.periodic(Duration(milliseconds: 16), (timer) {
final elapsed = DateTime.now().difference(startTime);
final progress = (elapsed.inMilliseconds / _duration.inMilliseconds)
.clamp(0.0, 1.0);
_currentPosition = LatLng(
startPosition.latitude +
(newLocation.latitude - startPosition.latitude) * progress,
startPosition.longitude +
(newLocation.longitude - startPosition.longitude) * progress,
);
onUpdate(_currentPosition);
if (progress >= 1.0) {
timer.cancel();
}
});
}
void dispose() {
_animationTimer?.cancel();
}
}Adjust the _duration based on your update frequency. If you receive GPS updates every 2 seconds, use an 800ms animation so positions transition smoothly without overlapping. If updates are sparse, extend the animation. The 16ms timer tick matches 60fps; use Duration(milliseconds: 8) for 120fps displays.
For even smoother results on modern phones, integrate with AnimationController and Tween:
class SmoothMarkerWidget extends StatefulWidget {
final LatLng targetLocation;
final Duration duration;
const SmoothMarkerWidget({
required this.targetLocation,
this.duration = const Duration(milliseconds: 800),
});
@override
State<SmoothMarkerWidget> createState() => _SmoothMarkerWidgetState();
}
class _SmoothMarkerWidgetState extends State<SmoothMarkerWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _latAnimation;
late Animation<double> _lngAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_startAnimation();
}
void _startAnimation() {
_latAnimation = Tween<double>(
begin: widget.targetLocation.latitude,
end: widget.targetLocation.latitude,
).animate(_controller);
_lngAnimation = Tween<double>(
begin: widget.targetLocation.longitude,
end: widget.targetLocation.longitude,
).animate(_controller);
_controller.forward();
}
@override
void didUpdateWidget(SmoothMarkerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.targetLocation != widget.targetLocation) {
_controller.reset();
_startAnimation();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([_latAnimation, _lngAnimation]),
builder: (context, child) {
return Marker(
position: LatLng(_latAnimation.value, _lngAnimation.value),
infoWindow: InfoWindow(title: 'Driver'),
);
},
);
}
}This approach leverages Flutter's animation framework for GPU-accelerated motion, reducing CPU load compared to manual timer loops.
Battery-aware updates: Distance filters and adaptive intervals
Constant GPS polling drains batteries fast. On a 30-minute ride, aggressive location updates can consume 15–20% of battery. We balance accuracy with power efficiency using distance filters and adaptive update intervals.
Distance filtering means: only trigger a location update if the device has moved more than X meters. This cuts unnecessary processing. Most location plugins support this natively:
import 'package:geolocator/geolocator.dart';
class LocationStreamManager {
Stream<Position> startTracking() {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 5, // Update only after 5m movement
timeLimit: Duration(seconds: 10), // Force update every 10s
),
);
}
}The distanceFilter: 5 means the callback fires only when the device moves at least 5 meters. For ride-tracking, this is usually enough; pedestrian tracking might use 2–3 meters.
Adaptive intervals
class AdaptiveLocationManager {
final LocationStreamManager _locStream = LocationStreamManager();
StreamSubscription<Position>? _subscription;
void startAdaptiveTracking(Function(Position) onLocation) {
_subscription = _locStream.startTracking().listen((position) {
final speed = position.speed ?? 0;
if (speed > 5) {
// Moving fast: use tight distance filter
_updateDistanceFilter(5);
} else if (speed > 1) {
// Moving slowly: moderate filter
_updateDistanceFilter(10);
} else {
// Stationary: increase filter to save battery
_updateDistanceFilter(25);
}
onLocation(position);
});
}
void _updateDistanceFilter(int meters) {
// Restart the stream with new settings
_subscription?.cancel();
_subscription = _locStream.startTracking().listen((position) {
// Update handler
});
}
void stop() {
_subscription?.cancel();
}
}In production, we also stop tracking entirely when the trip ends. Leaving location services running after the ride is a common battery leak. Always clean up subscriptions in your state's dispose() method.
Permissions: Explain why you need location before asking
Users reject location requests they don't understand. Before you call requestPermission(), show a brief explanation of why you need it. This improves acceptance rates significantly.
Use permission_handler to manage both iOS and Android permissions consistently:
import 'package:permission_handler/permission_handler.dart';
class LocationPermissionManager {
Future<bool> requestLocationPermission(BuildContext context) async {
final status = await Permission.location.request();
if (status.isDenied) {
// Show explanation dialog
_showPermissionExplanation(context);
return false;
} else if (status.isGranted) {
return true;
} else if (status.isDenied) {
return false;
}
return status.isGranted;
}
void _showPermissionExplanation(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Location Access Needed'),
content: Text(
'We need your location to track your ride in real time '
'and show your driver\'s position on the map.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
TextButton(
onPressed: () {
openAppSettings();
Navigator.pop(context);
},
child: Text('Open Settings'),
),
],
),
);
}
}On Android, declare android.permission.ACCESS_FINE_LOCATION and android.permission.ACCESS_COARSE_LOCATION in your manifest. On iOS, add NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription to your Info.plist.
Request "when in use" permission (Permission.locationWhenInUse) rather than "always" unless you have a specific background use case. Users are more likely to grant temporary access. If you need background tracking, explain that too.
Common mistakes
- Not disposing of map controllers and location streams. Memory leaks accumulate fast with live data. Always call
dispose()in your State and cancel StreamSubscriptions. - Animating every GPS update with a long duration. If updates arrive every 2 seconds and your animation runs for 5 seconds, markers will overlap. Keep animation duration shorter than your update interval.
- Setting distance filters too small. A 1-meter filter on a highway is wasteful. Use 5–10 meters for vehicles, 2–3 for pedestrians.
- Requesting location permission on app launch. Ask at the moment you actually need it (e.g., when booking a ride), not at startup. This improves acceptance rates.
- Ignoring battery impact in testing. Test on real devices with low battery; emulators hide power drain issues.
Key takeaways
- Choose Google Maps for traffic data and simplicity; Mapbox for offline maps and custom styling.
- Interpolate between GPS points to avoid jumpy markers; use Tween animations for GPU acceleration.
- Apply distance filters and adaptive intervals based on speed to balance accuracy and battery life.
- Always explain permission requests to users before asking, and request only what you need ("when in use" over "always").
- Dispose of map controllers, cancel location streams, and test on real hardware to catch battery leaks early.






Comments
Loading comments…