Maps, Location & Device Features

Showing the User's Location on the Map

16 min Lesson 7 of 12

Showing the User's Location on the Map

One of the most compelling features in any location-aware app is displaying where the user is right now — and keeping that indicator moving in real time as they travel. In this lesson you will wire together two packages you already know — geolocator and google_maps_flutter — to centre the camera on the user's live position, enable the built-in My Location layer, and drive a custom marker that updates on every new GPS fix.

Why Not Just Call getCurrentPosition Once?

Fetching a single position on startup is fine for one-shot use cases (e.g. "find restaurants near me"), but for a live tracking experience you need a continuous stream. Geolocator.getPositionStream() returns a Stream<Position> that emits a new event whenever the device moves beyond your configured distance filter. Each event carries fresh latitude, longitude, accuracy, speed, and heading fields.

Note: myLocationEnabled: true on the GoogleMap widget renders Google's built-in blue-dot layer and the "centre on me" button. You still need to manage your own StreamSubscription if you want to react to position changes in Dart (e.g. animate the camera, update a database, or display an accuracy circle).

Setting Up the GoogleMapController

To programmatically move the camera you need a reference to the GoogleMapController that is handed to you in the onMapCreated callback. Store it in a Completer so any subsequent code can await it safely:

Storing the Map Controller

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';

class LiveLocationMap extends StatefulWidget {
  const LiveLocationMap({super.key});

  @override
  State<LiveLocationMap> createState() => _LiveLocationMapState();
}

class _LiveLocationMapState extends State<LiveLocationMap> {
  final Completer<GoogleMapController> _mapController = Completer();
  StreamSubscription<Position>? _positionSub;
  LatLng _currentLatLng = const LatLng(0, 0);
  Set<Marker> _markers = {};

  @override
  void initState() {
    super.initState();
    _startTracking();
  }

  @override
  void dispose() {
    _positionSub?.cancel();
    super.dispose();
  }
  // ...
}

Using a Completer is important because onMapCreated fires asynchronously after the widget is first rendered. Any code that calls _mapController.future will simply wait until the controller is ready, avoiding null-reference crashes.

Subscribing to the Position Stream

Call Geolocator.getPositionStream() inside initState (after confirming permissions, which you handled in Lesson 5). Supply a LocationSettings object to tune the accuracy and distance filter. On each emission, animate the map camera and refresh the marker inside setState:

Stream-Based Live Tracking

Future<void> _startTracking() async {
  // Assumes permission is already granted (see Lesson 5)
  const locationSettings = LocationSettings(
    accuracy: LocationAccuracy.high,
    distanceFilter: 10, // metres between updates
  );

  _positionSub = Geolocator.getPositionStream(
    locationSettings: locationSettings,
  ).listen((Position position) async {
    final latLng = LatLng(position.latitude, position.longitude);

    // Animate the camera to follow the user
    final controller = await _mapController.future;
    await controller.animateCamera(
      CameraUpdate.newLatLng(latLng),
    );

    // Update the marker and rebuild the widget
    setState(() {
      _currentLatLng = latLng;
      _markers = {
        Marker(
          markerId: const MarkerId('user_location'),
          position: latLng,
          infoWindow: InfoWindow(
            title: 'You are here',
            snippet:
                '${position.latitude.toStringAsFixed(5)}, '
                '${position.longitude.toStringAsFixed(5)}',
          ),
        ),
      };
    });
  });
}
Tip: Keep distanceFilter above 0 (e.g. 10 m) to avoid a flood of near-identical events that waste battery and trigger unnecessary rebuilds. For pedestrian navigation 10–15 m is a good default; for vehicle tracking 30–50 m is typical.

Building the GoogleMap Widget

Pass the controller completer, enable the My Location layer, and supply the markers set. Setting myLocationButtonEnabled: false hides Google's default re-centre button so you can provide your own UI if desired:

GoogleMap Widget with myLocationEnabled

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('Live Location')),
    body: GoogleMap(
      onMapCreated: (GoogleMapController controller) {
        if (!_mapController.isCompleted) {
          _mapController.complete(controller);
        }
      },
      initialCameraPosition: CameraPosition(
        target: _currentLatLng,
        zoom: 16,
      ),
      myLocationEnabled: true,        // blue dot + accuracy ring
      myLocationButtonEnabled: false, // hide default button
      markers: _markers,
      zoomControlsEnabled: true,
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: _centreOnUser,
      child: const Icon(Icons.my_location),
    ),
  );
}

Future<void> _centreOnUser() async {
  final controller = await _mapController.future;
  await controller.animateCamera(
    CameraUpdate.newLatLngZoom(_currentLatLng, 16),
  );
}

Handling Errors and Edge Cases

  • Permission denied at runtime: wrap _startTracking() with a try/catch for PermissionDeniedException and show a snack bar.
  • Location services disabled: catch LocationServiceDisabledException and prompt the user to open Settings.
  • Stream error events: supply the optional onError parameter to the listen call to avoid unhandled exceptions silently killing the stream.
  • Widget disposed before controller ready: check mounted before calling setState after any await.
Warning: Always call _positionSub?.cancel() inside dispose(). Forgetting to cancel the subscription keeps the GPS sensor active after the widget is removed from the tree, draining the user's battery and potentially causing setState-on-disposed-widget errors.

Summary

In this lesson you learned how to combine geolocator's position stream with google_maps_flutter's camera API to build a live user-location tracker. The key steps are: obtain a GoogleMapController via a Completer, subscribe to Geolocator.getPositionStream(), animate the camera on each emission, update the marker inside setState, enable myLocationEnabled: true for the built-in blue dot, and always cancel the subscription in dispose(). With these building blocks you can extend the map to show accuracy circles, breadcrumb trails, or turn-by-turn overlays.