Apple Fitness (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Apple Fitness’s rings-on-black visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the Activity rings + Summary + Fitness+ shelf, navigation, motion, and haptics.
Why a Compose guide for an Apple app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (true-black canvas, the three immutable ring colors on 22%-opacity tracks, grouped rounded cards, the label-opacity text ramp, cinematic Fitness+ artwork) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Canvasfor the rings, a MaterialSurfaceinstead of a grouped UITableView cell,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for Fitness+ artwork. Apple Fitness is dark-first. Do not enable Material You dynamic color — the three ring colors are the brand and must never be re-themed to the wallpaper.
1. Color Tokens
// ui/theme/AFColors.kt
import androidx.compose.ui.graphics.Color
object AFColors {
// The three Activity rings (the brand — immutable, theme-invariant)
val Move = Color(0xFFFA114F)
val MoveLabel = Color(0xFFFF375F) // brightened on dark for text
val Exercise = Color(0xFF92E82A)
val ExerciseHi = Color(0xFF66FF00)
val Stand = Color(0xFF1EE4E1)
val StandHi = Color(0xFF00F0FF)
// Ring tracks = ring color @ 22% opacity
val MoveTrack = Move.copy(alpha = 0.22f)
val ExerciseTrack = Exercise.copy(alpha = 0.22f)
val StandTrack = Stand.copy(alpha = 0.22f)
// Chrome accent (single)
val Accent = Color(0xFFFF375F)
val AccentPressed = Color(0xFFD80E45)
val FitnessPlus = Color(0xFFC969E0)
// Surfaces (dark — primary)
val Canvas = Color(0xFF000000)
val Grouped1 = Color(0xFF1C1C1E)
val Grouped2 = Color(0xFF2C2C2E)
val Grouped3 = Color(0xFF3A3A3C)
val Separator = Color(0xFF38383A)
val Fill = Color(0xFF767680).copy(alpha = 0.24f)
// Surfaces (light — tablet / system light)
val LightCanvas = Color(0xFFF2F2F7)
val LightSurface = Color(0xFFFFFFFF)
val LightSeparator = Color(0xFFC6C6C8)
// Text — Apple label-opacity ramp (white-on-dark)
val LabelPrimary = Color(0xFFFFFFFF)
val LabelSecondary = Color(0xFFEBEBF5).copy(alpha = 0.60f)
val LabelTertiary = Color(0xFFEBEBF5).copy(alpha = 0.30f)
// Semantic
val Success = Color(0xFF30D158)
val Error = Color(0xFFFF453A)
val AwardGold = Color(0xFFFFD60A)
}
Wire it into a dark-first scheme. The ring colors live outside the M3 scheme on purpose — they are brand constants, not theme roles.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val AFDark = darkColorScheme(
primary = AFColors.Accent, // Move-pink chrome accent
onPrimary = AFColors.LabelPrimary,
background = AFColors.Canvas,
onBackground = AFColors.LabelPrimary,
surface = AFColors.Grouped1,
onSurface = AFColors.LabelPrimary,
surfaceVariant = AFColors.Grouped2,
outline = AFColors.Separator,
error = AFColors.Error,
)
private val AFLight = lightColorScheme(
primary = AFColors.Accent,
onPrimary = AFColors.LabelPrimary,
background = AFColors.LightCanvas,
onBackground = Color(0xFF000000),
surface = AFColors.LightSurface,
onSurface = Color(0xFF000000),
surfaceVariant = Color(0xFFE5E5EA),
outline = AFColors.LightSeparator,
error = AFColors.Error,
)
@Composable
fun AFTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) AFDark else AFLight, // dark is the intended default (phone)
typography = AFTypography,
content = content,
)
2. Typography
Apple Fitness uses SF Pro (iOS system). On Android the closest substitute is Inter (SIL OFL) — drop the TTFs in res/font/. Follow Apple’s text-style ramp; numerals tabular; secondary/tertiary text via the label-opacity colors above.
// ui/theme/AFType.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),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)
private const val TNUM = "tnum" // tabular figures
object AFText {
val LargeTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 40.sp, lineHeight = 44.sp, letterSpacing = (-1.0).sp)
val Date = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.6).sp)
val Header = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.5).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.4).sp)
val Title3 = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 25.sp, letterSpacing = (-0.3).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val BodyReg = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val RingValue = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 19.sp, lineHeight = 21.sp, fontFeatureSettings = TNUM)
val TileValue = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 24.sp, fontFeatureSettings = TNUM)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val Footnote = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Eyebrow = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 15.sp, letterSpacing = 0.4.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.2).sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp)
val Badge = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.5.sp)
}
val AFTypography = Typography(
headlineLarge = AFText.LargeTitle,
titleLarge = AFText.Section,
bodyLarge = AFText.Body,
labelSmall = AFText.Tab,
)
3. Signature Components
Activity Rings (Canvas)
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun ActivityRings(
move: Float, exercise: Float, stand: Float,
size: Dp = 130.dp, lineWidth: Dp = 14.dp,
) {
val m = remember { Animatable(0f) }
val e = remember { Animatable(0f) }
val s = remember { Animatable(0f) }
LaunchedEffect(Unit) {
val spec = tween<Float>(1000, easing = LinearOutSlowInEasing)
launch { m.animateTo(move.coerceAtMost(1f), spec) }
launch { delay(80); e.animateTo(exercise.coerceAtMost(1f), spec) }
launch { delay(160); s.animateTo(stand.coerceAtMost(1f), spec) }
}
Canvas(Modifier.size(size)) {
val sw = lineWidth.toPx()
val gap = sw + 3.dp.toPx()
fun ring(progress: Float, color: Color, track: Color, inset: Float) {
val d = this.size.minDimension - sw - inset * 2
val topLeft = Offset(sw / 2 + inset, sw / 2 + inset)
val arc = Size(d, d)
drawArc(track, 0f, 360f, false, topLeft, arc, style = Stroke(sw))
drawArc(color, -90f, 360f * progress, false, topLeft, arc,
style = Stroke(sw, cap = StrokeCap.Round))
}
ring(m.value, AFColors.Move, AFColors.MoveTrack, 0f)
ring(e.value, AFColors.Exercise, AFColors.ExerciseTrack, gap)
ring(s.value, AFColors.Stand, AFColors.StandTrack, gap * 2)
}
}
Ring Legend + Hero Card
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Alignment
private data class Leg(val name: String, val value: String, val goal: String, val color: Color)
@Composable
fun RingHeroCard() {
val legs = listOf(
Leg("Move", "486", "/620 KCAL", AFColors.MoveLabel),
Leg("Exercise", "38", "/30 MIN", AFColors.Exercise),
Leg("Stand", "9", "/12 HR", AFColors.Stand),
)
Row(
Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(18.dp))
.background(AFColors.Grouped1)
.padding(22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
ActivityRings(move = 0.80f, exercise = 1.27f, stand = 0.75f, size = 130.dp)
Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.weight(1f)) {
legs.forEach { l ->
Column {
Text(l.name.uppercase(), style = AFText.Eyebrow, color = l.color)
Row(verticalAlignment = Alignment.Bottom) {
Text(l.value, style = AFText.RingValue, color = AFColors.LabelPrimary)
Text(" ${l.goal}", style = AFText.Footnote.copy(fontSize = 12.sp),
color = AFColors.LabelSecondary)
}
}
}
}
}
}
Metric Tile
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun MetricTile(
icon: ImageVector, tint: Color, name: String,
value: String, unit: String = "", sub: String,
modifier: Modifier = Modifier,
) {
Column(
modifier
.clip(RoundedCornerShape(14.dp))
.background(AFColors.Grouped1)
.padding(14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(icon, null, tint = tint, modifier = Modifier.size(16.dp))
Text(name, style = AFText.Footnote.copy(fontSize = 12.sp), color = AFColors.LabelSecondary)
}
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
Text(value, style = AFText.TileValue, color = AFColors.LabelPrimary)
if (unit.isNotEmpty())
Text(" $unit", style = AFText.Footnote, color = AFColors.LabelSecondary)
}
Text(sub, style = AFText.Footnote, color = AFColors.LabelTertiary,
modifier = Modifier.padding(top = 2.dp))
}
}
Fitness+ Card
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.ui.graphics.Brush
@Composable
fun FitnessPlusCard(
badge: String, type: String, title: String, meta: String,
gradient: List<Color>,
) {
Column(Modifier.width(168.dp)) {
Box(
Modifier
.width(168.dp).height(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(Brush.linearGradient(gradient)),
) {
// Android has no live blur — translucent dark scrim approximates the frosted badge
Text(
badge.uppercase(), style = AFText.Badge, color = Color.White,
modifier = Modifier
.align(Alignment.TopStart).padding(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(alpha = 0.40f))
.padding(horizontal = 8.dp, vertical = 4.dp),
)
Box(
Modifier
.align(Alignment.BottomStart).padding(14.dp)
.size(34.dp).clip(CircleShape)
.background(Color.White.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.PlayArrow, null, tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Text(type.uppercase(), style = AFText.Eyebrow, color = AFColors.MoveLabel,
modifier = Modifier.padding(top = 10.dp))
Text(title, style = AFText.CardTitle, color = AFColors.LabelPrimary,
modifier = Modifier.padding(top = 3.dp))
Text(meta, style = AFText.Footnote, color = AFColors.LabelSecondary,
modifier = Modifier.padding(top = 2.dp))
}
}
Buttons
import androidx.compose.foundation.clickable
@Composable
fun AFPrimaryButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(AFColors.Move)
.clickable(onClick = onClick)
.padding(vertical = 15.dp),
contentAlignment = Alignment.Center,
) { Text(title, style = AFText.Button, color = Color.White) }
}
@Composable
fun AFTintedButton(title: String, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(AFColors.Move.copy(alpha = 0.18f))
.clickable(onClick = onClick)
.padding(horizontal = 22.dp, vertical = 12.dp),
) { Text(title, style = AFText.Button.copy(fontSize = 15.sp), color = AFColors.Accent) }
}
4. Navigation
Apple Fitness has a 3-tab bottom strip with no tint pill — active is the Move-pink accent only. The header is a day eyebrow + large date + avatar.
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@Composable
fun AFBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = AFColors.Grouped1, tonalElevation = 0.dp) {
val items = listOf(
"Summary" to Icons.Filled.RadioButtonChecked,
"Fitness+" to Icons.Filled.PlayCircleFilled,
"Sharing" to Icons.Filled.Group,
)
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 = AFText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = AFColors.Accent, // Move-pink, no pill
selectedTextColor = AFColors.Accent,
unselectedIconColor = AFColors.LabelTertiary,
unselectedTextColor = AFColors.LabelTertiary,
indicatorColor = Color.Transparent, // no Material pill — Apple uses tint only
),
)
}
}
}
The Summary header is a Column with a day eyebrow (AFText.Eyebrow in MoveLabel), the date (AFText.Date), and a trailing 32.dp avatar; the “Summary” large title collapses with a TopAppBar + nested scroll. The Fitness+ shelf is a LazyRow of FitnessPlusCards with snapFlingBehavior.
5. Motion
Apple Fitness motion: the rings carry the energy; chrome stays calm. 200–1000ms ease-out.
| Moment | Compose recipe |
|---|---|
| Rings sweep from 0 | three Animatable 0→value tween(1000, LinearOutSlowInEasing), staggered 80ms (Move→Exercise→Stand) |
| Ring close | particle burst (a Canvas/InfiniteTransition emitter in the ring color) + success haptic at progress ≥ 1 |
| All rings closed | larger celebration + stronger haptic |
| Card press | Modifier.scale via animateFloatAsState(spring(dampingRatio = 0.7f)) 1→0.97 on press |
| Tab switch | Crossfade(tween(200)); glyph tint tween 150ms |
| Fitness+ shelf | LazyRow + rememberSnapFlingBehavior; parallax via lazyListState item offset |
| Sheet present | ModalBottomSheet slide-up (Material default ~300ms) + scrim |
| Live HUD (in-workout) | rings animateFloatAsState(tween(300)) as metrics change |
| Award flip | graphicsLayer { rotationY } animateFloatAsState(tween(500)) |
// Canonical Apple Fitness motion — staggered ring sweep
LaunchedEffect(Unit) {
val spec = tween<Float>(1000, easing = LinearOutSlowInEasing)
launch { move.animateTo(targetMove, spec) }
launch { delay(80); exercise.animateTo(targetExercise, spec) }
launch { delay(160); stand.animateTo(targetStand, spec) }
}
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) (selection analog) on tab/segment change; a success pattern on ring close / all-rings-closed; HapticFeedbackType.LongPress (medium impact analog) on Start Workout. Reduce motion replaces the sweep with an instant final value via Crossfade.
6. Icons
Apple Fitness uses SF Symbols; the closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Summary (tab) | circle.circle.fill |
Icons.Filled.RadioButtonChecked |
| Fitness+ (tab) | play.rectangle.fill |
Icons.Filled.PlayCircleFilled |
| Sharing (tab) | person.2.fill |
Icons.Filled.Group |
| Steps | figure.walk |
Icons.Filled.DirectionsWalk |
| Distance | (curved path) | Icons.Filled.Straighten |
| Heart Rate | heart.fill |
Icons.Filled.Favorite |
| Workouts | figure.run |
Icons.Filled.DirectionsRun |
| Play (card) | play.fill |
Icons.Filled.PlayArrow |
| Move detail | flame.fill |
Icons.Filled.LocalFireDepartment |
| Exercise detail | figure.run |
Icons.Filled.DirectionsRun |
| Stand detail | figure.stand |
Icons.Filled.Accessibility |
| Award | medal.fill |
Icons.Filled.EmojiEvents |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| See All | chevron.right |
Icons.Filled.ChevronRight |
| Trends up / down | arrow.up.right / arrow.down.right |
Icons.Filled.TrendingUp / Icons.Filled.TrendingDown |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
Canvasrings + modern motion comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); true-black canvas wants light-content system bars in dark mode. The bottom bar respects the navigation inset; the in-workout HUD must respect all insets over video. - Tabular numerals: every numeric
TextStylesetsfontFeatureSettings = "tnum"so the ring legend, metric tiles, and live HUD don’t jitter. - No live blur on Android: Apple’s frosted badges/play buttons and blurred tab bar are approximated with translucent scrims (
Color.Black.copy(alpha = 0.40f)/Color.White.copy(alpha = 0.22f)) or aRenderEffect.createBlurEffecton API 31+; provide the scrim fallback below 31 and when reduce-transparency is requested. - Font scaling:
sphonors the user’s font scale — keep it on titles/body/footnotes. Pin layout-sensitive text (ring legend numerals, in-workout HUD, 10sp tab labels, frosted badges) viadpor a fixed-densityCompositionLocalProvider; ringsizescales with container width, not font scale. - TalkBack: announce rings via
Modifier.semantics(mergeDescendants = true) { contentDescription = "Move ring, 486 of 620 kilocalories, 78 percent" }; metric tiles “Steps, 8214, average 7540”; Fitness+ cards “20 minute HIIT with Bakari, Pop Anthems playlist”. - Color is never the only signal: ring legend rows carry the text label + value beside the colored ring; rings are also distinguishable by radius/position; trend arrows pair color with an up/down glyph.
- Touch targets: Material guidance is 48.dp — metric tiles full-tile tappable (≥ 72.dp), Fitness+ cards full-card (≥ 280.dp), buttons ≥ 48.dp, tab icons 24.dp glyph in the 56.dp bar.
- Contrast: white on
#000000is maximal;#FF375Fon#000000passes WCAG AA for the accent; keep ring labels at the brightened variants for text contrast (raw#FA114Ftext on black is borderline). - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, set the rings to their final value viaCrossfade, drop the particle burst (static “closed” check), no card scale, no shelf parallax, awards crossfade instead of 3D flip. - Dark mode: dark is the default (phone); surfaces step
#000000 → #1C1C1E → #2C2C2E; light is allowed on tablet via the M3 light scheme. The three ring colors and their 22%-opacity tracks NEVER change between modes — they are the brand. Do not enable Material YoudynamicColorScheme()— the rings must never re-theme to the wallpaper.