Lyft (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Lyft’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics — including the full-screen map + rounded bottom sheet model and the ride-type selector.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Lyft’s full-screen map, the soft rounded bottom sheet, single pink accent, very-rounded geometry, the ride-type selector) while making everything idiomatic Android —
ModalBottomSheetinstead of UIKit detents,dp/spinstead ofpt, Compose haptics instead ofUIImpactFeedbackGenerator.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, the Maps Compose library (com.google.maps.android:maps-compose), and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/LyftColors.kt
import androidx.compose.ui.graphics.Color
object LyftColors {
// Light
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF4F4F7)
val SurfaceDeep = Color(0xFFE9E9EF)
val Divider = Color(0xFFE5E5EA)
val TextPrimary = Color(0xFF11111F)
val TextSecondary = Color(0xFF6B6B7B)
val TextTertiary = Color(0xFF9A9AA8)
// Dark
val CanvasDark = Color(0xFF11111F)
val SurfaceDark = Color(0xFF1C1C2B)
val SurfaceDeepDark = Color(0xFF26263A)
val DividerDark = Color(0xFF2C2C40)
val TextPrimaryDark = Color(0xFFFFFFFF)
val TextSecondaryDark = Color(0xFFA0A0B4)
// Brand
val Pink = Color(0xFFFF00BF)
val PinkPressed = Color(0xFFD500A0)
val PinkTint = Color(0xFFFFE5F7)
val PinkTintDark = Color(0xFF33102B)
// Semantic
val Success = Color(0xFF00A862)
val Gold = Color(0xFFFFB400)
val Error = Color(0xFFE5484D)
}
Lyft ships both light and dark — wire two Material 3 schemes and pick by system theme. The pink stays identical on both.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
private val LyftLight = lightColorScheme(
primary = LyftColors.Pink, onPrimary = Color.White,
background = LyftColors.Canvas, onBackground = LyftColors.TextPrimary,
surface = LyftColors.Canvas, onSurface = LyftColors.TextPrimary,
surfaceVariant = LyftColors.Surface, onSurfaceVariant = LyftColors.TextSecondary,
outline = LyftColors.Divider, error = LyftColors.Error,
)
private val LyftDark = darkColorScheme(
primary = LyftColors.Pink, onPrimary = Color.White,
background = LyftColors.CanvasDark, onBackground = LyftColors.TextPrimaryDark,
surface = LyftColors.CanvasDark, onSurface = LyftColors.TextPrimaryDark,
surfaceVariant = LyftColors.SurfaceDark, onSurfaceVariant = LyftColors.TextSecondaryDark,
outline = LyftColors.DividerDark, error = LyftColors.Error,
)
@Composable
fun LyftTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) LyftDark else LyftLight,
typography = LyftTypography, content = content,
)
2. Typography
Lyft uses Inter with a friendly, rounded character. Drop the TTFs in res/font/ (lowercase, snake_case). Fall back to the system font (Roboto).
// ui/theme/LyftType.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 Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object LyftText {
val SheetTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val WhereTo = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
val RideName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val Price = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 20.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 20.sp)
val DriverName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val RideSub = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Badge = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 15.sp)
val PinLabel = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 15.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val LyftTypography = Typography(
headlineSmall = LyftText.SheetTitle,
titleMedium = LyftText.RideName,
bodyMedium = LyftText.Body,
labelSmall = LyftText.Badge,
)
Render prices, ETAs, and fare breakdowns with tabular figures via TextStyle(fontFeatureSettings = "tnum").
3. Signature Components
Full-Screen Map + Rounded Bottom Sheet
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.maps.android.compose.GoogleMap
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RideHomeScreen() {
val sheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = true,
)
val scaffold = rememberBottomSheetScaffoldState(bottomSheetState = sheetState)
BottomSheetScaffold(
scaffoldState = scaffold,
sheetPeekHeight = 140.dp,
sheetShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), // very rounded
sheetContainerColor = MaterialTheme.colorScheme.surface,
sheetDragHandle = {
Box(Modifier.padding(top = 8.dp).size(width = 36.dp, height = 4.dp)
.clip(RoundedCornerShape(2.dp)).background(LyftColors.Divider))
},
sheetContent = { ChooseRideSheet() },
) {
Box(Modifier.fillMaxSize()) {
GoogleMap(modifier = Modifier.fillMaxSize()) // full-bleed map
MapIconButton(
"menu",
Modifier.padding(16.dp).align(Alignment.TopStart),
) { /* open menu */ }
}
}
}
BottomSheetScaffoldsnaps with a spring. For the signature slight overshoot, wrap programmatic snaps inanimateTo(target, spring(dampingRatio = 0.85f, stiffness = 220f)).
Ride-Type Selector Row
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun RideTypeRow(
iconUrl: String,
name: String,
subtitle: String,
capacityEta: String,
price: String,
selected: Boolean,
onClick: () -> Unit,
) {
val haptics = LocalHapticFeedback.current
val bg by animateColorAsState(if (selected) LyftColors.PinkTint else Color.Transparent,
label = "rowBg")
val border by animateColorAsState(if (selected) LyftColors.Pink else Color.Transparent,
label = "rowBorder")
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(bg)
.border(2.dp, border, RoundedCornerShape(16.dp))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onClick()
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(model = iconUrl, contentDescription = null,
modifier = Modifier.size(width = 56.dp, height = 40.dp))
Column(Modifier.weight(1f)) {
Text(name, style = LyftText.RideName, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = LyftText.RideSub, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(capacityEta, style = LyftText.Meta, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(price, style = LyftText.Price.copy(fontFeatureSettings = "tnum"),
color = MaterialTheme.colorScheme.onSurface)
}
}
Primary CTA
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.ui.draw.scale
@Composable
fun LyftCTA(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f,
spring(dampingRatio = 0.8f), label = "ctaScale")
Box(
modifier
.fillMaxWidth()
.height(56.dp)
.scale(scale)
.clip(RoundedCornerShape(16.dp))
.background(if (pressed) LyftColors.PinkPressed else LyftColors.Pink)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(label, style = LyftText.Button, color = Color.White)
}
}
Pickup Pin (with drop)
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
@Composable
fun PickupPin(modifier: Modifier = Modifier) {
val drop = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
drop.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = 200f)) // bounce
}
Box(
modifier
.size(28.dp, 38.dp)
.scale(0.6f + 0.4f * drop.value)
.offset(y = (-40 * (1 - drop.value)).dp),
contentAlignment = Alignment.TopCenter,
) {
Canvas(Modifier.matchParentSize()) {
val w = size.width
val path = Path().apply {
addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, w))
moveTo(w / 2, w * 0.5f)
lineTo(w / 2, size.height)
close()
}
drawPath(path, LyftColors.Pink)
drawCircle(Color.White, radius = w * 0.18f, center = Offset(w / 2, w * 0.42f))
}
}
}
“Where to?” Search Bar
import androidx.compose.ui.draw.shadow
@Composable
fun WhereToBar(modifier: Modifier = Modifier) {
Row(
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.shadow(12.dp, RoundedCornerShape(16.dp),
spotColor = LyftColors.TextPrimary.copy(alpha = 0.16f))
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 18.dp)
.height(56.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(10.dp).clip(RoundedCornerShape(3.dp)).background(LyftColors.Pink))
Text("Where to?", style = LyftText.WhereTo,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
4. Sheet State Progression
enum class TripState { Idle, ChooseRide, ConfirmPickup, EnRoute, Rate }
// Hold TripState in a ViewModel. Map each to a target sheet value + content:
// Idle → PartiallyExpanded "Where to?"; ChooseRide → Expanded ride list;
// ConfirmPickup → a mid anchor + address/Confirm; EnRoute → driver card; Rate → rating+tip.
// On state change call sheetState.expand()/partialExpand() (or animateTo a custom anchor).
5. Navigation (No Tab Bar)
Lyft has no NavigationBar. The root is the map + BottomSheetScaffold; trip phases are state-driven. The floating menu button opens account/history as a separate destination (navController.navigate(...) to a full-screen route or a ModalBottomSheet), not a tab.
@Composable
fun MapIconButton(system: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val icon = when (system) {
"menu" -> Icons.Filled.Menu
"back" -> Icons.Filled.ArrowBackIosNew
"locate" -> Icons.Filled.MyLocation
else -> Icons.Filled.Layers
}
Box(
modifier
.size(44.dp)
.shadow(12.dp, CircleShape, spotColor = LyftColors.TextPrimary.copy(alpha = 0.16f))
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = system, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp))
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Sheet detent snap | BottomSheetScaffold default spring; programmatic animateTo(spring(dampingRatio = 0.85f, stiffness = 220f)) for the slight overshoot |
| Ride-type select | animateColorAsState for bg/border ring; HapticFeedbackType.TextHandleMove |
| Pickup pin drop | Animatable 0 → 1 spring(dampingRatio = 0.5f, stiffness = 200f); scale + offset; HapticFeedbackType.LongPress (~soft) |
| CTA press | Modifier.scale 1 → 0.98 via collectIsPressedAsState() |
| Map recenter | cameraPositionState.animate(update, durationMs = 400) |
| ETA bubble update | AnimatedContent cross-fade on the number; tabular figures prevent layout jump |
// Map recenter
LaunchedEffect(recenterTrigger) {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(userLatLng, 16f), durationMs = 400)
}
Haptics: prefer LocalHapticFeedback. For the pickup-pin landing, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) approximates iOS .soft impact.
7. Icons
Lyft ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Lyft’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Menu (map) | line.3.horizontal |
Icons.Filled.Menu |
| Back / dismiss | chevron.left |
Icons.Filled.ArrowBackIosNew |
| Recenter / locate | location.fill |
Icons.Filled.MyLocation |
| Layers | square.3.layers.3d |
Icons.Filled.Layers |
| Search | magnifyingglass |
Icons.Filled.Search |
| Saved: Home | house.fill |
Icons.Filled.Home |
| Saved: Work | briefcase.fill |
Icons.Filled.Work |
| Rating star | star.fill |
Icons.Filled.Star |
| Contact driver | phone.fill / message.fill |
Icons.Filled.Call / Icons.Filled.Message |
| Share trip | square.and.arrow.up |
Icons.Filled.IosShare |
| Safety | shield.fill |
Icons.Filled.Shield |
| Where-to dot | (drawn pink square) | — |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
BottomSheetScaffold+ Maps Compose are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the map renders full-bleed behind the system bars. System-bar icon contrast must flip with the map’s day/night tiles — useWindowCompatand update on theme change. Inset floating controls withModifier.windowInsetsPadding(WindowInsets.systemBars). - Font scaling:
sphonors the user’s font scale — keep it on sheet titles, ride names, body. Pin badges and pin labels; the “Where to?” prompt scales to a max and never wraps. - TalkBack: ride-type rows use
Modifier.semantics { role = Role.RadioButton; stateDescription = if (selected) "Selected" else "Not selected" }and merge name/sub/price; the CTA announces “Select Lyft”; the map exposes a summarycontentDescription. - Touch targets: Material guidance is 48.dp minimum. The CTA (56) and ride rows (~72) clear it; floating icon buttons are 44 — bump to 48 if your audit requires, keeping the visual circle at 44 via inner padding.
- Reduce Motion: check
Settings.Global.ANIMATOR_DURATION_SCALE(or accessibility manager) and swap sheet/pin springs for short tween animations. - Contrast:
#6B6B7Bon white and#A0A0B4on#11111Fpass WCAG AA at 14sp+. Lyft Pink#FF00BFon white passes AA for bold 17sp+ — keep the CTA bold and verify the pressed#D500A0too. - Dynamic color: do not enable Material You
dynamicLightColorScheme()/dynamicDarkColorScheme()— Lyft Pink must stay fixed across both themes regardless of wallpaper.