I build Afterfade on my own — seven five-second videos recorded over a day become one lo-fi track, built with Kotlin Multiplatform and Compose Multiplatform for Android and iOS. Background: why KMP and the Kotlin/Swift split. This post is about the UI.
Not a single Material Button, Card or Icon in this app. Everything — the mechanical keys, the cassette, the spinning reels — is drawn with Canvas and hand-written ImageVectors. The only Material I use is Text and one AlertDialog. I wanted the feel identical on both platforms, down to the millimetre, and what makes them match is that they run the same drawing code. The drawing exists once in commonMain. What worries people about Compose Multiplatform is that iOS will look like an Android app. This one looks like a cassette deck on both — there are no platform widgets to drift towards.
Why I did not use Material
The UI is built around a cassette deck and boombox metaphor. Material3's defaults did not fit — off-the-shelf components arrive with appearance and behaviour attached, and anything that does not fit has to be switched off piece by piece. Faster to draw from nothing.
Every dimension and colour goes through a token
Corner radii, stroke widths and colours all live as tokens in a 127-line theme definition. No raw dp values or colour literals appear in screen code.
object BoomboxElevation {
val Key = 4.dp // how far a mechanical key travels
}
object BoomboxColors {
val Background = Color(0xFFEDEBE8)
val RecRed = Color(0xFFD6431F)
// ...
}
To change how far a key sinks when pressed, I edit one line, and both platforms sink by the same new amount.
What is drawn in Canvas
Roughly 1,800 lines of drawing code live in commonMain — the home screen cassette, the recording deck, the full icon set (hand-written vectors, no icon library), and the mechanical keys. The cassette parts are a shared collection of drawing functions; four screens call into them for the pieces they need.
How the press was built
The buttons recreate the keys on an old boombox — the kind that physically clunk downward when you press them. A flat drop shadow sits behind the key; on press the key body offsets toward it over a 90 ms tween, and a LongPress haptic fires at the moment of contact.
val pressed by interactionSource.collectIsPressedAsState()
val travel by animateDpAsState(
targetValue = if (pressed || isRecording) BoomboxElevation.Key else 0.dp,
animationSpec = tween(durationMillis = 90),
)
LaunchedEffect(pressed) {
if (pressed) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
While recording, isRecording holds the key down. This code lives in commonMain — nothing about the press can drift between platforms.
Screen reader labels are written once
Dropping Material means dropping what those components quietly carried: a Button announces itself, a Canvas says nothing. So the mechanical keys carry name and role explicitly.
Box(
modifier = modifier
.clickable(onClick = onClick)
.semantics {
this.contentDescription = contentDescription
role = Role.Button
},
)
Labels come from four-language string resources — declared once, feeding the accessibility tree on both platforms.
"Shared-looking" is not the same as shared code
I had split the capture screen with expect/actual because it touches the camera. The consequence: parts with nothing to do with the camera — the shutter key, the processing reel, the permission screen — got written twice. They looked identical, but only because every fix had to be repeated in both copies.
flowchart TB
subgraph before["Before"]
A1["Android capture screen<br/>the whole screen"]
I1["iOS capture screen<br/>the whole screen (near copy)"]
end
subgraph after["After"]
C2["Shared code (commonMain)<br/>shutter key, processing view, permission screen"]
A2["Android capture screen<br/>camera only"]
I2["iOS capture screen<br/>camera only"]
C2 --> A2
C2 --> I2
end
before --> after
classDef sharedC fill:#d9ead3,stroke:#38761d,color:#000
classDef platC fill:#f4cccc,stroke:#aa3333,color:#000
class C2 sharedC
class A1,I1,A2,I2 platC
style before fill:#ffffff,stroke:#bbbbbb
style after fill:#ffffff,stroke:#bbbbbb
Moving everything that does not touch a camera API to the shared side gave a net reduction of 249 lines and brought the shared portion from 64.6% to 70.6%.
UI code written only for iOS
Some things must not be the same. On iOS you swipe from the left edge to go back — without it an app feels wrong. Compose Multiplatform does not provide it, so I wrote it in iosMain. Android does not get this — the left-edge swipe is the OS back gesture.
Which UIs suit this approach
The more you draw yourself, the larger the share that lives in common code. An app with a world of its own sits well with Compose Multiplatform; an app chasing each platform's stock look gets less out of this.
If you are choosing between Compose Multiplatform and Flutter for a hand-drawn UI, the drawing will not settle it. What settles it is whether you have something outside the UI worth keeping in the same language — here, 2,200 lines of audio processing next to 1,800 lines of drawing.
Afterfade is being built for RevenueCat Shipaton 2026.



Top comments (0)