SoundCloud (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports SoundCloud’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the signature waveform scrubber, 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 (SoundCloud’s light canvas, single-orange accent, the commentable waveform) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, aCanvas-drawn waveform instead of a SwiftUI shape,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images.
1. Color Tokens
// ui/theme/SoundCloudColors.kt
import androidx.compose.ui.graphics.Color
object SoundCloudColors {
// Light
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF2F2F2)
val Divider = Color(0xFFE5E5E5)
// Dark
val CanvasDark = Color(0xFF1A1A1A)
val SurfaceDark = Color(0xFF262626)
val DividerDark = Color(0xFF333333)
// Text
val TextPrimary = Color(0xFF333333)
val TextPrimaryDark = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF999999)
val TextTertiary = Color(0xFFBFBFBF)
// Brand
val Orange = Color(0xFFFF5500)
val OrangeLight = Color(0xFFFF7700)
val OrangePressed = Color(0xFFE64A00)
val ErrorRed = Color(0xFFE5484D)
}
SoundCloud ships both light and dark. Provide both Material 3 schemes; the orange is constant across them.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.isSystemInDarkTheme
private val SCLight = lightColorScheme(
primary = SoundCloudColors.Orange,
onPrimary = Color.White,
background = SoundCloudColors.Canvas,
onBackground = SoundCloudColors.TextPrimary,
surface = SoundCloudColors.Surface,
onSurface = SoundCloudColors.TextPrimary,
surfaceVariant = SoundCloudColors.Surface,
outline = SoundCloudColors.Divider,
error = SoundCloudColors.ErrorRed,
)
private val SCDark = darkColorScheme(
primary = SoundCloudColors.Orange,
onPrimary = Color.White,
background = SoundCloudColors.CanvasDark,
onBackground = SoundCloudColors.TextPrimaryDark,
surface = SoundCloudColors.SurfaceDark,
onSurface = SoundCloudColors.TextPrimaryDark,
surfaceVariant = SoundCloudColors.SurfaceDark,
outline = SoundCloudColors.DividerDark,
error = SoundCloudColors.ErrorRed,
)
@Composable
fun SoundCloudTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) SCDark else SCLight,
typography = SoundCloudTypography,
content = content,
)
2. Typography
Interstate is licensed. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the bundled Inter, then the system font (Roboto).
// ui/theme/SoundCloudType.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 Interstate = FontFamily(
Font(R.font.interstate_regular, FontWeight.Normal), // 400
Font(R.font.interstate_medium, FontWeight.Medium), // 500
Font(R.font.interstate_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object SoundCloudText {
val TitleLarge = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
val NowPlaying = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val Section = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val ProfileName = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.1).sp)
val TrackTitle = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp)
val CardTitle = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp)
val Subtitle = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Body = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Comment = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Meta = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val LabelUpper = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(Interstate, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
val Timestamp = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 13.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val SoundCloudTypography = Typography(
headlineLarge = SoundCloudText.TitleLarge,
headlineSmall = SoundCloudText.Section,
titleMedium = SoundCloudText.TrackTitle,
bodyMedium = SoundCloudText.Body,
labelSmall = SoundCloudText.Tab,
)
3. Signature Components
Commentable Waveform Scrubber
The iOS waveform is a custom-drawn amplitude graph. On Android, draw it on a Compose Canvas, detect drag for seek, and pin comment avatars along the baseline.
import androidx.compose.foundation.Canvas
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.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
data class WaveComment(val id: String, val position: Float, val avatarUrl: String, val text: String)
@Composable
fun WaveformScrubber(
samples: List<Float>, // 0..1 normalized amplitudes
progress: Float, // 0..1 playback position
comments: List<WaveComment> = emptyList(),
height: Dp = 64.dp,
onSeek: (Float) -> Unit = {},
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
var width by remember { mutableStateOf(1f) }
Box(modifier.fillMaxWidth().height(height + 16.dp)) {
Canvas(
Modifier
.fillMaxWidth()
.height(height)
.pointerInput(Unit) {
detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) }
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
val p = (change.position.x / size.width).coerceIn(0f, 1f)
onSeek(p)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
) {
width = size.width
val barW = 1.5.dp.toPx()
val gap = 1.dp.toPx()
val count = (size.width / (barW + gap)).toInt().coerceAtLeast(1)
for (i in 0 until count) {
val amp = if (samples.isEmpty()) 0.3f else samples[(i * samples.size / count)]
val played = i.toFloat() / count <= progress
val h = (amp * (size.height - 8.dp.toPx())).coerceAtLeast(3.dp.toPx())
drawBar(
x = i * (barW + gap),
height = h,
width = barW,
canvasHeight = size.height,
color = if (played) SoundCloudColors.Orange
else SoundCloudColors.OrangeLight.copy(alpha = 0.45f),
)
}
// playhead
val px = progress * size.width
drawLine(
color = SoundCloudColors.TextPrimary,
start = Offset(px, 0f),
end = Offset(px, size.height),
strokeWidth = 1.dp.toPx(),
)
}
// inline comment avatars on the baseline
comments.forEach { c ->
AsyncImage(
model = c.avatarUrl,
contentDescription = "Comment by ${c.id}",
modifier = Modifier
.size(20.dp)
.offset { IntOffset((c.position * width - 10.dp.toPx()).roundToInt(), (height - 4.dp).roundToPx()) }
.clip(CircleShape)
.border(1.5.dp, SoundCloudColors.Canvas, CircleShape),
)
}
}
}
private fun DrawScope.drawBar(x: Float, height: Float, width: Float, canvasHeight: Float, color: Color) {
drawRoundRect(
color = color,
topLeft = Offset(x, (canvasHeight - height) / 2f),
size = Size(width, height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(1f, 1f),
)
}
Primary Play FAB
@Composable
fun SCPlayButton(
isPlaying: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 64.dp,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.93f else 1f, spring(dampingRatio = 0.7f), label = "fab")
val haptics = LocalHapticFeedback.current
Box(
modifier
.size(size)
.scale(scale)
.shadow(20.dp, CircleShape, spotColor = SoundCloudColors.Orange.copy(alpha = 0.32f))
.clip(CircleShape)
.background(if (pressed) SoundCloudColors.OrangePressed else SoundCloudColors.Orange)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(size * 0.4f),
)
}
}
Primary / Outline Pill (Follow / Following)
enum class PillStyle { Filled, Outline }
@Composable
fun SCPill(
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 = "pill")
val filled = style == PillStyle.Filled
val shape = RoundedCornerShape(4.dp) // SoundCloud's slight rounding, not a full pill
Box(
modifier
.scale(scale)
.clip(shape)
.then(
if (filled) Modifier.background(if (pressed) SoundCloudColors.OrangePressed else SoundCloudColors.Orange)
else Modifier.border(1.dp, if (pressed) SoundCloudColors.Orange else SoundCloudColors.TextSecondary, shape)
)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = if (filled) 24.dp else 20.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = if (filled) SoundCloudText.Button else SoundCloudText.Subtitle,
color = if (filled) Color.White else MaterialTheme.colorScheme.onBackground,
)
}
}
Track Row (with mini-waveform)
@Composable
fun TrackRow(
title: String,
uploader: String,
artworkUrl: String,
samples: List<Float>,
progress: Float,
isPlaying: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier
.fillMaxWidth()
.height(72.dp)
.background(if (pressed) MaterialTheme.colorScheme.surface else Color.Transparent)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = artworkUrl,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(uploader, style = SoundCloudText.Subtitle, color = SoundCloudColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
title,
style = SoundCloudText.TrackTitle,
color = if (isPlaying) SoundCloudColors.Orange else MaterialTheme.colorScheme.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Box(Modifier.width(110.dp).height(28.dp)) {
WaveformScrubber(samples = samples, progress = progress, height = 28.dp)
}
Icon(Icons.Filled.MoreHoriz, "More", tint = SoundCloudColors.TextSecondary, modifier = Modifier.size(20.dp))
}
}
4. Inline Comment Reveal
Drive reveal from the player position so a comment surfaces exactly as the orange sweep reaches its avatar:
@Composable
fun rememberRevealedComment(progress: Float, comments: List<WaveComment>): WaveComment? {
var revealed by remember { mutableStateOf<WaveComment?>(null) }
LaunchedEffect(progress) {
comments.firstOrNull { kotlin.math.abs(it.position - progress) < 0.004f }?.let {
revealed = it
kotlinx.coroutines.delay(3000)
revealed = null
}
}
return revealed
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. SoundCloud’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque canvas surface. Active tint is SoundCloud Orange.
@Composable
fun SCBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Library" to Icons.Filled.LibraryMusic,
"Upload" to Icons.Filled.AddCircle,
"You" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(if (label == "Upload") 28.dp else 24.dp)) },
label = { Text(label, style = SoundCloudText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = SoundCloudColors.Orange,
selectedTextColor = SoundCloudColors.Orange,
unselectedIconColor = SoundCloudColors.TextSecondary,
unselectedTextColor = SoundCloudColors.TextSecondary,
indicatorColor = Color.Transparent, // SoundCloud has no Material pill
),
)
}
}
}
The persistent Now Playing mini-bar sits directly above this bar: a 52.dp Surface row with a 2.dp SoundCloudColors.Orange line pinned to its top edge as the always-visible progress cue — render it in the Scaffold bottomBar slot stacked above SCBottomBar.
6. Motion
| Moment | Compose recipe |
|---|---|
| Waveform progress sweep | Advance progress linearly from the player clock (position / duration); recompose the Canvas — no easing |
| Play/pause tap | animateFloatAsState 1 → 0.93 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress |
| Inline comment reveal | AnimatedVisibility + slideInVertically + fadeIn over 200ms when the playhead passes the avatar |
| Like tap | Animatable keyframes 1 → 1.2 → 1 over 280ms; HapticFeedbackType.LongPress |
| Scrub | HapticFeedbackType.TextHandleMove on each drag delta crossing ~5% |
| Mini-bar → full player | SharedTransitionLayout + Modifier.sharedElement() on the artwork (Compose 1.7+); fallback AnimatedVisibility + slideInVertically |
// Like bounce
@Composable
fun LikeButton(liked: Boolean, onToggle: () -> Unit) {
val scale = remember { Animatable(1f) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Icon(
if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = "Like",
tint = if (liked) SoundCloudColors.Orange else SoundCloudColors.TextSecondary,
modifier = Modifier
.size(20.dp)
.scale(scale.value)
.clickable {
scope.launch {
scale.animateTo(1.2f, tween(140)); scale.animateTo(1f, tween(140))
}
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggle()
},
)
}
Haptics: prefer LocalHapticFeedback. For scrub ticks, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) approximates iOS’s selection feedback.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export SoundCloud’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Next / Previous | forward.end.fill / backward.end.fill |
Icons.Filled.SkipNext / SkipPrevious |
| Shuffle | shuffle |
Icons.Filled.Shuffle |
| Repeat | repeat |
Icons.Filled.Repeat |
| Like | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Repost | arrow.2.squarepath |
Icons.Filled.Repeat |
| Comment | bubble.right |
Icons.Outlined.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Library (tab) | play.square.stack |
Icons.Filled.LibraryMusic |
| Upload (tab) | arrow.up.circle.fill |
Icons.Filled.AddCircle |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; the
Canvaswaveform + modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; applyModifier.windowInsetsPadding(WindowInsets.systemBars)orScaffoldinsets so the mini-bar clears gesture nav. - Font scaling:
sphonors the user’s font scale automatically — keep it on titles, uploader names, body, comments. Pin layout-sensitive text (waveform timestamps, tab labels) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: give the waveform
Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) }and a custom seek action; announce inline comments viaLiveRegionMode.Polite. - Touch targets: Material guidance is 48.dp minimum. The 64.dp FAB clears it; ensure the 20.dp action icons get
Modifier.size(48.dp)hit area via padding. - Contrast:
#999999on#FFFFFFpasses WCAG AA at 14sp+; validate the 11sp timestamps and darken toward#808080if targeting strict compliance. - Dynamic color: do not enable Material You
dynamicColorScheme()— SoundCloud’s brand requires the fixed orange and the#FFFFFF/#1A1A1Acanvases regardless of wallpaper.