compose-multiplatform-screen
Skill15 starsChanged 22 days agoApache-2.0View on GitHub
Use when adding or extending a Compose Multiplatform screen in commonMain — a new screen, ViewModel, UiState, actions/intents, one-shot side effects (navigate, snackbar), MVI or MVVM presentation, collectAsStateWithLifecycle, koinViewModel, testTag objects, @Preview of screen states, or ViewModel unit tests. Also for effects lost or delivered twice, state not updating, or navigation fired from a ViewModel.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- android-to-kmp-migrationSkill
- compose-design-system-componentSkill
- compose-multiplatform-localizationSkill
- compose-multiplatform-navigationSkill
- figma-to-mobile-uiSkill
- kmp-api-endpointSkill
- kmp-build-checkSkill
- kmp-build-failure-triageSkill
- kmp-ci-github-actionsSkill
- kmp-code-reviewSkill
- kmp-crash-triageSkill
- kmp-data-layerSkill
- kmp-dev-machine-cleanupSkill
- kmp-expect-actualSkill
- kmp-feature-moduleSkill
- kmp-flaky-test-triageSkill
- kmp-gradle-version-catalogSkill
- kmp-koin-diSkill
- kmp-module-healthSkill
- kmp-room-databaseSkill
- kmp-secure-storageSkill
- kmp-swiftui-interopSkill
- kmp-test-coverage-backfillSkill
- kmp-testing-patternsSkill
- kmp-xcframework-distributionSkill
- mobile-accessibility-test-idsSkill
- mobile-bug-fixSkill
- mobile-feature-implementSkill
- mobile-feature-planSkill
- mobile-performance-fixSkill
- mobile-platform-paritySkill
- mobile-releaseSkill
- mobile-security-reviewSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 5 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 36
Do not write code until you can answer each question from the repository itself.
- Very specific: line 40
Presentation pattern: Search for the ViewModel base class (grep -rn "abstract class .*ViewModel" --include=*.kt).
- Distinctive: line 59
New screen: use the project's newest pattern.
- Distinctive: line 61
Never add a second DI framework, navigation library or effect mechanism.
- Very specific: line 96
Update state atomically (MutableStateFlow.update { }), never value = value.copy(...) from concurrent coroutines.
- Very specific: line 131
Use collectAsStateWithLifecycle() (lifecycle-runtime-compose), not collectAsState(): it stops collecting while the host is below STARTED.
- Distinctive: line 152
Follow the project's existing tag format if one exists.
- Distinctive: line 179
Do not test composables here.
- Very specific: line 206
Run what applies and report each check as PASS, FAIL or NOT RUN (reason).
- Very specific: line 217
Use ./gradlew :shared:tasks --all | grep -i test if the task names differ.
- Very specific: line 220
Every interactive element in the new view has a testTag from the screen's TestTags object.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Compose Multiplatform screen
Build every screen as three parts: a stateful entry that collects state, a pure view that
renders UiState and emits actions, and an effect handler that is the only place one-shot
events (navigation, snackbars) are executed. The ViewModel owns state and never touches
navigation, Context or Compose types.
When to use
- Adding a new screen to a Compose Multiplatform app (
commonMain). - Adding state, actions or side effects to an existing screen.
- Writing or fixing ViewModel tests for a screen.
- Symptoms: a navigation event fires twice or never, state "resets" after rotation, a snackbar is
lost when the app is backgrounded, a ViewModel holds a
NavController.
When not to use
- Graph structure, routes, tabs, deep links, auth gates →
compose-multiplatform-navigation. - A reusable button/input/card for the design system →
compose-design-system-component. - Implementing a screen from a Figma frame →
figma-to-mobile-ui(then come back here for the ViewModel and tests). - SwiftUI screens over shared logic → that is iOS-native UI; see Related skills.
Step 0 — Read the project first
Do not write code until you can answer each question from the repository itself.
- Nearest neighbour. Open the most similar existing screen. A real neighbour beats this template; copy its file layout, naming and package structure.
- Presentation pattern. Search for the ViewModel base class
(
grep -rn "abstract class .*ViewModel" --include=*.kt). Is there an MVI base (state + effect, with helpers such assetState {},reduce {}orpostSideEffect()), a plainViewModelwithMutableStateFlow, or several generations side by side? - Effect idiom.
Channel(...).receiveAsFlow(),MutableSharedFlow, or events-as-state? - DI. Koin (
koinViewModel()), Kodein, manual factories, orviewModel { }initializers. - Navigation. navigation-compose (
NavHostController), Voyager, Decompose, Navigation 3. - Design system. Theme object (
AppTheme,MaterialThemeextensions), component prefix, token names for colours/spacing/typography. - Tags and tests. Existing
*TestTagsobjects and their string format; test libraries (kotlin.test,kotlinx-coroutines-test, Turbine); test naming convention. - Versions.
gradle/libs.versions.toml: Compose Multiplatform version decides the preview import (see Quick reference).
Rules that follow from Step 0
- Extending an existing screen: stay in its idiom. A half-migrated ViewModel (some state in an
MVI reducer, some in loose
MutableStateFlows; some events in aChannel, some in aSharedFlow) is worse than either pattern. Migrate a screen only as its own, reviewed change. - New screen: use the project's newest pattern. With no precedent, use the MVI shape below and the small base class in references/mvi-viewmodel-base.md.
- Never add a second DI framework, navigation library or effect mechanism.
Workflow
1. Define the contract (UiState, Action, Effect)
data class NoteEditorUiState(
val title: String = "",
val body: String = "",
val isSaving: Boolean = false,
val error: NoteEditorError? = null,
) {
// Derived flags live in state, not in the composable: one place to test them.
val canSave: Boolean get() = !isSaving && title.isNotBlank()
}
sealed interface NoteEditorAction {
data class TitleChanged(val value: String) : NoteEditorAction
data object SaveClicked : NoteEditorAction
}
sealed interface NoteEditorEffect { // one-shot only; durable things belong in state
data class NoteSaved(val noteId: String) : NoteEditorEffect
}Done when: every value the view renders is a UiState property; every user gesture is an
Action; every effect is something that must happen once (navigate, show a transient message).
Dialog visibility, errors and loading are state, not effects — they must survive rotation.
2. Write the ViewModel
- Extend the project's base, or
MviViewModel<State, Effect>from the reference. - One public entry point:
fun onAction(action: XAction). - Update state atomically (
MutableStateFlow.update { }), nevervalue = value.copy(...)from concurrent coroutines. - Capture the state you need before launching a coroutine (
val draft = currentState); reading it after a suspension point may see newer input. - Repositories/use cases are constructor-injected and main-safe. Inject a dispatcher only where this class itself switches context.
- No
NavController, noContext, no@Composable, noandroidx.compose.*imports.
Done when the ViewModel compiles in commonMain with zero Compose imports.
3. Write the screen (entry + view + effect handler)
@Composable
fun NoteEditorScreen(
onNoteSaved: (noteId: String) -> Unit,
viewModel: NoteEditorViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
NoteEditorView(state = state, onAction = viewModel::onAction)
CollectEffects(viewModel.effects) { effect ->
when (effect) {
is NoteEditorEffect.NoteSaved -> onNoteSaved(effect.noteId)
}
}
}NoteEditorView(state, onAction, modifier)is pure: no ViewModel, nokoinInject, noLaunchedEffectthat navigates. Only view-local state (LazyListState, focus, scroll) may beremembered there.CollectEffects(in the reference) collects with the lifecycle atSTARTEDonDispatchers.Main.immediate. It is the only place a navigation callback is invoked.- Navigation is passed in as lambdas (or the project's navigator). The nav graph owns the
NavController; the screen does not. - Use
collectAsStateWithLifecycle()(lifecycle-runtime-compose), notcollectAsState(): it stops collecting while the host is belowSTARTED.
Done when the view can be called from a preview with a literal UiState and a no-op lambda.
4. Add TestTags and semantics
object NoteEditorTestTags {
const val SCREEN = "note_editor_screen"
const val TITLE_FIELD = "note_editor_title_field"
const val SAVE_BUTTON = "note_editor_save_button"
fun tagChip(tagId: String) = "note_editor_tag_chip_$tagId" // stable backend id, never an index
}- Tag the screen root and every interactive element, loader, error and empty state.
testTagis a locator;contentDescriptionis spoken prose. Never reuse one string for both.- Compose Multiplatform maps
testTagto the iOSaccessibilityIdentifier, so XCUITest/Appium can use the same tags. On Android, UiAutomator/Appium additionally needModifier.semantics { testTagsAsResourceId = true }near the root (Android source set). - Follow the project's existing tag format if one exists.
5. Add previews
- One preview per meaningful state: empty, filled, saving, error. Preview the view, never the stateful entry (Koin is not running in previews).
- Wrap in the app theme (
AppTheme { }), not a bareMaterialTheme, so tokens resolve. - Previews in
commonMainneed an Android target; they render in Android Studio / IntelliJ with the Kotlin Multiplatform plugin.
6. Register DI and the route
- Koin:
viewModel { params -> NoteEditorViewModel(noteId = params.getOrNull(), notesRepository = get()) }(orviewModelOf(::NoteEditorViewModel)when there are no runtime parameters). - Pass route arguments with
koinViewModel(parameters = { parametersOf(route.noteId) }). - Inside a
NavHost,koinViewModel()is scoped to the current back-stack entry: each visit to the destination gets its own ViewModel, cleared when the entry is popped. - Route definition and graph wiring →
compose-multiplatform-navigation.
7. Write ViewModel tests (commonTest)
Dispatchers.setMain(StandardTestDispatcher())in@BeforeTest,resetMain()in@AfterTest(viewModelScoperuns onDispatchers.Main.immediate).- Hand-written fakes for repositories; no mocking framework needed.
- Start collecting effects before triggering the action (an eager collector in
backgroundScopeonUnconfinedTestDispatcher(testScheduler), or Turbine'stest { }). - One behaviour per test; assert both the final state and the emitted effects.
- Do not test composables here; tests cover reducer, state derivation and effects.
Full code for every step — base class, screen, previews, Koin module, fake, tests — is in references/worked-example-note-editor.md.
Quick reference
| Need | API (multiplatform) |
| Collect state | androidx.lifecycle.compose.collectAsStateWithLifecycle |
| Lifecycle-aware effect collection | LocalLifecycleOwner (androidx.lifecycle.compose) + repeatOnLifecycle(Lifecycle.State.STARTED) |
| ViewModel (Koin 4) | org.koin.compose.viewmodel.koinViewModel; module DSL org.koin.core.module.dsl.viewModel / viewModelOf |
| ViewModel (no DI) | androidx.lifecycle.viewmodel.compose.viewModel { NoteEditorViewModel(...) } |
| Preview annotation, CMP 1.10+ | androidx.compose.ui.tooling.preview.Preview (artifact org.jetbrains.compose.ui:ui-tooling-preview) |
| Preview annotation, CMP < 1.10 | org.jetbrains.compose.ui.tooling.preview.Preview (deprecated from 1.10) |
| Intercept back (unsaved changes) | see compose-multiplatform-navigation → back handling |
| Test main dispatcher | kotlinx.coroutines.test.setMain / resetMain |
| Effect transport | Delivery | Use when |
Channel(BUFFERED).receiveAsFlow() | Exactly one collector; buffered while nobody collects | Default for navigation/snackbar effects |
MutableSharedFlow() (replay 0) | Dropped if no subscriber at emit time (init, rotation, background) | Avoid for effects |
MutableSharedFlow(replay = 1) | Re-delivered to every new subscriber | Avoid: double navigation after rotation |
Event in UiState + "consumed" action | Survives anything; needs an explicit ack | Must-not-lose events (payment result, irreversible action) |
Verify
Run what applies and report each check as PASS, FAIL or NOT RUN (reason). Never report a
check as passed that you did not run. Replace :shared with the module that holds the screen.
./gradlew :shared:compileKotlinMetadata # common code compiles (no Android/iOS-only APIs)
./gradlew :shared:testAndroidHostTest # JVM tests (task name with the KMP Android library plugin)
./gradlew :shared:testDebugUnitTest # JVM tests (task name with com.android.library + androidTarget)
./gradlew :shared:iosSimulatorArm64Test # same tests on iOS (macOS only)
./gradlew :composeApp:assembleDebug # Android app still buildsUse ./gradlew :shared:tasks --all | grep -i test if the task names differ. Also check:
grep -rn "androidx.compose" <logic package>returns nothing for the ViewModel files.- Every interactive element in the new view has a
testTagfrom the screen'sTestTagsobject. - Previews render (open the file in the IDE) — report NOT RUN if no IDE is available.
Common mistakes
| Mistake | Why it bites | Fix |
Navigating inside the ViewModel or holding a NavController there | Leaks the back-stack owner, breaks tests and iOS reuse | Emit an effect; navigate in the effect handler |
Navigation in a stray LaunchedEffect(state.saved) | Fires again on recomposition/rotation when the flag is still true | Use an effect, or reset the flag with an explicit action |
Effects in MutableSharedFlow() with no replay | Effects emitted in init or during rotation are silently dropped | Channel(Channel.BUFFERED).receiveAsFlow() |
Collecting effects with plain LaunchedEffect { flow.collect } | Keeps collecting while stopped; events consumed off-screen | repeatOnLifecycle(STARTED) + Dispatchers.Main.immediate |
collectAsState() for ViewModel state | Keeps collecting in background on Android | collectAsStateWithLifecycle() |
_state.value = _state.value.copy(...) from several coroutines | Lost updates under concurrency | _state.update { it.copy(...) } |
Reading state.value after a suspend call in a save flow | Saves text typed after the click | Snapshot the draft before launching |
Base class calls an abstract initialState() from its constructor | Subclass fields are not initialised yet: wrong or null defaults | Pass the initial state as a constructor argument |
| Dialog visibility or error as an effect | Lost on rotation / process death | Keep it in UiState |
Computing isSaveEnabled inside the composable | Untested logic, duplicated across platforms | Derived property on UiState |
| Previewing the stateful screen | Preview needs Koin/ViewModel and fails | Preview the pure view with literal states |
Debounce or async work between onValueChange and the text field's value | Lost keystrokes, cursor jumps | Keep the text update synchronous; debounce side work (search) separately |
| Effect test collects after the action | Misses the emission or becomes flaky | Start the collector first, then act, then advanceUntilIdle() |
| Mixing idioms when extending an old screen | Two state sources, two event channels | Stay in the screen's existing idiom |
Related skills
compose-multiplatform-navigation— routes, NavHost, tabs, auth gate, deep links, back handling.compose-design-system-component— reusable components and theme tokens used by the view.figma-to-mobile-ui— turning a Figma frame into the view.compose-multiplatform-localization— strings, plurals and formatting in the view.- chrisbanes/skills —
compose-state-and-effects,kotlin-concurrency-and-flow,compose-ui-testing-patterns. - Meet-Miyani/compose-skill — an opinionated Compose/CMP architecture skill set.
- android/skills — Jetpack Compose skills for Android-only apps.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

