Skip to main content
ClaudeWave
Skill1.2k repo starsupdated 3d ago

compose-multiplatform

Compose Multiplatform / KMP patterns - expect/actual composables, platform-specific code, density and font handling cross-target, iOS/Android/Desktop interop.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/Jwuthri/Tracely-ai /tmp/compose-multiplatform && cp -r /tmp/compose-multiplatform/.agents/skills/compose-multiplatform ~/.claude/skills/compose-multiplatform
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# Compose Multiplatform

> Compose Multiplatform (CMP) and Kotlin Multiplatform (KMP) patterns for cross-platform UI.
> Loaded for projects with `org.jetbrains.compose` plugin.
> Foundation: `../compose-motion/SKILL.md` for animation API; this file covers what's specific to writing one Compose codebase for Android + iOS + Desktop + Web.

---

## KMP vs CMP - quick clarification

**KMP** (Kotlin Multiplatform) is the language and build infrastructure: shared Kotlin code compiled to JVM, Native (iOS, macOS, Linux, Windows), and Wasm. **CMP** (Compose Multiplatform) is the UI framework on top of KMP, built by JetBrains as a port of Jetpack Compose. You write a single Compose codebase in `commonMain` that runs on Android, iOS, Desktop (JVM), and Web (Wasm). Platform-specific code lives in `androidMain`, `iosMain`, `desktopMain`, `wasmJsMain` and is wired in via `expect`/`actual` declarations.

---

## Project structure

```
composeApp/
├── src/
│   ├── commonMain/        ← shared Compose code (most of the app)
│   │   └── kotlin/
│   ├── androidMain/       ← Android-specific (uses Activity, Context)
│   ├── iosMain/           ← iOS-specific (uses UIKit/UIView interop)
│   ├── desktopMain/       ← JVM desktop (uses java.awt/swing if needed)
│   └── wasmJsMain/        ← Wasm web target
├── build.gradle.kts
iosApp/                    ← Xcode project consuming the generated framework
androidApp/                ← Android Application module (often merged into composeApp)
```

The `commonMain` folder should hold 80-95% of your code in a well-architected CMP project. If `iosMain` or `androidMain` start growing past a few hundred lines, you're probably leaking platform concerns into UI logic that could stay shared.

---

## `expect`/`actual` pattern

The KMP escape hatch when you genuinely need different implementations per target. Declare the contract once in `commonMain`, implement it once per target.

```kotlin
// commonMain
expect fun openShareSheet(text: String)

// androidMain
actual fun openShareSheet(text: String) {
    val intent = Intent(Intent.ACTION_SEND).apply {
        type = "text/plain"
        putExtra(Intent.EXTRA_TEXT, text)
    }
    context.startActivity(Intent.createChooser(intent, null))
}

// iosMain
actual fun openShareSheet(text: String) {
    val activityVC = UIActivityViewController(
        activityItems = listOf(text),
        applicationActivities = null
    )
    UIApplication.sharedApplication.keyWindow
        ?.rootViewController
        ?.presentViewController(activityVC, true, null)
}
```

`expect`/`actual` works for top-level functions, classes, type aliases, and properties. The signature in `actual` must match exactly, including modifiers and default values.

---

## `expect`/`actual` for composables

Composables follow the same rules. Useful when a feature needs a platform-specific Compose API (Android `RuntimeShader`, iOS `UIKitView`, Desktop `SwingPanel`).

```kotlin
// commonMain
@Composable
expect fun PlatformBlur(modifier: Modifier = Modifier, content: @Composable () -> Unit)

// androidMain (uses RuntimeShader on Android 13+)
@Composable
actual fun PlatformBlur(modifier: Modifier, content: @Composable () -> Unit) {
    Box(modifier.graphicsLayer { renderEffect = blurEffect }) { content() }
}

// iosMain (uses UIVisualEffectView via UIKitView)
@Composable
actual fun PlatformBlur(modifier: Modifier, content: @Composable () -> Unit) {
    Box(modifier) {
        UIKitView(
            factory = { UIVisualEffectView(effect = UIBlurEffect.systemMaterial()) },
            modifier = Modifier.matchParentSize()
        )
        content()
    }
}
```

Rule: `expect` composables should be the exception, not the rule. Most "platform feel" differences can be tuned via tokens (colors, corner radii, spring stiffness) in `commonMain`, not via separate code paths.

---

## `LocalDensity` cross-platform

On Android, `LocalDensity.current.density` reflects the device DPI bucket (1.0, 1.5, 2.0, 3.0...). On iOS, density is computed from `UIScreen.scale` (typically 2.0 or 3.0 on Retina). On Desktop, density depends on the screen scaling factor (1.0 by default; 2.0 on Retina-class displays; user-configurable on Windows). On Wasm, density follows `window.devicePixelRatio`.

Don't hardcode `Dp` to pixel ratios; trust `Dp` and `LocalDensity` to handle conversion. If you need an exact pixel value (e.g., for a `Canvas` draw operation), do the conversion explicitly:

```kotlin
val density = LocalDensity.current
val pxValue = with(density) { 16.dp.toPx() }
```

Avoid reading `density` inside hot loops; cache the conversion.

---

## `LocalConfiguration` and platform-aware UI

`LocalConfiguration.current` is **Android-only** and lives in `androidMain`. For CMP, prefer the cross-platform alternatives:

- `LocalWindowInfo.current.containerSize` - the window/screen size as `IntSize`, available in `commonMain`.
- `LocalDensity.current` - density, available in `commonMain`.
- `LocalLayoutDirection.current` - LTR / RTL.
- `BoxWithConstraints { ... }` - read `maxWidth` / `maxHeight` directly inside layout.

If you need real device characteristics (orientation, idiom, model), wrap the access in `expect`/`actual` and pass a typed object like `PlatformInfo` to the common layer.

---

## Fonts cross-platform via Compose Resources

`org.jetbrains.compose.resources` is the shared resources plugin. Drop fonts in `commonMain/composeResources/font/`, and the Gradle plugin generates a typed `Res` accessor.

```
composeApp/src/commonMain/composeResources/
├── font/
│   ├── Inter-Regular.ttf
│   └── Inter-Bold.ttf
├── drawable/
│   └── logo.svg
└── values/
    ├── strings.xml          ← default locale
    └── strings.fr.xml       ← French overrides
```

Usage in `commonMain`:

```kotlin
import myproject.composeapp.generated.resources.Inter_Regular
import myproject.composeapp.generated.resources.Inter_Bold
import myproject.composeapp.generated.resources.Res

val InterFamily = FontFamily(
    Font(Res.f