Tripadvisor (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Tripadvisor’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, 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 (Tripadvisor’s bright white gallery canvas, single-green accent, the five-circle bubble rating) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Brushscrim instead of a CALayer gradient,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/TripColors.kt
import androidx.compose.ui.graphics.Color
object TripColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF2F2F2)
val Divider = Color(0xFFE0E0E0)
val SurfacePressed = Color(0xFFE8E8E8)
// Text
val TextPrimary = Color(0xFF000000)
val TextSecondary = Color(0xFF6B6B6B)
val TextTertiary = Color(0xFF9B9B9B)
// Brand
val Green = Color(0xFF34E0A1)
val GreenPressed = Color(0xFF21C589)
val OwlBlack = Color(0xFF000000)
// Semantic
val EmptyBubble = Color(0xFFD9D9D9)
val ErrorRed = Color(0xFFD6122E)
}
Wire it into a Material 3 lightColorScheme so ripples, dividers, and default component colors inherit the brand. Tripadvisor is light-first.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val TripScheme = lightColorScheme(
primary = TripColors.Green,
onPrimary = TripColors.OwlBlack, // intentional: black on bright mint
background = TripColors.Canvas,
onBackground = TripColors.TextPrimary,
surface = TripColors.Canvas,
onSurface = TripColors.TextPrimary,
surfaceVariant = TripColors.Surface,
outline = TripColors.Divider,
error = TripColors.ErrorRed,
)
@Composable
fun TripTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = TripScheme, typography = TripTypography, content = content)
2. Typography
Trip Sans is proprietary. Drop substitute Inter TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto).
// ui/theme/TripType.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 TripSans = 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 TripText {
val TitleLarge = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val PlaceHero = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val PlaceName = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val CardTitle = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val ReviewBody = TextStyle(TripSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val ButtonSec = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp)
val Meta = TextStyle(TripSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Tab = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
val Badge = TextStyle(TripSans, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
val Caption = TextStyle(TripSans, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val TripTypography = Typography(
headlineLarge = TripText.TitleLarge,
headlineSmall = TripText.Section,
titleMedium = TripText.PlaceName,
bodyMedium = TripText.ReviewBody,
labelSmall = TripText.Tab,
)
3. Signature Components
Five-Circle Bubble Rating
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
@Composable
fun BubbleRating(
value: Float, // 0f .. 5f
size: Dp = 16.dp,
reviewCount: Int? = null,
modifier: Modifier = Modifier,
) {
Row(modifier, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
repeat(5) { i ->
val fill = (value - i).coerceIn(0f, 1f) // 0, 0.5, or 1
Canvas(Modifier.size(size)) {
val r = this.size.minDimension / 2f
drawCircle(TripColors.EmptyBubble, r, Offset(r, r))
if (fill > 0f) {
clipRect(right = this.size.width * fill) {
drawCircle(TripColors.Green, r, Offset(r, r))
}
}
}
}
}
if (reviewCount != null) {
Text(
"%.1f (%,d)".format(value, reviewCount),
style = TripText.Meta,
color = TripColors.TextSecondary,
)
}
}
}
Interactive Bubble Picker (Review Write)
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.shape.CircleShape
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun BubblePicker(
rating: Int,
onChange: (Int) -> Unit,
size: Dp = 32.dp,
) {
val haptics = LocalHapticFeedback.current
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(1..5).forEach { i ->
val selected = i <= rating
val scale by animateFloatAsState(
targetValue = if (i == rating) 1.15f else 1f,
animationSpec = spring(dampingRatio = 0.55f),
label = "bubble$i",
)
Box(
Modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.background(if (selected) TripColors.Green else TripColors.EmptyBubble)
.clickable(remember { MutableInteractionSource() }, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS light impact
onChange(i)
}
)
}
}
}
Primary CTA Pill
enum class PillStyle { Filled, Outline }
@Composable
fun TripPillButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: PillStyle = PillStyle.Filled,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "pillScale")
val filled = style == PillStyle.Filled
Box(
modifier = modifier
.scale(scale)
.clip(CircleShape)
.then(
if (filled) Modifier.background(if (pressed) TripColors.GreenPressed else TripColors.Green)
else Modifier.border(1.dp, TripColors.OwlBlack, CircleShape)
)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = if (filled) 28.dp else 24.dp, vertical = if (filled) 14.dp else 12.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = if (filled) TripText.Button else TripText.ButtonSec, color = TripColors.OwlBlack)
}
}
Place Card
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon
import coil.compose.AsyncImage
@Composable
fun PlaceCard(
name: String,
photoUrl: String,
rating: Float,
reviews: Int,
category: String,
priceTier: String,
distance: String,
awarded: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var saved by remember { mutableStateOf(false) }
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "cardScale")
Column(
modifier
.scale(scale)
.clickable(interaction, indication = null, onClick = onClick),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box {
AsyncImage(
model = photoUrl,
contentDescription = name,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(3f / 2f)
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop,
)
Box(
Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.30f))
.clickable { saved = !saved },
contentAlignment = Alignment.Center,
) {
Icon(
if (saved) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (saved) "Saved" else "Save",
tint = if (saved) TripColors.Green else Color.White,
modifier = Modifier.size(20.dp),
)
}
}
if (awarded) {
Box(
Modifier
.clip(CircleShape)
.background(TripColors.Green)
.padding(horizontal = 10.dp, vertical = 4.dp),
) { Text("TRAVELERS' CHOICE", style = TripText.Badge, color = TripColors.OwlBlack) }
}
Text(name, style = TripText.PlaceName, color = TripColors.TextPrimary)
BubbleRating(value = rating, size = 16.dp, reviewCount = reviews)
Text("$category · $priceTier · $distance", style = TripText.Meta, color = TripColors.TextSecondary)
}
}
Place Detail Hero (scrim over photo)
import androidx.compose.ui.graphics.Brush
@Composable
fun PlaceHero(
name: String,
photoUrl: String,
rating: Float,
reviews: Int,
) {
Box(Modifier.fillMaxWidth().height(280.dp)) {
AsyncImage(
model = photoUrl,
contentDescription = "$name photo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.5f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.55f),
)
)
)
Column(
Modifier.align(Alignment.BottomStart).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(name, style = TripText.PlaceHero, color = Color.White)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BubbleRating(value = rating, size = 18.dp)
Text("%,d reviews".format(reviews), style = TripText.Meta, color = Color.White)
}
}
}
}
4. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Tripadvisor’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 96%-opaque white surface. Active tint is Tripadvisor Green — green is the active indicator here.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun TripBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = TripColors.Canvas.copy(alpha = 0.96f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Explore" to Icons.Filled.Explore,
"Search" to Icons.Filled.Search,
"Trips" to Icons.Filled.Work,
"Review" to Icons.Filled.Create,
"More" to Icons.Filled.MoreHoriz,
)
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 = TripText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = TripColors.Green,
selectedTextColor = TripColors.Green,
unselectedIconColor = TripColors.TextSecondary,
unselectedTextColor = TripColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — Tripadvisor has none
),
)
}
}
}
5. Navigation
The top bar carries the owl logomark (left, 28.dp), a location pill (center), and the profile avatar (right). Below it, a pinned #F2F2F2 search field at 48.dp with a full-pill CircleShape. On scroll, collapse to a compact blurred bar — Android has no live blur, so use the 96%-opaque white surface and a 1.dp Divider. Render the search field in a Scaffold topBar slot so list content scrolls beneath it.
6. Motion
| Moment | Compose recipe |
|---|---|
| Save-heart bounce | Animatable keyframes 1 → 1.2 → 1 over 280ms; HapticFeedbackType.LongPress |
| Bubble selection | animateFloatAsState 1 → 1.15 spring(dampingRatio = 0.55f); HapticFeedbackType.TextHandleMove |
| Card tap | animateFloatAsState 1 → 0.98 spring(dampingRatio = 0.8f) |
| Hero parallax | read LazyListState.firstVisibleItemScrollOffset, apply Modifier.graphicsLayer { translationY = offset * 0.5f } to the hero |
| Tab switch | Crossfade 200ms, active icon swaps to filled variant |
// Save-heart bounce
val scale = remember { Animatable(1f) }
LaunchedEffect(saved) {
if (saved) {
scale.animateTo(1.2f, tween(140))
scale.animateTo(1f, tween(140))
}
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, ...) to approximate iOS’s .light impact.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity with Tripadvisor’s custom glyphs, export them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Search | magnifyingglass |
Icons.Filled.Search |
| Save heart | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Filters | line.3.horizontal.decrease |
Icons.Filled.Tune |
| Location pin | mappin.and.ellipse |
Icons.Filled.LocationOn |
| Directions | arrow.triangle.turn.up.right.diamond |
Icons.Filled.Navigation |
| Helpful vote | hand.thumbsup |
Icons.Outlined.ThumbUp / Icons.Filled.ThumbUp |
| Explore (tab) | safari.fill |
Icons.Filled.Explore |
| Trips (tab) | suitcase.fill |
Icons.Filled.Work |
| Review (tab) | square.and.pencil |
Icons.Filled.Create |
| More (tab) | ellipsis |
Icons.Filled.MoreHoriz |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the white canvas wantsWindowCompatdark-content system bars. ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)orScaffoldinsets; the detail hero bleeds full-width while the sticky bar clears the status bar. - Font scaling:
sphonors the user’s font scale automatically — keep it on place names, review body, headers. Pin layout-sensitive units (the bubble rating, tab labels, photo-count pill) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: expose the bubble rating as one combined
contentDescription("Rated 4.5 out of 5, 1,284 reviews"); make the review pickerModifier.semantics { stateDescription = "$rating of 5 bubbles" }with an adjustable role; mark the save heart as a separate toggle button. - Touch targets: Material guidance is 48.dp minimum. The CTA pill clears it; ensure the 36.dp save-heart overlay has a 48.dp hit area via padding, and each review-form bubble has ≥44.dp effective touch.
- Contrast:
#6B6B6Bon#FFFFFFpasses WCAG AA at 13sp+. Black-on-#34E0A1is intentional and high-contrast — do not switch to white text. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Tripadvisor’s brand requires the fixed white canvas and the single mint-green accent regardless of wallpaper.