coderef

Google Maps

Google Maps' iOS app is a pure map-first interface — the earth is the canvas and the UI is a handful of floating shapes laid over it.
DownloadRaw
Google Maps

Build with the Google Maps look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
dark
Typefaces
Google Sans, Google Sans 700
Sections
8
Length
4,456 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Google Maps

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

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 true FloatingActionButton is the Directions action, the drawer is BottomSheetScaffold with anchored detents, and sp/dp replace pt. Because it is Material-native, this is one of the few apps where you should embrace dynamicColorScheme() 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-compose and BottomSheetScaffold are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+. Add the Maps SDK API key in the manifest.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity — the map is full-bleed under the status bar and gesture nav. Float the search bar with statusBarsPadding() + 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: sp honors 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 from dp or wrapping in CompositionLocalProvider(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 a contentDescription of its category + place name; collapse the place card into one node with semantics(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: #202124 on #FFFFFF exceeds AAA. #5F6368 secondary meets AA at 14sp+ on white — validate #80868B tertiary at 13sp and the white-70% turn-card subtext on #4285F4; boost toward solid white / #3C4043 when 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 + #174EA6 casing, 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 dynamic primary since blue is also the generic accent, but pin/route/traffic semantics stay constant.
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses