Tinder (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Tinder’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (the swipe-card stack, stamps, the 5-button action bar, the match screen), 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 3:4 full-bleed swipe card, the pink→orange flame gradient, the five-verb action colors, the rotated LIKE/NOPE stamps) while making everything idiomatic Android — a
NavigationBarinstead of a UITabBar,pointerInput/detectDragGesturesinstead ofDragGesture,LocalHapticFeedbackinstead of.sensoryFeedback,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for remote profile photos. No color extraction, so androidx.palette is not required.
1. Color Tokens
// ui/theme/TinderColors.kt
import androidx.compose.ui.graphics.Color
object TinderColors {
// Canvas (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceMuted = Color(0xFFF5F5F5)
val SurfaceTint = Color(0xFFFAFAFA)
val Divider = Color(0xFFE5E5E5)
// Text
val TextPrimary = Color(0xFF424242) // intentional: softer than pure black
val TextSecondary = Color(0xFF737373)
val TextTertiary = Color(0xFF9E9E9E)
// Brand
val Pink = Color(0xFFFD267A)
val Orange = Color(0xFFFF6036)
// Action colors (the 5 verbs)
val NopeRed = Color(0xFFFF4458)
val SuperLikeBlue = Color(0xFF5D8DF1)
val BoostPurple = Color(0xFFA952FF)
val RewindGold = Color(0xFFFFBD3B)
val LikeStampGreen = Color(0xFF00D68F)
// Semantic
val VerifiedBlue = Color(0xFF29B0FF)
val MatchGlow = Color(0xFF00D68F)
// Dark
val DarkCanvas = Color(0xFF121212)
val DarkSurface1 = Color(0xFF1D1D1D)
val DarkSurface2 = Color(0xFF2A2A2A)
val DarkDivider = Color(0xFF3A3A3A)
val DarkTextPrimary = Color(0xFFFFFFFF)
val DarkTextSecondary = Color(0xFFB8B8B8)
}
// The flame brand gradient — Match screen + premium CTAs ONLY
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.geometry.Offset
val TinderBrandGradient = Brush.linearGradient(
colors = listOf(TinderColors.Pink, TinderColors.Orange),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, 0f), // horizontal: leading → trailing
)
Tinder is a light-first app (white canvas, photos do the work). Wire a Material 3 lightColorScheme so ripples, dividers, and stock component defaults inherit the brand. Provide the dark scheme for night mode.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val TinderLight = lightColorScheme(
primary = TinderColors.Pink,
onPrimary = Color.White,
background = TinderColors.Canvas,
onBackground = TinderColors.TextPrimary,
surface = TinderColors.SurfaceMuted,
onSurface = TinderColors.TextPrimary,
surfaceVariant = TinderColors.SurfaceTint,
outline = TinderColors.Divider,
error = TinderColors.NopeRed,
)
private val TinderDark = darkColorScheme(
primary = TinderColors.Pink,
onPrimary = Color.White,
background = TinderColors.DarkCanvas,
onBackground = TinderColors.DarkTextPrimary,
surface = TinderColors.DarkSurface1,
onSurface = TinderColors.DarkTextPrimary,
surfaceVariant = TinderColors.DarkSurface2,
outline = TinderColors.DarkDivider,
error = TinderColors.NopeRed,
)
@Composable
fun TinderTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (darkTheme) TinderDark else TinderLight,
typography = TinderTypography,
content = content,
)
2. Typography
Tinder Sans is proprietary (licensed from the brand team). Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto) — its humanist proportions are the closest free substitute; the match-hero italic display cut falls back to a bold italic.
// ui/theme/TinderType.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val TinderSans = FontFamily(
Font(R.font.tinder_sans_regular, FontWeight.Normal), // 400
Font(R.font.tinder_sans_medium, FontWeight.Medium), // 500
Font(R.font.tinder_sans_bold, FontWeight.Bold), // 700
Font(R.font.tinder_sans_black, FontWeight.Black), // 800
Font(R.font.tinder_sans_black_italic, FontWeight.Black, FontStyle.Italic), // 800 italic — match hero + stamps
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TinderText {
val MatchHero = TextStyle(TinderSans, fontWeight = FontWeight.Black, fontStyle = FontStyle.Italic, fontSize = 48.sp, lineHeight = 53.sp, letterSpacing = (-0.5).sp)
val ScreenTitle = TextStyle(TinderSans, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.3).sp)
val ProfileName = TextStyle(TinderSans, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
val ProfileAge = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 28.sp)
val Section = TextStyle(TinderSans, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val Button = TextStyle(TinderSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val ButtonSmall = TextStyle(TinderSans, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 15.sp)
val Body = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val CardMeta = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Chat = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
val ChatTime = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 13.sp)
val Stamp = TextStyle(TinderSans, fontWeight = FontWeight.Black, fontStyle = FontStyle.Italic, fontSize = 36.sp, lineHeight = 36.sp, letterSpacing = 2.sp)
val SuperStamp = TextStyle(TinderSans, fontWeight = FontWeight.Black, fontStyle = FontStyle.Italic, fontSize = 32.sp, lineHeight = 32.sp, letterSpacing = 2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val TinderTypography = Typography(
headlineLarge = TinderText.ScreenTitle,
headlineSmall = TinderText.Section,
titleMedium = TinderText.ProfileName,
bodyMedium = TinderText.Body,
labelLarge = TinderText.Button,
)
3. Signature Components
Swipe Card (drag + rotation + stamps)
The hero. Compose drags via Modifier.pointerInput + detectDragGestures; rotation is (offsetX / 12) clamped to ±15°, exactly mirroring the SwiftUI guide.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
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.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.material3.Text
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import kotlin.math.abs
import kotlinx.coroutines.launch
enum class SwipeDirection { LEFT, RIGHT, UP }
@Composable
fun TinderSwipeCard(
name: String,
age: Int,
distance: String,
occupation: String,
photoUrls: List<String>,
modifier: Modifier = Modifier,
onSwipe: (SwipeDirection) -> Unit = {},
) {
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
var currentPhoto by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val threshold = 150f
val rotation = (offsetX.value / 12f).coerceIn(-15f, 15f)
val likeOpacity = (offsetX.value / 160f).coerceIn(0f, 1f)
val nopeOpacity = (-offsetX.value / 160f).coerceIn(0f, 1f)
val superOpacity = (-offsetY.value / 160f).coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(3f / 4f) // load-bearing — never change
.graphicsLayer {
translationX = offsetX.value
translationY = offsetY.value
rotationZ = rotation
}
.clip(RoundedCornerShape(16.dp))
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, drag ->
change.consume()
scope.launch { offsetX.snapTo(offsetX.value + drag.x) }
scope.launch { offsetY.snapTo(offsetY.value + drag.y) }
},
onDragEnd = {
when {
offsetX.value > threshold -> commit(scope, offsetX, offsetY, 1600f, 0f) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.RIGHT)
}
offsetX.value < -threshold -> commit(scope, offsetX, offsetY, -1600f, 0f) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.LEFT)
}
offsetY.value < -threshold -> commit(scope, offsetX, offsetY, 0f, -2000f) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.UP)
}
else -> { // snap back, spring damping ~0.7
scope.launch { offsetX.animateTo(0f, spring(0.7f, 200f)) }
scope.launch { offsetY.animateTo(0f, spring(0.7f, 200f)) }
}
}
},
)
}
.pointerInput(photoUrls.size) {
detectTapGestures { tap ->
currentPhoto =
if (tap.x < size.width / 2) (currentPhoto - 1).coerceAtLeast(0)
else (currentPhoto + 1).coerceAtMost(photoUrls.lastIndex)
}
},
contentAlignment = Alignment.BottomStart,
) {
AsyncImage(
model = photoUrls[currentPhoto],
contentDescription = "$name, $age",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Bottom legibility gradient over the lower ~50%
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.5f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.7f),
)
)
)
// Photo paginator — thin white segments at top
Row(
Modifier.fillMaxWidth().align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
repeat(photoUrls.size) { i ->
Box(
Modifier
.weight(1f)
.height(3.dp)
.clip(RoundedCornerShape(1.5.dp))
.background(if (i == currentPhoto) Color.White else Color.White.copy(alpha = 0.4f))
)
}
}
// Name + age + meta
Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.Bottom) {
Text(name, style = TinderText.ProfileName, color = Color.White)
Text(", $age", style = TinderText.ProfileAge, color = Color.White)
}
Text(
"$distance · $occupation",
style = TinderText.CardMeta,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
// Stamps — rotated, opacity tied to drag progress
TinderStamp("LIKE", TinderColors.LikeStampGreen, -15f, Modifier.align(Alignment.TopStart).padding(32.dp).graphicsLayer { alpha = likeOpacity })
TinderStamp("NOPE", TinderColors.NopeRed, 15f, Modifier.align(Alignment.TopEnd).padding(32.dp).graphicsLayer { alpha = nopeOpacity })
TinderStamp("SUPER LIKE", TinderColors.SuperLikeBlue, 0f, Modifier.align(Alignment.TopCenter).padding(top = 64.dp).graphicsLayer { alpha = superOpacity }, isSuper = true)
}
}
private fun commit(
scope: kotlinx.coroutines.CoroutineScope,
x: Animatable<Float, *>,
y: Animatable<Float, *>,
tx: Float, ty: Float,
onDone: () -> Unit,
) {
onDone()
scope.launch { x.animateTo(tx, tween(400)) }
scope.launch { y.animateTo(ty, tween(400)) }
}
Swipe Stamp
import androidx.compose.foundation.border
@Composable
fun TinderStamp(
text: String,
color: Color,
rotation: Float,
modifier: Modifier = Modifier,
isSuper: Boolean = false,
) {
Text(
text = text,
style = if (isSuper) TinderText.SuperStamp else TinderText.Stamp,
color = color,
modifier = modifier
.graphicsLayer { rotationZ = rotation }
.border(4.dp, color, RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
Five Action Buttons Row
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
@Composable
fun TinderActionBar(
onRewind: () -> Unit,
onNope: () -> Unit,
onSuper: () -> Unit,
onLike: () -> Unit,
onBoost: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TinderActionButton(48.dp, TinderColors.RewindGold, Icons.Filled.Replay, "Rewind", onRewind)
TinderActionButton(56.dp, TinderColors.NopeRed, Icons.Filled.Close, "Pass", onNope)
TinderActionButton(40.dp, TinderColors.SuperLikeBlue, Icons.Filled.Star, "Super Like", onSuper)
TinderActionButton(56.dp, TinderColors.LikeStampGreen, Icons.Filled.Favorite, "Like", onLike)
TinderActionButton(48.dp, TinderColors.BoostPurple, Icons.Filled.Bolt, "Boost", onBoost)
}
}
@Composable
fun TinderActionButton(
size: Dp,
color: Color,
icon: ImageVector,
label: String,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.92f else 1f, spring(0.5f, 600f), label = "actionScale")
val haptics = LocalHapticFeedback.current
Box(
modifier = Modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.background(TinderColors.Canvas)
.border(2.dp, color, CircleShape)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onClick()
}
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(size * 0.42f))
}
}
Primary Brand CTA (flame gradient)
@Composable
fun TinderBrandPillButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "ctaScale")
Box(
modifier = modifier
.scale(scale)
.fillMaxWidth()
.heightIn(min = 52.dp)
.clip(CircleShape)
.background(TinderBrandGradient) // pink→orange, branded
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 32.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = TinderText.Button, color = Color.White)
}
}
Match Screen
@Composable
fun TinderMatchScreen(
yourPhotoUrl: String,
theirPhotoUrl: String,
theirName: String,
onSendMessage: () -> Unit,
onKeepPlaying: () -> Unit,
) {
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) } // ~success
Box(
Modifier.fillMaxSize().background(TinderBrandGradient), // full-screen flame gradient
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("It's a Match!", style = TinderText.MatchHero, color = Color.White)
Text("You and $theirName liked each other", style = TinderText.Body, color = Color.White.copy(alpha = 0.8f))
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
MatchAvatar(yourPhotoUrl)
MatchAvatar(theirPhotoUrl)
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// White pill with gradient text
Box(
Modifier.fillMaxWidth().clip(CircleShape).background(Color.White)
.clickable(onClick = onSendMessage).padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text("Send Message", style = TinderText.Button, color = TinderColors.Pink) }
// Outline pill
Box(
Modifier.fillMaxWidth().clip(CircleShape).border(2.dp, Color.White, CircleShape)
.clickable(onClick = onKeepPlaying).padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text("Keep Playing", style = TinderText.Button, color = Color.White) }
}
}
}
}
@Composable
private fun MatchAvatar(url: String) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(120.dp).clip(CircleShape).border(3.dp, Color.White, CircleShape),
contentScale = ContentScale.Crop,
)
}
Chat Bubble (gradient sender / muted self)
enum class ChatSender { ME, THEM }
@Composable
fun TinderChatBubble(text: String, sender: ChatSender, modifier: Modifier = Modifier) {
val isThem = sender == ChatSender.THEM
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = if (isThem) Arrangement.Start else Arrangement.End,
) {
Box(
Modifier
.widthIn(max = 280.dp)
.clip(RoundedCornerShape(20.dp))
.then(
if (isThem) Modifier.background(TinderBrandGradient) // matched sender = flame gradient
else Modifier.background(TinderColors.SurfaceMuted)
)
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Text(text, style = TinderText.Chat, color = if (isThem) Color.White else TinderColors.TextPrimary)
}
}
}
4. Swipe Card Stack (the distinctive interaction)
Tinder has no color extraction; its defining dynamic system is the peeking card stack. The next card sits behind at scale 0.95 and rises to 1.0 over 250ms as the top card commits off-screen — mirroring DESIGN.md §5 (“subsequent cards peek behind, slight scale offset 0.95”).
@Composable
fun <T> SwipeCardStack(
items: List<T>,
modifier: Modifier = Modifier,
onSwiped: (T, SwipeDirection) -> Unit,
card: @Composable (T) -> Unit,
) {
var topIndex by remember { mutableIntStateOf(0) }
Box(modifier, contentAlignment = Alignment.Center) {
// Render up to two cards: the peeking next card, then the active top card
val nextIndex = topIndex + 1
if (nextIndex <= items.lastIndex) {
val rise by animateFloatAsState(0.95f, spring(0.8f, 300f), label = "nextRise")
Box(Modifier.graphicsLayer { scaleX = rise; scaleY = rise }) {
card(items[nextIndex])
}
}
if (topIndex <= items.lastIndex) {
key(topIndex) {
Box {
// TinderSwipeCard is the active card; on commit, advance the stack
card(items[topIndex])
}
}
}
}
// Caller wires TinderSwipeCard's onSwipe to: onSwiped(items[topIndex], dir); topIndex++
}
Hoist topIndex and the per-card swipe callback into a ViewModel; the action bar’s Like/Nope/Super buttons drive the same commit(...) path so users who cannot drag still have full parity.
5. Bottom Navigation (icon-only)
Tinder’s iOS tab bar is icon-only (Flame | Star | Chat | Profile), no labels, taller than typical, with the active tint = flame gradient. Material 3’s NavigationBar always reserves label space, so we render a custom row to keep it spartan. Android has no live blur — use a 96%-opaque canvas surface with a hairline top border.
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.ui.draw.drawBehind
@Composable
fun TinderBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val items = listOf(
"Discover" to Icons.Filled.Whatshot,
"Top Picks" to Icons.Filled.Star,
"Matches" to Icons.Filled.ChatBubble,
"Profile" to Icons.Filled.Person,
)
Row(
Modifier
.fillMaxWidth()
.background(TinderColors.Canvas.copy(alpha = 0.96f))
.drawBehind {
drawLine(TinderColors.Divider, androidx.compose.ui.geometry.Offset(0f, 0f),
androidx.compose.ui.geometry.Offset(size.width, 0f), 0.5.dp.toPx())
}
.windowInsetsPadding(WindowInsets.navigationBars)
.height(64.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { i, (label, icon) ->
val active = selected == i
Box(
Modifier.size(48.dp).clip(CircleShape).clickable { onSelect(i) }
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
if (active) {
Icon(icon, null, modifier = Modifier.size(28.dp)
.graphicsLayer(compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(TinderBrandGradient, blendMode = androidx.compose.ui.graphics.BlendMode.SrcAtop)
}, tint = Color.Unspecified)
} else {
Icon(icon, null, tint = TinderColors.TextTertiary, modifier = Modifier.size(28.dp))
}
}
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Swipe drag | graphicsLayer { translationX/Y; rotationZ = (dx/12).coerceIn(-15f,15f) } driven by detectDragGestures + Animatable.snapTo |
| Snap back | Animatable.animateTo(0f, spring(dampingRatio = 0.7f, stiffness = 200f)) over ~400ms |
| Swipe commit | animateTo(±1600f, tween(400)) off-screen + HapticFeedbackType.LongPress |
| Next card rise | animateFloatAsState(1f, spring(dampingRatio = 0.8f)) 0.95 → 1.0 over ~250ms |
| Action button press | animateFloatAsState(0.92f, spring(dampingRatio = 0.5f, stiffness = 600f)) + glyph fill flash |
| Match reveal | full-screen gradient + confetti via Animatable keyframes (see below); success-style haptic |
| New-match glow | rememberInfiniteTransition ring scale 1.0 → 1.2, alpha 1 → 0, 1.5s RepeatMode.Restart |
// Pulsing green "new match" ring around an avatar (DESIGN.md §4 New Match Glow)
@Composable
fun NewMatchGlow(content: @Composable () -> Unit) {
val t = rememberInfiniteTransition(label = "glow")
val scale by t.animateFloat(1f, 1.2f, infiniteRepeatable(tween(1500), RepeatMode.Restart), label = "glowScale")
val alpha by t.animateFloat(1f, 0f, infiniteRepeatable(tween(1500), RepeatMode.Restart), label = "glowAlpha")
Box(contentAlignment = Alignment.Center) {
Box(
Modifier.matchParentSize()
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.border(2.dp, TinderColors.MatchGlow, CircleShape)
)
content()
}
}
Haptics: prefer LocalHapticFeedback. For richer control on commit/match use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(12, ...) to approximate the iOS .medium impact; chain two effects for the Boost activation’s heavy+medium pattern.
7. Icons
Tinder ships custom glyphs (the flame logo especially); the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Tinder’s flame logo and the rounded action glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Flame (Discover tab) | flame.fill |
Icons.Filled.Whatshot (or custom flame drawable) |
| Star (Top Picks / Super Like) | star.fill |
Icons.Filled.Star |
| Chat (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| Profile (tab) | person.fill |
Icons.Filled.Person |
| Rewind (action) | arrow.counterclockwise |
Icons.Filled.Replay |
| Nope (action) | xmark |
Icons.Filled.Close |
| Like (action) | heart.fill |
Icons.Filled.Favorite |
| Boost (action) | bolt.fill |
Icons.Filled.Bolt |
| Verified | checkmark.seal.fill |
Icons.Filled.Verified |
| Info (card ⓘ) | info.circle.fill |
Icons.Filled.Info |
| Send | arrow.up |
Icons.AutoMirrored.Filled.Send |
| GIF picker | photo.on.rectangle.angled |
Icons.Filled.Gif |
| Settings | gearshape.fill |
Icons.Filled.Settings |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; gesture + spring motion are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. The swipe screen extends under the status bar with a transparent top; applyModifier.windowInsetsPadding(WindowInsets.systemBars)so the card’s 16.dp margins and the action bar clear the gesture nav and the home indicator. - Font scaling:
sphonors the user’s font scale automatically — keep it on the profile name/age, bio, and chat. Pin layout-sensitive text: the rotatedLIKE/NOPE/SUPER LIKEstamps and the photo paginator are glance-/layout-critical — derive their size fromdpor wrap inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Tab area has no labels, so nothing to pin there. - TalkBack: the swipe card needs
Modifier.semantics { contentDescription = "$name, $age, $distance, $occupation. Double tap for profile." }and custom accessibility actions for Like/Pass/Super so non-drag users have parity (the action-bar buttons already expose discrete targets with clearcontentDescriptions — “Rewind”, “Pass”, “Super Like”, “Like”, “Boost”). - Touch targets: Material guidance is 48.dp minimum. The 56.dp Like/Nope buttons clear it; the 40.dp Super Like and the 32.dp card info button must carry a 48.dp hit area via padding even though the glyph is small. Card drag area is the full 3:4 surface.
- Contrast: white name/age sits on a
rgba(0,0,0,0.7)legibility gradient — always keep the gradient under text over photos. The flame-gradient CTAs use pure white text; validate the lighter#FF6036end against white at 16sp Bold (it passes WCAG AA at that weight).#737373meta on white passes AA at 14sp+. - Reduce data / motion: respect a reduced-motion preference by skipping the confetti burst and the card rotation during drag (keep translation only), and replacing the match-screen entrance with a plain fade.
- Dynamic color: do not enable Material You
dynamicLightColorScheme()/dynamicDarkColorScheme()— Tinder is a strong-brand app: the flame pink→orange gradient and the fixed five-verb action colors must not shift with the user’s wallpaper.