16 - Google Maps in Compose
Recap
In 15 - Foreground Service the TrackingService started collecting locations in the background and writing them into TrackingRepository. The data is there. Positions, the track, checkpoints and the waypoint flow through a StateFlow<TrackingState>, but so far only the statistics row shows them. This lecture puts them on a map. It covers the Google Maps SDK through the maps-compose library, the API key, camera control for the follow and orientation modes the final project requires, markers for CPs and the WP, the track as a pace-coloured polyline, and replaying old sessions.
By the end of this lecture you should be able to:
- Create and restrict a Maps API key and keep it out of git with the Secrets Gradle plugin.
- Show a
GoogleMapcomposable and configure it withMapPropertiesandMapUiSettings. - Control the camera with
CameraPositionState: keep the map centered, rotate it north-up, direction-up or user-chosen, and detect user gestures. - Draw CP and WP markers and the live track as a
Polyline. - Colour track segments by pace with
StyleSpans and gradients. - Fit the camera to an old session and keep long tracks fast.
Lecture demos: native-2026-fall
Maps SDK and maps-compose
The Maps SDK for Android (play-services-maps) renders Google Maps inside a classic MapView. maps-compose wraps that view in composables: GoogleMap for the map, plus Marker, Polyline, Circle and others that live inside the map's content lambda. Since maps-compose 9.0 the artifact brings the Maps SDK and the core of android-maps-utils with it, so you don't declare them separately. (9.0 replaced the old android-maps-ktx helpers with android-maps-utils. Imports from com.google.maps.android.ktx in old tutorials have moved to com.google.maps.android.)
# libs.versions.toml
[libraries]
maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "mapsCompose" }
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "mapsCompose" }
[plugins]
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
// app/build.gradle.kts
dependencies {
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils) // optional: clustering, WMS tile overlays
}
maps-compose 9 is compiled for JVM target 17. With the wizard's JavaVersion.VERSION_11 the build fails with "Cannot inline bytecode built with JVM target 17 into bytecode that is being built with JVM target 11". Set both sourceCompatibility and targetCompatibility in compileOptions to JavaVersion.VERSION_17. With AGP 9's built-in Kotlin, Kotlin's jvmTarget follows targetCompatibility automatically.
The Maps SDK needs Google Play services on the device. On the emulator, pick a system image with Google Play or Google APIs.
The API key
Every app that shows Google Maps needs an API key from a Google Cloud project:
- Google Cloud console → create a project (Maps Platform requires a billing account on it; native Android map loads are not charged, but other APIs like Directions or Places are).
- APIs & Services → Library → enable Maps SDK for Android.
- APIs & Services → Credentials → Create credentials → API key.
- Restrict the key:
- Application restrictions: Android apps, then add your package name and the SHA-1 of the signing certificate.
- API restrictions: only Maps SDK for Android.
The SHA-1 depends on the keystore that signs the APK. Debug builds are signed with ~/.android/debug.keystore, which is different on every computer:
./gradlew signingReport # prints SHA-1 for every variant
# or
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
Add one SHA-1 entry per developer machine, one for your release keystore, and, if you publish through Google Play, the app signing key SHA-1 from Play Console (Play re-signs your app).
An unrestricted key that ends up in a public git repository will be found and abused within hours. Restrictions are the real protection: the key is always recoverable from the APK. Keeping it out of git (next section) just stops accidental leaks.
A grey map with only the Google logo means the key is wrong or restricted for this package/SHA-1. The reason is in Logcat (filter for Google Android Maps SDK).
Secrets Gradle plugin
The plugin reads keys from a properties file and exposes them as manifest placeholders (and optionally as BuildConfig fields).
// build.gradle.kts (top level)
plugins {
alias(libs.plugins.secrets) apply false
}
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets)
}
secrets {
propertiesFileName = "secrets.properties" // real key, NOT in git
defaultPropertiesFileName = "local.defaults.properties" // placeholder, in git
}
# secrets.properties (project root) - add to .gitignore
MAPS_API_KEY=AIzaSy...your-key...
# local.defaults.properties (project root) - committed, lets a fresh clone build
MAPS_API_KEY=DEFAULT_API_KEY
<!-- AndroidManifest.xml, inside <application> -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
Your teammates and the TA reviewing your code create their own secrets.properties. The build still works without it: the map just stays grey.
The first map
private val Tallinn = LatLng(59.4370, 24.7536)
@Composable
fun SimpleMap(modifier: Modifier = Modifier) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(Tallinn, 14f)
}
GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
) {
Marker(
state = rememberUpdatedMarkerState(position = Tallinn),
title = "Tallinn",
snippet = "Start here",
)
}
}
GoogleMapis a normal composable for layout purposes: give it a size with modifiers.- Everything inside its trailing lambda is map content (markers, polylines, circles, tile overlays). Map content composables can only be called there. They are marked with
@GoogleMapComposable. rememberCameraPositionStateusesrememberSaveable, so the camera survives rotation and process death.- In
@Previewthe map renders as an empty box. Test on a device or emulator.
MapProperties and MapUiSettings
Two immutable classes configure the map. Change a value by passing a new instance, typically derived from your UI state:
val properties = MapProperties(
mapType = MapType.TERRAIN, // NORMAL, SATELLITE, HYBRID, TERRAIN, NONE
isMyLocationEnabled = hasLocationPermission,
)
val uiSettings = MapUiSettings(
compassEnabled = false, // the final project needs a REAL compass (lecture 17)
myLocationButtonEnabled = false, // we have our own "re-center" button
zoomControlsEnabled = false,
mapToolbarEnabled = false,
rotationGesturesEnabled = settings.mapOrientation == MapOrientation.USER_CHOSEN,
)
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = properties,
uiSettings = uiSettings,
)
isMyLocationEnabled = trueshows the blue dot from the Maps SDK's own location provider. Without a granted location permission it throws aSecurityException, so derive it from the permission state from lecture 12. In SportMap you can also skip the blue dot and draw the last track point yourself, so the map shows exactly the filtered positions your statistics use.- The built-in compass button is only a rotation indicator. It appears while the map is rotated and resets it to north. The final project explicitly says it is not a compass, so turn it off.
TERRAINshows contour shading, which is the closest Google Maps gets to an orienteering map.- Since maps-compose 8.6 the map follows the system dark theme (
mapColorScheme = ComposeMapColorScheme.FOLLOW_SYSTEMis the default). PassLIGHTorDARKto override, orMapStyleOptionsinMapPropertiesfor a fully custom style.
The camera
The camera is described by a CameraPosition:
| Field | Meaning |
|---|---|
target | LatLng at the center of the screen |
zoom | 2 (world) … 21 (buildings); 15–17 is good for running |
bearing | degrees clockwise from true north that point to the top of the screen |
tilt | 0 (straight down) … 90 |
CameraPositionState is the single source of truth for the camera. Read position to observe it, and change it with:
move(update): instant;animate(update, durationMs): asuspendfunction that completes when the animation ends and is cancelled if the calling coroutine is cancelled. Call it from aLaunchedEffect.
CameraUpdates come from CameraUpdateFactory: newLatLng, newLatLngZoom, zoomIn, newCameraPosition, newLatLngBounds...
Camera updates that depend on the map's size (newLatLngBounds) fail before the map has been laid out. Track onMapLoaded in a state and start camera effects only after it.
Follow and orientation modes
The final project asks for two independent toggles. One is keep the map centered on the current position. The other is the orientation: north-up, direction-up or user-chosen. Settings.keepCentered and Settings.mapOrientation from lecture 10 are the user's defaults. While the map is open, the ViewModel keeps a transient following flag that starts from keepCentered (see the map screen). One function computes the camera for both:
fun followCamera(
current: CameraPosition,
position: LatLng?,
keepCentered: Boolean,
orientation: MapOrientation,
headingDeg: Float?,
): CameraPosition {
val target = if (keepCentered && position != null) position else current.target
val bearing = when (orientation) {
MapOrientation.NORTH_UP -> 0f
MapOrientation.DIRECTION_UP -> headingDeg ?: current.bearing
MapOrientation.USER_CHOSEN -> current.bearing // whatever the user rotated to
}
return CameraPosition.Builder(current).target(target).bearing(bearing).build()
}
@Composable
fun FollowCameraEffect(
cameraPositionState: CameraPositionState,
isMapLoaded: Boolean,
position: LatLng?,
keepCentered: Boolean,
orientation: MapOrientation,
headingDeg: Float?,
) {
LaunchedEffect(isMapLoaded, position, keepCentered, orientation, headingDeg) {
if (!isMapLoaded) return@LaunchedEffect
val current = cameraPositionState.position
val wanted = followCamera(current, position, keepCentered, orientation, headingDeg)
if (wanted.target != current.target || wanted.bearing != current.bearing) {
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(wanted), 500)
}
}
}
When a new position arrives, the effect restarts. That cancels a running animation and starts the next one, so the camera never lags behind.
Where does the heading come from? For direction-up, the direction of movement is the natural choice. TrackMath.bearingDeg between recent track points works, as long as they are a few metres apart, because GPS jitter makes very short segments point anywhere. Standing still, there is no recent movement to use. This lecture then keeps the current bearing, and lecture 17 adds the compass as the fallback heading. A compass emits 10–60 values per second, and a 500 ms animation shouldn't restart that often. So the heading is rounded to whole 5° steps and repeated values are dropped (distinctUntilChanged) before it reaches the camera.
The map's bearing is relative to true north, while a magnetic compass measures magnetic north. In Estonia the difference (declination) is about 10°. Lecture 17 shows how to convert with GeomagneticField.
Detecting user gestures
If the user drags the map to look ahead, auto-centering must stop, or the next GPS fix snaps the camera back. CameraPositionState reports why the camera started moving:
LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.isMoving &&
cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE
) {
onUserMovedMap() // ViewModel: following = false
}
}
cameraMoveStartedReason is GESTURE for the user's gestures on the map (pan, pinch, rotate, tilt) and DEVELOPER_ANIMATION for your own animate/move calls, so the follow effect doesn't trigger itself. API_ANIMATION means the SDK moved the camera after a non-gesture user action, such as the zoom buttons, the My Location button or a marker tap. A pinch-zoom is a gesture too. If zooming should keep centering, compare the camera target with the current position when the movement ends instead. Provide a "re-center" button (the "Reset" in the final project's sketch) that turns following back on.
The drag must not write Settings.keepCentered. That setting is the user's default, and a drag is a temporary "let me look around". If every drag were written to DataStore, the next session would start uncentered and the Settings switch would flip by itself.
In north-up and direction-up modes, disable rotation gestures (rotationGesturesEnabled above). In user-chosen mode the user rotates the map with two fingers, and followCamera keeps that bearing.
Markers: checkpoints and the waypoint
@Composable
@GoogleMapComposable
fun CheckpointMarkers(checkpoints: List<GpsLocation>) {
checkpoints.forEachIndexed { index, cp ->
key(cp.recordedAt) {
Marker(
state = rememberUpdatedMarkerState(position = LatLng(cp.latitude, cp.longitude)),
title = "CP ${index + 1}",
snippet = "±${cp.accuracy.roundToInt()} m",
icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN),
)
}
}
}
@Composable
@GoogleMapComposable
fun WaypointMarker(waypoint: GpsLocation?) {
if (waypoint == null) return
Marker(
state = rememberUpdatedMarkerState(position = LatLng(waypoint.latitude, waypoint.longitude)),
title = "WP",
icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE),
)
}
- CPs are permanent, so there are many markers.
key(cp.recordedAt)gives each marker a stable identity. Without it, removing or inserting a CP would move marker state to the wrong element (the same reason lazy lists need keys, lecture 05). - There is only one WP. When the user adds a new one,
TrackingRepositoryreplaces it, the old marker leaves the composition and is removed from the map. rememberUpdatedMarkerState(position)creates the marker state once and updates its position on every recomposition.rememberMarkerStateis deprecated in maps-compose 9 because it did not follow position changes, which confused everyone. Tutorials still use it.BitmapDescriptorFactoryworks only after the Maps SDK is initialized. Calling it inside the map content lambda is safe, while calling it at the top of your screen composable can throwIBitmapDescriptorFactory is not initialized.
For a fully custom marker, draw it with Compose:
MarkerComposable(
cpNumber, // keys: re-render the bitmap when these change
state = rememberUpdatedMarkerState(position = cpLatLng),
) {
Box(
modifier = Modifier.size(32.dp).background(Color(0xFF2E7D32), CircleShape),
contentAlignment = Alignment.Center,
) {
Text("$cpNumber", color = Color.White, fontWeight = FontWeight.Bold)
}
}
MarkerComposable renders its content into a bitmap once per key change. It's not a live composable, so animations don't work inside it, and it costs more than a plain Marker. AdvancedMarker with PinConfig exists too, but needs a Cloud map ID.
The track as a polyline
@Composable
@GoogleMapComposable
fun TrackLine(points: List<LatLng>) {
if (points.size < 2) return
Polyline(
points = points,
color = MaterialTheme.colorScheme.primary,
width = 12f, // pixels, not dp
jointType = JointType.ROUND,
startCap = RoundCap(),
endCap = RoundCap(),
)
}
When points changes, maps-compose updates the existing Maps SDK polyline. It doesn't create a new one. That's cheap for hundreds of points. Long tracks are covered in the Performance section.
Colouring by pace
The final project wants track segments coloured by speed, configurable per live session and per old session ("sometimes optimal range is 4-7, sometimes 7-20 minutes per km"). With the range paceMinSecPerKm..paceMaxSecPerKm (from Settings, or from GpsSession when the old session has its own range), map each segment's pace to a colour:
fun paceColor(secPerKm: Double?, minSecPerKm: Int, maxSecPerKm: Int): Color {
if (secPerKm == null) return Color.Gray
val t = ((secPerKm - minSecPerKm) / (maxSecPerKm - minSecPerKm).toDouble())
.coerceIn(0.0, 1.0).toFloat()
return if (t < 0.5f) {
lerp(Color(0xFF2E7D32), Color(0xFFFDD835), t * 2f) // green -> yellow
} else {
lerp(Color(0xFFFDD835), Color(0xFFC62828), (t - 0.5f) * 2f) // yellow -> red
}
}
lerp is androidx.compose.ui.graphics.lerp. Faster than the minimum pace is green, slower than the maximum is red, and everything in between blends.
A Maps SDK polyline can style each segment separately with StyleSpans. One span per segment, in order:
fun paceSpans(track: List<GpsLocation>, minSecPerKm: Int, maxSecPerKm: Int): List<StyleSpan> {
val colors = track.zipWithNext { a, b ->
paceColor(TrackMath.segmentPaceSecPerKm(a, b), minSecPerKm, maxSecPerKm).toArgb()
}
return colors.mapIndexed { i, color ->
val previous = colors.getOrElse(i - 1) { color }
StyleSpan(StrokeStyle.gradientBuilder(previous, color).build()) // smooth transition
}
}
Polyline(
points = points,
spans = spans, // overload with spans: no single color parameter
width = 14f,
jointType = JointType.ROUND,
)
StyleSpan(colorInt) gives each segment one solid colour. The gradientBuilder variant blends from the previous segment's colour into this one, so the line has no visible steps.
At running speed (about 3 m/s) two fixes 1 s apart are only 3 m apart, so a 3 m position error is a 100% pace error. For colouring, compute the pace over a short window, for example from the point five fixes back or 30 m back, instead of from the previous point. The statistics row (lecture 13) is unaffected, because it uses whole sections.
Old sessions: fit the track
On SessionDetailScreen the map should show the whole recorded track. Build LatLngBounds and let the camera fit them once the map is loaded:
@Composable
fun SessionMap(detail: SessionDetail, settings: Settings, modifier: Modifier = Modifier) {
val points = remember(detail) { detail.locations.map { LatLng(it.latitude, it.longitude) } }
val minPace = detail.session.paceMinSecPerKm ?: settings.paceMinSecPerKm
val maxPace = detail.session.paceMaxSecPerKm ?: settings.paceMaxSecPerKm
val spans = remember(detail, minPace, maxPace) { paceSpans(detail.locations, minPace, maxPace) }
val cameraPositionState = rememberCameraPositionState()
var isMapLoaded by remember { mutableStateOf(false) }
val paddingPx = with(LocalDensity.current) { 48.dp.roundToPx() }
LaunchedEffect(isMapLoaded, points) {
if (!isMapLoaded || points.isEmpty()) return@LaunchedEffect
val update = if (points.size == 1) {
CameraUpdateFactory.newLatLngZoom(points.first(), 16f)
} else {
val bounds = LatLngBounds.builder().apply { points.forEach { include(it) } }.build()
CameraUpdateFactory.newLatLngBounds(bounds, paddingPx)
}
cameraPositionState.animate(update, 800)
}
GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapUiSettings(compassEnabled = false, mapToolbarEnabled = false),
onMapLoaded = { isMapLoaded = true },
) {
if (points.size >= 2) Polyline(points = points, spans = spans, width = 14f)
CheckpointMarkers(detail.locations.filter { it.type == LocationType.CP })
}
}
newLatLngBounds(bounds, padding)takes padding in pixels, hence theLocalDensityconversion.- A single point has no area. Bounds around one point would zoom to the maximum level, so use
newLatLngZoominstead. - After the fit, the user can pan and zoom freely ("allow zooming into the track of old sessions"). The effect only re-runs when the session changes.
Performance with long tracks
A 3-hour session at one fix per second is about 10 000 points. Three things keep the map smooth:
- Recompose the map only for map data. The statistics change every second. Pass
GoogleMaponly what it draws (points, spans, markers, camera inputs), not the wholeTrackingState. The stats row reads its own state, so its recompositions don't touch the map content. - Compute once, off the main thread. Convert
GpsLocationtoLatLngand compute spans in the ViewModel withflowOnon the injected@DefaultDispatcher(below), or inremember(key)for static data. Never in every recomposition. With strong skipping,CheckpointMarkers(checkpoints)is skipped as long as the same list instance arrives, andTrackingState.copy()keeps unchanged lists as the same instance. Composables that take a singleGpsLocation(likeWaypointMarker) are compared withequalsonly ifGpsLocationis stable. It contains ajava.time.Instant, so add thecompose_stability.conffrom lecture 06, which declaresInstantstable. - Simplify for display. The Douglas–Peucker algorithm drops points that don't change the shape:
val displayPoints = remember(points) {
if (points.size < 3) points else PolyUtil.simplify(points, 2.0) // tolerance in metres
}
PolyUtil (from com.google.maps.android, included through maps-compose) is O(n²) in the worst case, so simplify old sessions once, not on every live update. Keep the raw points for statistics and GPX export. For live tracking, a common trick is two polylines: a simplified "frozen" part that you rebuild every few hundred points, plus a short raw tail with the latest fixes.
The SportMap map screen
The ViewModel combines four things into exactly what the map draws (combine from lecture 09): live tracking state, settings, a transient following flag, and the user's current position before a session starts. The heading is a separate flow because it changes on its own schedule. Start/stop handling from lecture 15 (uiState, stop confirmation) is omitted here.
data class MapDrawState(
val points: List<LatLng> = emptyList(),
val spans: List<StyleSpan> = emptyList(),
val checkpoints: List<GpsLocation> = emptyList(),
val waypoint: GpsLocation? = null,
val position: LatLng? = null, // last track point, or the idle fix before a session
val isTracking: Boolean = false,
val showMyLocation: Boolean = false, // Maps SDK blue dot: permission granted and not tracking
val following: Boolean = true,
val orientation: MapOrientation = MapOrientation.NORTH_UP,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MapViewModel @Inject constructor(
private val trackingRepository: TrackingRepository,
private val settingsRepository: SettingsRepository,
locationClient: LocationClient,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, // lecture 08
) : ViewModel() {
private val locationPermission = MutableStateFlow(false)
/** Transient "keep centered": follows the setting, a drag turns it off, Reset turns it on. */
private val following = MutableStateFlow(true)
init {
settingsRepository.settings
.map { it.keepCentered }
.distinctUntilChanged()
.onEach { following.value = it } // initial value + explicit changes in Settings
.launchIn(viewModelScope)
}
/** Position before a session: only while not tracking, with permission, and while the map collects. */
private val idleFix: Flow<GpsLocation?> = combine(
trackingRepository.state.map { it.isTracking }.distinctUntilChanged(),
locationPermission,
) { tracking, granted -> !tracking && granted }
.distinctUntilChanged()
.flatMapLatest { needed ->
if (!needed) flowOf(null)
else locationClient.locationUpdates(intervalMs = 5_000)
.map<Location, GpsLocation?> { it.toGpsLocation() }
.onStart { emit(null) } // combine needs a value before the first fix
.catch { emit(null) } // e.g. permission revoked meanwhile
}
/** Latest known position: the track while tracking, the idle fix otherwise. */
private val currentFix: StateFlow<GpsLocation?> =
combine(trackingRepository.state, idleFix) { tracking, idle -> tracking.track.lastOrNull() ?: idle }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val mapState: StateFlow<MapDrawState> = combine(
trackingRepository.state, settingsRepository.settings, currentFix, following, locationPermission,
) { tracking, settings, fix, isFollowing, granted ->
val track = tracking.track
MapDrawState(
points = track.map { LatLng(it.latitude, it.longitude) },
spans = paceSpans(track, settings.paceMinSecPerKm, settings.paceMaxSecPerKm),
checkpoints = tracking.checkpoints,
waypoint = tracking.waypoint,
position = fix?.let { LatLng(it.latitude, it.longitude) },
isTracking = tracking.isTracking,
showMyLocation = granted && !tracking.isTracking,
following = isFollowing,
orientation = settings.mapOrientation,
)
}
.flowOn(defaultDispatcher) // spans for 10 000 points: CPU work, not main thread
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MapDrawState())
/** Direction-up heading from movement, in 5° steps. Lecture 17 adds the compass fallback. */
val headingDeg: StateFlow<Float?> = trackingRepository.state
.map { movementHeading(it.track)?.roundTo5() }
.distinctUntilChanged()
.flowOn(defaultDispatcher)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun onLocationPermissionChanged(granted: Boolean) { locationPermission.value = granted }
fun onUserMovedMap() { following.value = false }
fun onRecenter() { following.value = true }
/** Bearing of the last 15 s of movement, if it covers at least 5 m. Null when standing still. */
private fun movementHeading(track: List<GpsLocation>): Float? {
val last = track.lastOrNull() ?: return null
val from = track.asReversed().drop(1)
.takeWhile { Duration.between(it.recordedAt, last.recordedAt) <= Duration.ofSeconds(15) }
.firstOrNull { TrackMath.distanceM(it, last) >= 5.0 }
?: return null
return TrackMath.bearingDeg(from, last)
}
private fun Float.roundTo5(): Float = ((this / 5f).roundToInt() * 5 % 360).toFloat()
}
followingis not a setting. It starts fromSettings.keepCenteredand follows it when the user changes it on the Settings screen. Map drags and the Reset button change only the ViewModel's flag.idleFixgives the map a position before tracking starts. It collectsLocationClient(lecture 13) only while the map is visible (WhileSubscribed), the location permission is granted, and nothing is tracked. Once tracking starts,flatMapLateststops it, and the service's filtered track takes over.- The screen reports the permission state from lecture 12 with
onLocationPermissionChanged.
/** The map part of the map screen. MapRoute (lectures 11, 15) places it - lecture 18 shows the whole screen. */
@Composable
fun MapArea(viewModel: MapViewModel, hasLocationPermission: Boolean, modifier: Modifier = Modifier) {
LaunchedEffect(hasLocationPermission) { viewModel.onLocationPermissionChanged(hasLocationPermission) }
val mapState by viewModel.mapState.collectAsStateWithLifecycle()
val heading by viewModel.headingDeg.collectAsStateWithLifecycle()
TrackMap(state = mapState, headingDeg = heading, onUserMovedMap = viewModel::onUserMovedMap, modifier = modifier)
}
@Composable
fun TrackMap(
state: MapDrawState,
headingDeg: Float?,
onUserMovedMap: () -> Unit,
modifier: Modifier = Modifier,
) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(59.4370, 24.7536), 16f)
}
var isMapLoaded by remember { mutableStateOf(false) }
FollowCameraEffect(
cameraPositionState, isMapLoaded, state.position,
state.following, state.orientation, headingDeg,
)
LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.isMoving &&
cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE
) onUserMovedMap()
}
GoogleMap(
modifier = modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = MapProperties(isMyLocationEnabled = state.showMyLocation),
uiSettings = MapUiSettings(
compassEnabled = false,
myLocationButtonEnabled = false,
zoomControlsEnabled = false,
mapToolbarEnabled = false,
rotationGesturesEnabled = state.orientation == MapOrientation.USER_CHOSEN,
),
contentPadding = WindowInsets.safeDrawing.asPaddingValues(),
onMapLoaded = { isMapLoaded = true },
) {
if (state.points.size >= 2) {
Polyline(points = state.points, spans = state.spans, width = 14f, jointType = JointType.ROUND)
}
CheckpointMarkers(state.checkpoints)
WaypointMarker(state.waypoint)
if (state.isTracking) {
state.position?.let { position ->
Circle(center = position, radius = 4.0, fillColor = Color(0xFF1565C0), strokeColor = Color.White, strokeWidth = 4f)
}
}
}
}
- The map draws edge-to-edge, behind the status and navigation bars.
contentPaddingtells the map which area is covered, so the Google logo, attribution and camera centering avoid the system bars. Your own overlays (toolbar buttons, stats row) usesafeDrawingPadding()as in lecture 05. - In landscape the stats move to the side (lecture 07). The map just gets a different size, nothing else changes.
- Before a session, the Maps SDK's blue dot shows where you are, and the camera centers on the idle fix. While tracking, the
Circlemarks the latest filtered position, the one the statistics use.
MapEffect: the raw GoogleMap
maps-compose covers most needs. For the rest, MapEffect hands you the underlying com.google.android.gms.maps.GoogleMap inside a LaunchedEffect:
@OptIn(MapsComposeExperimentalApi::class)
@Composable
@GoogleMapComposable
fun SnapshotEffect(trigger: Int, onSnapshot: (Bitmap) -> Unit) {
MapEffect(trigger) { map ->
if (trigger > 0) map.awaitSnapshot()?.let(onSnapshot) // e.g. attach a track image to an email
}
}
Use it carefully: maps-compose owns the map's listeners and properties. Overriding setOnCameraMoveListener and similar on the raw map breaks CameraPositionState.
Alternatives to Google Maps
- MapLibre Native: open-source vector maps (OpenStreetMap data from a tile provider of your choice), with a Compose wrapper. No Google key, but you need a tile source.
- osmdroid: older, view-based OpenStreetMap library. Use it through
AndroidView. - WMS overlays on top of Google Maps:
WmsTileOverlayinmaps-compose-utilscan draw a national mapping agency's layers (orthophotos, base maps) over the Google base map.
The final project accepts any map. Google Maps is used in these lectures because the Compose integration is the most complete.
In the final project
- Map display, location on the map:
TrackMapwith the latest filtered position, inside a screen that draws edge-to-edge. - "Keep map constantly centered" =
FollowCameraEffectdriven by the ViewModel's transient following flag, which starts fromSettings.keepCentered. A user drag switches it off, and the Reset button switches it on again. The setting itself only changes on the Settings screen. - Position before a session starts: the ViewModel collects
LocationClientwhile the map is visible and nothing is tracked, and the blue dot is on. - "North-up / direction-up / user-chosen-up" =
Settings.mapOrientation, implemented as the camerabearing. Direction-up uses the movement heading, and lecture 17 adds the compass fallback for standing still. - Trail from start to current location =
PolylineofTrackingState.track. - CPs (permanent) and WP (single) = keyed markers and one optional marker.
- Gradient colouring by pace, configurable per session =
paceSpanswith the range fromSettingsorGpsSession. - Old sessions with zoom =
SessionMapwithnewLatLngBounds. - The built-in Maps compass is disabled, and lecture 17 adds a real one.
Self preparation QA
- What two restrictions should a Maps API key have, and why do you still keep it out of git? — An application restriction (package name + SHA-1 of the signing certificate) and an API restriction (Maps SDK for Android only). Git exposure invites abuse of quota, and the Secrets plugin prevents accidental commits. The restrictions are what actually protect the key, because it can always be extracted from the APK.
- Why does the map work on your laptop but show grey tiles on a teammate's? — Debug builds are signed with each machine's own debug keystore. The teammate's SHA-1 isn't in the key's restriction list, or they have no
secrets.properties. - What is the difference between
CameraPositionState.moveandanimate? —movechanges the camera instantly.animateis a suspend function that animates, completes when done, and is cancelled with its coroutine. - How do you implement north-up, direction-up and user-chosen orientation? — Through the camera bearing: 0 for north-up, the movement (or compass) heading for direction-up, and the current camera bearing, which the user sets with rotation gestures, for user-chosen.
- How do you stop auto-centering when the user drags the map, without your own animations triggering it? — Watch
cameraPositionState.isMovingand checkcameraMoveStartedReason == GESTURE. Your ownanimate/movecalls reportDEVELOPER_ANIMATION.API_ANIMATIONis for SDK-driven moves after non-gesture user actions like the zoom or My Location buttons. The drag switches off a transient flag in the ViewModel, notSettings.keepCentered, which stays the user's default. - Why should CP markers be wrapped in
key(...), and why isrememberMarkerStatedeprecated? — Keys keep each marker's state attached to the right checkpoint when the list changes.rememberMarkerStatedidn't update when the position argument changed, sorememberUpdatedMarkerStatereplaced it. - How do you colour each track segment by pace? — Compute a colour per segment from its pace and the configured range, then pass one
StyleSpanper segment (optionally gradients) toPolyline(points, spans). - Why can
newLatLngBoundscrash, and what is its padding unit? — It needs the map's size, so call it only afteronMapLoaded. The padding is in pixels. - Name three measures that keep a 3-hour track smooth on the map. — Pass the map only map data so stats ticks don't recompose it, compute
LatLngs and spans once off the main thread, and simplify long tracks for display withPolyUtil.simplify. - Why is the Maps SDK's built-in compass not enough for the final project? — It's only a rotation indicator that resets the map to north. It doesn't measure the device's heading with sensors.