Uber (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Uber’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (the “Where to?” sheet, the black CTA, ride-option cards, the active-trip card, the driver beacon), Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (the map as hero, the stark monochrome chrome, the pure-black primary CTA, Uber Move Mono for every ETA/fare/address, the bottom sheet as the primary container) while making everything idiomatic Android — Google Maps Compose instead of MapKit, a
ModalBottomSheetwith detents instead ofPresentationDetents,NavigationBarinstead of a UITabBar,LocalHapticFeedbackinstead of.sensoryFeedback,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for the driver photo, and com.google.maps.android:maps-compose for the map hero. No color extraction, so androidx.palette is not required.
1. Color Tokens
// ui/theme/UberColors.kt
import androidx.compose.ui.graphics.Color
object UberColors {
// Canvas & Brand
val Black = Color(0xFF000000)
val White = Color(0xFFFFFFFF)
val CanvasDark = Color(0xFF0C0C0C) // near-black, NOT pure — matches dark map, less OLED bleed
// Gray ramp (Base design system)
val Gray50 = Color(0xFFF6F6F6)
val Gray100 = Color(0xFFEEEEEE)
val Gray200 = Color(0xFFE5E5E5)
val Gray400 = Color(0xFFAFAFAF)
val Gray600 = Color(0xFF757575)
val Gray700 = Color(0xFF545454)
val Gray900 = Color(0xFF2F2F2F)
val Gray950 = Color(0xFF1A1A1A)
// Functional (semantic — each does ONE job)
val Green = Color(0xFF05A357) // active trip, driver arrived, confirmed
val Red = Color(0xFFD72113) // surge, cancel, error
val Blue = Color(0xFF0A47FF) // info, Uber for Business
val Amber = Color(0xFFFFCB00) // high-tier surge, caution
// Functional lightened for dark mode
val GreenDark = Color(0xFF22C77A)
val RedDark = Color(0xFFFF4C3F)
val BlueDark = Color(0xFF3D6DFF)
// Map tint (light)
val MapLand = Color(0xFFF2F2F2)
val MapRoad = Color(0xFFFFFFFF)
val MapWater = Color(0xFFD9E5F2)
// Map tint (dark)
val MapLandDark = Color(0xFF1E1E1E)
val MapRoadDark = Color(0xFF2F2F2F)
val MapWaterDark = Color(0xFF0F1B2A)
// Dark surfaces
val DarkSurface1 = Color(0xFF1A1A1A)
val DarkSurface2 = Color(0xFF2F2F2F)
val DarkDivider = Color(0xFF3A3A3A)
}
Uber’s chrome is light-first (white canvas, black UI) and flips to a #0C0C0C dark canvas that matches the dark map. Wire both Material 3 schemes; the primary is always black, never a brand color.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val UberLight = lightColorScheme(
primary = UberColors.Black, // primary CTA is always black
onPrimary = UberColors.White,
background = UberColors.White,
onBackground = UberColors.Black,
surface = UberColors.White,
onSurface = UberColors.Black,
surfaceVariant = UberColors.Gray50,
outline = UberColors.Gray200,
error = UberColors.Red,
)
private val UberDark = darkColorScheme(
primary = UberColors.White, // on dark, the "black bar" inverts to white
onPrimary = UberColors.Black,
background = UberColors.CanvasDark,
onBackground = UberColors.White,
surface = UberColors.DarkSurface1,
onSurface = UberColors.White,
surfaceVariant = UberColors.DarkSurface2,
outline = UberColors.DarkDivider,
error = UberColors.RedDark,
)
@Composable
fun UberTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (darkTheme) UberDark else UberLight,
typography = UberTypography,
content = content,
)
2. Typography
Uber Move is proprietary and ships in three cuts doing three jobs: Display (≥20pt), Text (UI), Mono (numbers/addresses). Drop the TTFs in res/font/. Fall back to the system font (Roboto); for the Mono cut fall back to a monospace family — the data-precision cue is load-bearing, never substitute the Text cut for numerals.
// ui/theme/UberType.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 UberMove = FontFamily(
Font(R.font.uber_move_medium, FontWeight.Medium),
Font(R.font.uber_move_bold, FontWeight.Bold),
)
val UberMoveText = FontFamily(
Font(R.font.uber_move_text_regular, FontWeight.Normal),
Font(R.font.uber_move_text_medium, FontWeight.Medium),
Font(R.font.uber_move_text_bold, FontWeight.Bold),
)
val UberMoveMono = FontFamily(
Font(R.font.uber_move_mono_regular, FontWeight.Normal),
Font(R.font.uber_move_mono_medium, FontWeight.Medium),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object UberText {
val Hero = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 40.sp, letterSpacing = (-0.6).sp)
val SheetTitle = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 28.sp, letterSpacing = (-0.4).sp)
val NavTitle = TextStyle(UberMove, fontWeight = FontWeight.Medium, fontSize = 18.sp, lineHeight = 22.sp)
val WhereTo = TextStyle(UberMoveText, fontWeight = FontWeight.Medium, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val RowTitle = TextStyle(UberMoveText, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(UberMoveText, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Meta = TextStyle(UberMoveText, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val LabelUpper = TextStyle(UberMoveText, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(UberMoveText, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 17.sp)
val ButtonSmall = TextStyle(UberMoveText, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 15.sp)
val Tab = TextStyle(UberMoveText, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Caption = TextStyle(UberMoveText, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
// Mono — every fare, ETA, address
val Price = TextStyle(UberMoveMono, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 19.sp)
val ETA = TextStyle(UberMoveMono, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 17.sp)
val ETABadge = TextStyle(UberMoveMono, fontWeight = FontWeight.Medium, fontSize = 18.sp, lineHeight = 22.sp)
val Address = TextStyle(UberMoveMono, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val UberTypography = Typography(
headlineLarge = UberText.Hero,
headlineSmall = UberText.SheetTitle,
titleMedium = UberText.RowTitle,
bodyMedium = UberText.Body,
labelLarge = UberText.Button,
labelSmall = UberText.Tab,
)
3. Signature Components
Primary Black CTA
The signature: always #000000, never branded. 56.dp tall, full-width inside the sheet, 8.dp corners.
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
@Composable
fun UberPrimaryButton(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, spring(0.85f, 600f), label = "ctaScale")
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.scale(scale)
.clip(RoundedCornerShape(8.dp))
.background(if (pressed) UberColors.Gray900 else UberColors.Black)
.clickable(interaction, indication = null, enabled = !isLoading) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onClick()
},
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(color = UberColors.White, strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
} else {
Text(title, style = UberText.Button, color = UberColors.White)
}
}
}
“Where to?” Bottom Sheet Input
The signature rideshare entry point. Leading “From”→“To” dot stack connected by a dashed line; the recognizable “Where to?” placeholder.
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
@Composable
fun WhereToInput(onTap: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(UberColors.Gray50)
.clickable(onClick = onTap),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(
Modifier.padding(start = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(Modifier.size(8.dp).background(UberColors.Black))
repeat(3) { Box(Modifier.size(1.dp, 3.dp).background(UberColors.Gray400)) }
Box(Modifier.size(8.dp).background(UberColors.Black))
}
Text("Where to?", style = UberText.WhereTo, color = UberColors.Black)
Spacer(Modifier.weight(1f))
Row(
Modifier
.padding(end = 8.dp)
.clip(CircleShape)
.border(1.dp, UberColors.Gray200, CircleShape)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Schedule, contentDescription = null, tint = UberColors.Black, modifier = Modifier.size(14.dp))
Text("Later", style = UberText.ButtonSmall, color = UberColors.Black)
}
}
}
Ride Option Card
72.dp selectable row; selected = 2.dp black border + #F6F6F6 fill. Price/ETA in Uber Move Mono.
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
@Composable
fun RideOptionCard(
name: String,
eta: String,
capacity: Int,
price: String,
carImageUrl: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
val borderWidth by animateFloatAsState(if (selected) 2f else 1f, label = "rideBorder")
Row(
modifier = modifier
.fillMaxWidth()
.height(72.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) UberColors.Gray50 else UberColors.White)
.border(borderWidth.dp, if (selected) UberColors.Black else UberColors.Gray200, RoundedCornerShape(8.dp))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS selection
onClick()
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(model = carImageUrl, contentDescription = null, modifier = Modifier.size(56.dp), contentScale = ContentScale.Fit)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(name, style = UberText.RowTitle, color = UberColors.Black)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Icon(Icons.Filled.Person, null, tint = UberColors.Gray600, modifier = Modifier.size(10.dp))
Text("$capacity", style = UberText.Caption, color = UberColors.Gray600)
}
}
Text("$eta away", style = UberText.Address, color = UberColors.Gray600)
}
Text(price, style = UberText.Price, color = UberColors.Black)
}
}
Active Trip Card
Driver photo + name + rating, license plate in a #F6F6F6 Mono pill, Message/Call/Share circular actions.
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Message
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun ActiveTripCard(
driverName: String,
rating: Double,
carModel: String,
plate: String,
driverPhotoUrl: String,
modifier: Modifier = Modifier,
) {
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AsyncImage(
model = driverPhotoUrl, contentDescription = "$driverName photo",
modifier = Modifier.size(48.dp).clip(CircleShape), contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(driverName, style = UberText.RowTitle, color = UberColors.Black)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Filled.Star, null, tint = UberColors.Gray600, modifier = Modifier.size(11.dp))
Text(String.format("%.2f", rating), style = UberText.ETA, color = UberColors.Gray600)
}
}
Box(
Modifier.clip(CircleShape).background(UberColors.Gray50).padding(horizontal = 10.dp, vertical = 4.dp)
) { Text(plate, style = UberText.ETA, color = UberColors.Black) }
}
Text(carModel, style = UberText.Meta, color = UberColors.Gray700)
HorizontalDivider(color = UberColors.Gray200)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TripAction(Icons.Filled.Message, "Message")
TripAction(Icons.Filled.Call, "Call")
TripAction(Icons.Filled.Share, "Share")
}
}
}
@Composable
private fun TripAction(icon: ImageVector, label: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Box(
Modifier.size(44.dp).clip(CircleShape).background(UberColors.Gray50),
contentAlignment = Alignment.Center,
) { Icon(icon, contentDescription = label, tint = UberColors.Black, modifier = Modifier.size(20.dp)) }
Text(label, style = UberText.Caption, color = UberColors.Gray600)
}
}
Bottom Sheet with Detents
iOS uses 3 PresentationDetents. The Compose analog is Material 3 ModalBottomSheet (or a custom AnchoredDraggable). Top corners 16.dp, a 36×4.dp grabber, bottom flush.
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UberBottomSheet(
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
onDismiss: () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = UberColors.White,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), // bottom flush
dragHandle = {
Box(Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 14.dp), contentAlignment = Alignment.Center) {
Box(Modifier.size(36.dp, 4.dp).clip(RoundedCornerShape(2.dp)).background(UberColors.Gray200))
}
},
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 0.dp), content = content)
}
}
4. Map as Hero + Base Tokens (the distinctive interaction)
Uber has no color extraction. Its defining system is the map filling 60–80% of every screen with a custom-tinted style and the Base monochrome polyline/pin. On Android use Google Maps Compose with a JSON style string built from the Base map tokens.
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.android.gms.maps.model.MapStyleOptions
import com.google.android.gms.maps.model.LatLng
@Composable
fun UberMap(
pickup: LatLng,
destination: LatLng,
route: List<LatLng>,
modifier: Modifier = Modifier,
darkTheme: Boolean = isSystemInDarkTheme(),
) {
val cameraPositionState = rememberCameraPositionState()
GoogleMap(
modifier = modifier.fillMaxSize(), // map IS the hero — fills the screen, sheet floats over
cameraPositionState = cameraPositionState,
properties = MapProperties(
mapType = MapType.NORMAL,
// Load a JSON style derived from Base tokens: land #F2F2F2, roads #FFFFFF, water #D9E5F2
// (dark: land #1E1E1E, roads #2F2F2F, water #0F1B2A); POIs excluded.
mapStyleOptions = MapStyleOptions(if (darkTheme) UBER_MAP_STYLE_DARK else UBER_MAP_STYLE_LIGHT),
),
) {
Marker(state = com.google.maps.android.compose.MarkerState(pickup))
Marker(state = com.google.maps.android.compose.MarkerState(destination)) // swap icon → DestinationPin
Polyline(
points = route,
color = if (darkTheme) UberColors.White else UberColors.Black, // 5.dp mono polyline
width = with(androidx.compose.ui.platform.LocalDensity.current) { 5.dp.toPx() },
)
}
}
// Provide these as raw style JSON strings (Base map tokens → Google Maps style array):
const val UBER_MAP_STYLE_LIGHT = """[ /* land #F2F2F2, water #D9E5F2, roads #FFFFFF, labels muted, POIs off */ ]"""
const val UBER_MAP_STYLE_DARK = """[ /* land #1E1E1E, water #0F1B2A, roads #2F2F2F, labels light, POIs off */ ]"""
Destination Pin & Floating Map Control
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.shadow
import androidx.compose.material.icons.filled.MyLocation
@Composable
fun DestinationPin(modifier: Modifier = Modifier) {
Box(modifier.size(32.dp, 40.dp), contentAlignment = Alignment.BottomCenter) {
Box(
Modifier
.size(28.dp, 8.dp)
.background(Color.Black.copy(alpha = 0.25f), CircleShape) // ground shadow
)
Box(
Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp))
.background(UberColors.Black)
)
}
}
@Composable
fun FloatingMapButton(icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(44.dp)
.shadow(8.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.12f))
.clip(CircleShape)
.background(UberColors.White)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { Icon(icon, contentDescription = null, tint = UberColors.Black, modifier = Modifier.size(20.dp)) }
}
Driver Arrival Beacon
Three concentric green rings emanating from the car icon. Compose uses rememberInfiniteTransition with staggered delays.
import androidx.compose.animation.core.*
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.ui.draw.scale
@Composable
fun DriverBeacon(modifier: Modifier = Modifier) {
val t = rememberInfiniteTransition(label = "beacon")
Box(modifier, contentAlignment = Alignment.Center) {
repeat(3) { i ->
val progress by t.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(1800, delayMillis = 0, easing = EaseInOut),
RepeatMode.Restart,
initialStartOffset = StartOffset(i * 600),
),
label = "ring$i",
)
Box(
Modifier
.size(48.dp)
.scale(1f + progress * 1.4f)
.background(UberColors.Green.copy(alpha = (0.3f - i * 0.1f) * (1f - progress)), CircleShape)
)
}
Box(
Modifier.size(24.dp).clip(CircleShape).background(UberColors.White),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.DirectionsCar, "Driver has arrived", tint = UberColors.Black, modifier = Modifier.size(14.dp)) }
}
}
5. Bottom Navigation (Tab Bar)
Material 3 NavigationBar. Uber’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a near-opaque surface with a hairline top border. Active tint is black (filled variant), inactive #757575 (outline) — labels always shown.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun UberBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = UberColors.White.copy(alpha = 0.96f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Services" to Icons.Filled.GridView,
"Activity" to Icons.Filled.Schedule,
"Account" to Icons.Filled.Person,
)
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 = UberText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = UberColors.Black, // black, not a brand color
selectedTextColor = UberColors.Black,
unselectedIconColor = UberColors.Gray600,
unselectedTextColor = UberColors.Gray600,
indicatorColor = Color.Transparent, // no Material pill — Uber has none
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| CTA press | animateFloatAsState 1 → 0.98 with spring(dampingRatio = 0.85f), fill → #2F2F2F, HapticFeedbackType.LongPress |
| Sheet detent change | ModalBottomSheet / AnchoredDraggable spring snap to collapsed / medium / expanded |
| Ride option select | border animateFloatAsState 1.dp → 2.dp over 150ms, HapticFeedbackType.TextHandleMove |
| Destination pin drop | Animatable spring with bounce on place; shadow grows with the pin |
| Driver pulse | rememberInfiniteTransition, 3 staggered rings, 1.8s RepeatMode.Restart, StartOffset(i*600) |
| Route polyline draw | animate the polyline point count or a dash phase over 400ms tween on trip confirm |
| Recenter tap | cameraPositionState.animate(...) ~0.6s, no haptic (soft action) |
// Ride-option border animating 1 → 2.dp on selection (DESIGN.md §6)
val border by animateFloatAsState(
targetValue = if (selected) 2f else 1f,
animationSpec = tween(150),
label = "rideSelect",
)
Haptics: prefer LocalHapticFeedback. For the warning on Cancel-ride confirmation use a Vibrator double-pulse (VibrationEffect.createWaveform(longArrayOf(0, 30, 60, 30), -1)); for “Driver arrived” a single VibrationEffect.createOneShot(20, ...) approximates iOS .success.
7. Icons
Uber ships geometric 2pt-stroke glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity (the destination pin, the car silhouette), ship vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Recenter | location.fill |
Icons.Filled.MyLocation |
| Zoom In / Out | plus / minus |
Icons.Filled.Add / Icons.Filled.Remove |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBackIos |
| Close | xmark |
Icons.Filled.Close |
| Search | magnifyingglass |
Icons.Filled.Search |
| Clock (Later) | clock |
Icons.Filled.Schedule |
| Home (saved) | house.fill |
Icons.Filled.Home |
| Work (saved) | briefcase.fill |
Icons.Filled.Work |
| Star (saved) | star.fill |
Icons.Filled.Star |
| Person (capacity) | person.fill |
Icons.Filled.Person |
| Message | message.fill |
Icons.Filled.Message |
| Call | phone.fill |
Icons.Filled.Call |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Home tab | house.fill |
Icons.Filled.Home |
| Services tab | square.grid.2x2.fill |
Icons.Filled.GridView |
| Activity tab | clock.fill |
Icons.Filled.Schedule |
| Account tab | person.fill |
Icons.Filled.Person |
| Payment card | creditcard.fill |
Icons.Filled.CreditCard |
| Car | car.fill |
Icons.Filled.DirectionsCar |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; Maps Compose + modern motion are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Add a Google Maps API key in the manifest for the map hero. - Edge-to-edge: call
enableEdgeToEdge(); the map extends under the status bar / camera cutout. Floating map controls must respectWindowInsets.systemBars; the bottom sheet’s smallest detent must sit above the gesture-nav inset (WindowInsets.navigationBars). - Font scaling:
sphonors the user’s font scale automatically — keep it on row titles, sheet titles, body. Pin layout-sensitive text: the destination-pin label, license-plate pill, and tab labels are visual-consistency / layout-critical — derive fromdpor wrap inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Cap the 56.dp CTA’s label at ~120% then ellipsize so the bar never grows. - TalkBack: add
contentDescriptionon map markers (“Pickup at 212 Market Street”); group the active-trip card into one element withModifier.semantics(mergeDescendants = true); announce the pulse beacon as “Driver has arrived” (it already carries thatcontentDescription). - Touch targets: Material guidance is 48.dp minimum. The 56.dp CTA and full-row 56–72.dp list rows clear it; the 44.dp floating map buttons should carry a 48.dp hit area via padding even though the visual circle is 44.dp; the payment chip is a full-width 44.dp+ row.
- Contrast:
#757575on#FFFFFFpasses WCAG AA for 14sp+ Regular. The 11sp UPPERCASE labels and 13sp Mono address are below that — use#545454(Gray700) for anything 13sp or smaller targeting compliance. Green#05A357on white passes AA at 14sp+ Medium. White on the black CTA passes AAA. - Reduce motion: when a reduced-motion preference is set, freeze the driver beacon as a single static green ring and swap the polyline stroke animation for an instant draw.
- Dynamic color: do not enable Material You
dynamicColorScheme— Uber’s identity is the strict monochrome chrome (black CTA, white canvas, functional-only color). Tinting the primary or surface from the wallpaper would break the “signage, not software” feel and the semantic meaning of green/red/blue.