DEV Community

Kanon Fujita
Kanon Fujita

Posted on

[CMP]One Canvas-Drawn Cassette Deck UI, Identical on Android and iOS

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.

The same cassette-deck home screen running on iOS (left) and Android (right)

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)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Two iPhone screenshots with callouts mapping the five Canvas-drawn parts: on the Home screen, the home screen cassette, the shared cassette parts (screws, reels, tape window, stripes), the three mechanical keys and the icon glyphs inside them; on the generating screen, the recording deck with its REC indicator, counter and level meter.

How the press was built

Pressing the shutter key on the capture screen: the key stays pressed for the five-second countdown, then the processing overlay spins the reel

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)
}
Enter fullscreen mode Exit fullscreen mode

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
        },
)
Enter fullscreen mode Exit fullscreen mode

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)