Google Maps (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Google Maps’ visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (floating search bar, Directions FAB, location dot, teardrop pin, bottom-sheet drawer, turn card), Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? Google Maps is a Material app — the iOS build is Material wearing SF Pro. On Android this guide goes home: the real Maps Compose SDK (
com.google.maps.android:maps-compose) draws the map, a trueFloatingActionButtonis the Directions action, the drawer isBottomSheetScaffoldwith anchored detents, andsp/dpreplacept. Because it is Material-native, this is one of the few apps where you should embracedynamicColorScheme()for chrome (see §8).
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, maps-compose4.3+ for the map surface, and Coil 2.6+ for place photos.
1. Color Tokens
// ui/theme/GMapsColors.kt
import androidx.compose.ui.graphics.Color
object GMapsColors {
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceMuted = Color(0xFFF1F3F4)
val Divider = Color(0xFFDADCE0)
// Text
val TextPrimary = Color(0xFF202124)
val TextSecondary = Color(0xFF5F6368)
val TextTertiary = Color(0xFF80868B)
// Google logo colors (the pin system)
val Blue = Color(0xFF4285F4)
val BluePressed = Color(0xFF1A73E8)
val BlueDark = Color(0xFF174EA6) // route casing
val Red = Color(0xFFEA4335) // default pin
val Yellow = Color(0xFFFBBC04) // rating star / alerts
val Green = Color(0xFF34A853) // saved pin
val Orange = Color(0xFFFB8C00) // category pin / Pegman
// Blue ramp
val BlueHalo = Color(0x2E4285F4) // rgba(66,133,244,0.18) accuracy fill
val BlueHaloEdge = Color(0x664285F4) // rgba(66,133,244,0.40) accuracy outline
// Map tiles (light)
val RoadWhite = Color(0xFFFFFFFF)
val HighwayYellow = Color(0xFFFDF6E3)
val WaterBlue = Color(0xFFAADAFF)
val ParkGreen = Color(0xFFC8E6C9)
val BuildingFill = Color(0xFFF0F0F0)
val LandNeutral = Color(0xFFF5F5F5)
// Traffic overlay
val TrafficRed = Color(0xFFD50000)
val TrafficOrange = Color(0xFFFB8C00)
val TrafficGreen = Color(0xFF0F9D58)
// Dark Mode (Google's warm near-black, NOT pure black)
val DarkCanvas = Color(0xFF202124)
val DarkSurface1 = Color(0xFF2D2E31)
val DarkSurface2 = Color(0xFF3C4043)
val DarkDivider = Color(0xFF3C4043)
val DarkText = Color(0xFFE8EAED)
val DarkTextSec = Color(0xFF9AA0A6)
// Dark map tiles
val DarkMapLand = Color(0xFF242F3E)
val DarkMapRoad = Color(0xFF38414E)
val DarkMapWater = Color(0xFF17263C)
val DarkMapPark = Color(0xFF023E58)
}
Wire it into a Material 3 scheme. Dark mode is #202124; the map itself uses a JSON map style, not the color scheme.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import android.os.Build
private val GMapsLight = lightColorScheme(
primary = GMapsColors.Blue,
onPrimary = Color.White,
background = GMapsColors.Canvas,
onBackground = GMapsColors.TextPrimary,
surface = GMapsColors.Canvas,
onSurface = GMapsColors.TextPrimary,
surfaceVariant = GMapsColors.SurfaceMuted,
secondaryContainer = Color(0xFFE8F0FE), // active chip / selected pill
outline = GMapsColors.Divider,
error = GMapsColors.Red,
)
private val GMapsDark = darkColorScheme(
primary = GMapsColors.Blue,
onPrimary = Color.White,
background = GMapsColors.DarkCanvas,
onBackground = GMapsColors.DarkText,
surface = GMapsColors.DarkSurface1,
onSurface = GMapsColors.DarkText,
surfaceVariant = GMapsColors.DarkSurface2,
secondaryContainer = GMapsColors.Blue.copy(alpha = 0.24f),
outline = GMapsColors.DarkDivider,
error = GMapsColors.Red,
)
@Composable
fun GMapsTheme(
dark: Boolean = isSystemInDarkTheme(),
dynamic: Boolean = true, // Material-native Google app — Material You ON for chrome
content: @Composable () -> Unit,
) {
val ctx = LocalContext.current
val scheme = when {
dynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (dark) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
dark -> GMapsDark
else -> GMapsLight
}
MaterialTheme(colorScheme = scheme, typography = GMapsTypography, content = content)
}
2. Typography
Google Sans for headings/CTAs/turn cards; Roboto (Android’s system default — what iOS deliberately falls back to SF Pro for) for body, addresses, and all data. Drop Google Sans TTFs in res/font/. Distances, ETAs, ratings, and speeds use tabular figures (tnum) so list columns align.
// ui/theme/GMapsType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val GoogleSans = FontFamily(
Font(R.font.google_sans_regular, FontWeight.Normal), // 400
Font(R.font.google_sans_medium, FontWeight.Medium), // 500
Font(R.font.google_sans_bold, FontWeight.Bold), // 700 — turn cards only
)
val Roboto = FontFamily(
Font(R.font.roboto_regular, FontWeight.Normal),
Font(R.font.roboto_medium, FontWeight.Medium),
)
const val TNUM = "tnum" // tabular figures — every distance / ETA / rating / speed
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, line-height = size × ratio)
object GMapsText {
val NavTurn = TextStyle(GoogleSans, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 40.sp, letterSpacing = (-0.4).sp)
val ScreenTitle = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
val PlaceTitle = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val SectionHead = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val RowTitle = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Address = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
val Meta = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp, fontFeatureSettings = TNUM)
val Rating = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 17.sp, fontFeatureSettings = TNUM)
val Chip = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 14.sp)
val Button = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 16.sp)
val ButtonSmall = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 14.sp)
val Tab = TextStyle(GoogleSans, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Eta = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 16.sp, fontFeatureSettings = TNUM)
val DistancePill = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 13.sp, fontFeatureSettings = TNUM)
}
// Map onto Material 3 slots so stock components inherit the brand
val GMapsTypography = Typography(
headlineLarge = GMapsText.ScreenTitle,
headlineSmall = GMapsText.PlaceTitle,
titleMedium = GMapsText.SectionHead,
bodyMedium = GMapsText.Body,
labelLarge = GMapsText.Button,
labelSmall = GMapsText.Tab,
)
3. Signature Components
Floating Top Search Bar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun GMSearchBar(onTap: () -> Unit, onMic: () -> Unit, modifier: Modifier = Modifier) {
Surface(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).height(48.dp),
shape = CircleShape, // full pill
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp, // rgba(0,0,0,0.15) 0 2px 8px
onClick = onTap,
) {
Row(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(28.dp).clip(CircleShape).background(GMapsColors.SurfaceMuted), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Person, "Profile", tint = Color.White, modifier = Modifier.size(16.dp))
}
Text("Search here", style = GMapsText.Button, color = GMapsColors.TextSecondary, modifier = Modifier.weight(1f))
IconButton(onClick = onMic, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Mic, "Voice search", tint = GMapsColors.TextSecondary)
}
}
}
}
Directions FAB
import androidx.compose.material.icons.filled.Directions
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun GMDirectionsFab(onClick: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
FloatingActionButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact(.medium)
onClick()
},
containerColor = MaterialTheme.colorScheme.primary, // Google Blue
contentColor = Color.White,
shape = CircleShape,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp, pressedElevation = 12.dp),
modifier = modifier.size(56.dp),
) {
Icon(Icons.Filled.Directions, contentDescription = "Directions", modifier = Modifier.size(24.dp))
}
}
Your Location Dot (accuracy halo + heading cone)
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
@Composable
fun GMLocationDot(headingDegrees: Float? = null, accuracyDp: Dp = 140.dp) {
val pulse by rememberInfiniteTransition(label = "loc").animateFloat(
initialValue = 1f, targetValue = 1.15f,
animationSpec = infiniteRepeatable(tween(1800, easing = EaseInOut), RepeatMode.Reverse),
label = "pulse",
)
Box(contentAlignment = Alignment.Center) {
// Accuracy circle — the trust signal
Box(
Modifier
.size(accuracyDp)
.clip(CircleShape)
.background(GMapsColors.BlueHalo)
.border(1.dp, GMapsColors.BlueHaloEdge, CircleShape),
)
// Heading cone (optional)
if (headingDegrees != null) {
Canvas(Modifier.size(40.dp).offset(y = (-48).dp).rotate(headingDegrees)) {
drawPath(
androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2f, 0f); lineTo(size.width, size.height); lineTo(0f, size.height); close()
},
brush = androidx.compose.ui.graphics.Brush.verticalGradient(
listOf(GMapsColors.Blue.copy(alpha = 0.55f), GMapsColors.Blue.copy(alpha = 0f)),
),
)
}
}
// Inner blue dot + 3dp white ring
Box(
Modifier.size(18.dp).scale(pulse).clip(CircleShape).background(Color.White),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(12.dp).clip(CircleShape).background(GMapsColors.Blue))
}
}
}
Teardrop Map Pin
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
enum class PinKind { Default, Saved, HomeWork, Category }
fun PinKind.fill() = when (this) {
PinKind.Default -> GMapsColors.Red
PinKind.Saved -> GMapsColors.Green
PinKind.HomeWork -> GMapsColors.Blue
PinKind.Category -> GMapsColors.Orange
}
@Composable
fun GMMapPin(kind: PinKind, glyph: androidx.compose.ui.graphics.vector.ImageVector? = null) {
Box(contentAlignment = Alignment.TopCenter) {
Canvas(Modifier.size(32.dp, 40.dp)) {
// ground-hugging ellipse shadow — makes the pin feel dropped into the world
drawOval(
Color.Black.copy(alpha = 0.25f),
topLeft = Offset(size.width * 0.15f, size.height - 6.dp.toPx()),
size = androidx.compose.ui.geometry.Size(size.width * 0.7f, 6.dp.toPx()),
)
drawTeardrop(kind.fill())
}
if (kind == PinKind.Default) {
Box(Modifier.padding(top = 10.dp).size(10.dp).clip(CircleShape).background(Color.White))
} else if (glyph != null) {
Icon(glyph, null, tint = Color.White, modifier = Modifier.padding(top = 9.dp).size(14.dp))
}
}
}
private fun DrawScope.drawTeardrop(color: Color) {
val w = size.width
val r = w / 2f
val path = Path().apply {
addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, w))
moveTo(2f, r)
quadraticBezierTo(0f, w, w / 2f, size.height)
quadraticBezierTo(w, w, w - 2f, r)
close()
}
drawPath(path, color)
}
Place Sheet Card
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
@Composable
fun GMPlaceCard(
title: String,
rating: Double,
reviewCount: Int,
category: String,
distance: String,
isOpen: Boolean,
photoUrl: String?,
) {
Column(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AsyncImage(
model = photoUrl,
contentDescription = null,
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)).background(GMapsColors.SurfaceMuted),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = GMapsText.PlaceTitle, color = GMapsColors.TextPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text("%.1f".format(rating), style = GMapsText.Rating, color = GMapsColors.TextPrimary)
repeat(5) { i ->
Icon(
if (i < rating.toInt()) Icons.Filled.Star else Icons.Outlined.StarBorder,
null, tint = GMapsColors.Yellow, modifier = Modifier.size(12.dp),
)
}
Text("($reviewCount)", style = GMapsText.Body, color = GMapsColors.TextSecondary)
}
Text("$category · $distance", style = GMapsText.Meta, color = GMapsColors.TextSecondary)
if (isOpen) Text("Open now", style = GMapsText.Meta.copy(fontWeight = FontWeight.Medium), color = GMapsColors.Green)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GMPill(Icons.Filled.Directions, "Directions", filled = true)
GMPill(Icons.Outlined.BookmarkBorder, "Save", filled = false)
GMPill(Icons.Filled.Share, "Share", filled = false)
GMPill(Icons.Filled.Call, "Call", filled = false)
}
}
}
@Composable
fun GMPill(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, filled: Boolean) {
Row(
Modifier
.height(36.dp)
.clip(CircleShape)
.background(if (filled) GMapsColors.Blue else Color.Transparent)
.then(if (filled) Modifier else Modifier.border(1.dp, GMapsColors.Divider, CircleShape))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(icon, null, tint = if (filled) Color.White else GMapsColors.Blue, modifier = Modifier.size(16.dp))
Text(title, style = GMapsText.ButtonSmall, color = if (filled) Color.White else GMapsColors.Blue)
}
}
Navigation Turn Card
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.TurnRight
import androidx.compose.material.icons.filled.TurnLeft
@Composable
fun GMTurnCard(instruction: String, distance: String, nextInstruction: String?) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
color = GMapsColors.Blue, // the only display moment — blue card, bold white type
shadowElevation = 16.dp,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(Icons.Filled.TurnRight, null, tint = Color.White, modifier = Modifier.size(44.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(instruction, style = GMapsText.PlaceTitle.copy(fontFamily = GoogleSans, fontWeight = FontWeight.Bold, fontSize = 22.sp), color = Color.White, maxLines = 2)
Text("in $distance", style = GMapsText.Body, color = Color.White.copy(alpha = 0.7f))
}
}
if (nextInstruction != null) {
HorizontalDivider(color = Color.White.copy(alpha = 0.2f))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.TurnLeft, null, tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(22.dp))
Text("Then $nextInstruction", style = GMapsText.Body, color = Color.White.copy(alpha = 0.8f))
}
}
}
}
}
4. Google-Maps-Specific Feature: Map-as-Hero + Floating Bottom-Sheet Drawer
The map owns 60–85% of the viewport on every primary screen — the UI floats on top, never stacks below. Use maps-compose’s GoogleMap as the full-bleed base layer and a Material 3 BottomSheetScaffold whose sheetPeekHeight realizes the three Maps detents (collapsed 140.dp / medium 380.dp / expanded near-full). The route polyline is a Polyline composable with a darker casing drawn beneath it.
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.SheetValue
import com.google.maps.android.compose.*
import com.google.android.gms.maps.model.LatLng
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapScreen(routePoints: List<LatLng>) {
val cameraPositionState = rememberCameraPositionState()
val scaffoldState = rememberBottomSheetScaffoldState()
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = 140.dp, // collapsed detent
sheetContainerColor = MaterialTheme.colorScheme.surface,
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), // 16dp top corners
sheetDragHandle = { // the 32x4dp grabber
Box(
Modifier.padding(top = 8.dp).size(32.dp, 4.dp)
.clip(CircleShape).background(GMapsColors.Divider),
)
},
sheetContent = {
// search results list / GMPlaceCard at expanded detent
GMPlaceCard("Blue Bottle Coffee", 4.5, 2345, "Coffee shop · $$", "1.2 mi", true, null)
},
) {
Box(Modifier.fillMaxSize()) {
// 1) The map IS the canvas — full-bleed, edge to edge
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = MapProperties(mapStyleOptions = darkOrLightStyle()),
uiSettings = MapUiSettings(zoomControlsEnabled = false, myLocationButtonEnabled = false),
) {
if (routePoints.isNotEmpty()) {
// Casing first (wider, darker), then the 5dp blue fill on top
Polyline(points = routePoints, color = GMapsColors.BlueDark, width = 13f)
Polyline(points = routePoints, color = GMapsColors.Blue, width = 10f)
}
}
// 2) Floating chrome — pills + FAB, 16dp from edges, never stacked below the map
GMSearchBar(onTap = {}, onMic = {}, modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 8.dp))
GMDirectionsFab(
onClick = {},
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp).offset(y = (-148).dp), // clears the collapsed sheet
)
}
}
}
Map controls (recenter, layers, compass) stack on the trailing edge as 40.dp Surface(shape = CircleShape, shadowElevation = 6.dp) circles, offset above the current sheet detent — never more than 3, to keep the map breathable. Provide a dark-tile MapStyleOptions JSON (#242F3E land / #17263C water / #023E58 parks) for dark mode and cross-fade map styles over ~1.2s on theme toggle.
5. Navigation
Primary navigation is the Material 3 NavigationBar (Explore / Go / Saved / Contribute / Updates). Android has no live blur, so the bar is a 92%-opaque canvas Surface with a 0.5dp top divider. Active tint is Google Blue with the filled icon variant.
@Composable
fun GMBottomBar(selected: Int, onSelect: (Int) -> Unit) {
Column {
HorizontalDivider(color = GMapsColors.Divider, thickness = 0.5.dp)
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Explore" to Icons.Filled.Explore,
"Go" to Icons.Filled.NearMe,
"Saved" to Icons.Filled.Bookmark,
"Contribute" to Icons.Filled.AddCircle,
"Updates" to Icons.AutoMirrored.Filled.Article,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = GMapsText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = GMapsColors.Blue,
selectedTextColor = GMapsColors.Blue,
unselectedIconColor = GMapsColors.TextSecondary,
unselectedTextColor = GMapsColors.TextSecondary,
indicatorColor = Color.Transparent, // Maps has no Material pill behind tabs
),
)
}
}
}
}
The category chip row (Restaurants / Coffee / Gas / Hotels …) is a LazyRow of FilterChips directly under the search bar with an 8.dp gap; active chip → #E8F0FE container, #4285F4 border, #1967D2 text.
6. Motion
| Moment | Compose recipe |
|---|---|
| Pin drop | Animatable spring(dampingRatio = 0.55f, stiffness = 400f) 1.1 → 1.0; shadow scales with pin; HapticFeedbackType.LongPress (≈ .impact(.medium)) |
| Search-bar expand | AnimatedVisibility morph to full-screen, 280ms ease-out, list fadeIn |
| Sheet detent change | BottomSheetScaffold anchored drag — spring(dampingRatio = 0.85f) snap between detents |
| FAB press | FloatingActionButton pressedElevation lift (Material does not scale) + LongPress haptic |
| Route draw-on | animate Polyline Animatable 0 → 1 fraction over 600ms ease-out, then cameraPositionState.animate() to follow |
| Location dot pulse | rememberInfiniteTransition scale 1.0 → 1.15 over 1.8s EaseInOut reverse |
| Turn-card swap | AnimatedContent slideInVertically { it } togetherWith fadeOut(), 300ms; light haptic on each turn |
| Camera recenter | cameraPositionState.animate(update, durationMs = 600) — no haptic (soft action) |
// Pin drop with overshoot + haptic
@Composable
fun DroppingPin(kind: PinKind) {
val scale = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 400f))
}
Box(Modifier.scale(scale.value)) { GMMapPin(kind) }
}
Haptics: prefer LocalHapticFeedback. HapticFeedbackType.LongPress ≈ iOS .impact(.medium) (pin drop / Start); TextHandleMove ≈ .selection (tab / chip). For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONFIRM) (API 30+) or a Vibrator VibrationEffect.createOneShot(14, …).
7. Icons
Google Maps uses Material Symbols natively; androidx.compose.material:material-icons-extended is the first-party match. The Maps pin glyph, Pegman, and turn-arrow set ship as vector drawables (ImageVector.vectorResource(R.drawable.…)).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Directions FAB | arrow.triangle.turn.up.right.diamond.fill |
Icons.Filled.Directions |
| Search mic | mic.fill |
Icons.Filled.Mic |
| Location dot | custom circle + ring | (drawn in Canvas) |
| Saved bookmark | bookmark / .fill |
Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Call | phone.fill |
Icons.Filled.Call |
| Recenter | location.fill |
Icons.Filled.MyLocation |
| Layers | square.3.layers.3d |
Icons.Filled.Layers |
| Compass | safari |
Icons.Filled.Explore |
| Turn right / left | arrow.turn.up.right / .left |
Icons.Filled.TurnRight / Icons.Filled.TurnLeft |
| Straight | arrow.up |
Icons.Filled.Straight |
| Rating star | star.fill |
Icons.Filled.Star |
| Explore (tab) | safari.fill |
Icons.Filled.Explore |
| Go (tab) | location.north.circle.fill |
Icons.Filled.NearMe |
| Saved (tab) | bookmark.fill |
Icons.Filled.Bookmark |
| Contribute (tab) | plus.circle.fill |
Icons.Filled.AddCircle |
| Updates (tab) | newspaper.fill |
Icons.AutoMirrored.Filled.Article |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
maps-composeandBottomSheetScaffoldare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Add the Maps SDK API key in the manifest. - Edge-to-edge: call
enableEdgeToEdge()in theActivity— the map is full-bleed under the status bar and gesture nav. Float the search bar withstatusBarsPadding()+ 8.dp; the bottom sheet honors the navigation-bar inset and pads content 24.dp above it. The Directions FAB stays 16.dp from edges and offsets above the current sheet detent. - Font scaling:
sphonors the user’s font scale automatically — keep it on place titles, addresses, reviews, row titles. Pin layout-sensitive text (distance/ETA labels, the speed chip, the 11sp tab labels) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)); clamp the nav turn instruction at ~48sp max so it stays legible at speed without breaking the card. - Tabular figures: keep
fontFeatureSettings = "tnum"on every distance / ETA / rating / speed style — list column alignment (“1.2 mi” over “14.7 mi”) breaks without it. - TalkBack: announce the location dot as “Your location, accuracy 30 feet” via
Modifier.semantics; give each pin acontentDescriptionof its category + place name; collapse the place card into one node withsemantics(mergeDescendants = true). Map controls and the FAB are separate labeled buttons. - Touch targets: Material guidance is 48.dp minimum. The FAB (56.dp) and search bar (48.dp) are clear; give 32.dp visual pins a ≥40–48.dp hit area, and ensure the 16.dp action-pill glyphs sit inside ≥36–48.dp rows.
- Contrast:
#202124on#FFFFFFexceeds AAA.#5F6368secondary meets AA at 14sp+ on white — validate#80868Btertiary at 13sp and the white-70% turn-card subtext on#4285F4; boost toward solid white /#3C4043when the system “increase contrast” setting (isHighTextContrastEnabled) is on. - Dynamic color: RECOMMENDED — embrace
dynamicColorScheme()for chrome. Google Maps is a Material-native Google app; on Android 12+ the floating chrome (sheet surface, search bar, tab container) should adopt Material You from the user’s wallpaper. Keep the semantic anchors strictly fixed: the four pin colors (Red default / Green saved / Blue home-work / Orange category), the blue route polyline +#174EA6casing, the rating yellow#FBBC04, the red/orange/green traffic overlay, and the map tile palette — these encode meaning on the map and must never drift with the wallpaper. The Directions FAB / location dot may follow the dynamicprimarysince blue is also the generic accent, but pin/route/traffic semantics stay constant.