Jetpack Compose is the default Android UI of this curriculum because it replaces XML layouts and manual
findViewById wiring with plain Kotlin functions that describe the screen for a given state. This track
moves from your first @Composable to hoisted state, Material 3, lazy lists, a ViewModel + StateFlow
pipeline, navigation, and recomposition performance — tagged by level so you can read only as deep as you need.
Create a Compose project and run it
BeginnerIn Android Studio, create a new project from the “Empty Activity” (Compose) template, then run it on an emulator.
Why the Compose template, and what it gives you
The “Empty Activity” template in current Android Studio is already a Compose project: it sets up the
Compose compiler plugin, the Material 3 dependency, and a MainActivity whose onCreate calls
setContent { } instead of setContentView(R.layout.…). There is no XML layout file — the UI is Kotlin.
You don’t wire up a build by hand for this track; the template does it so you can focus on composables.
Verify the toolchain
# Confirm Android Studio's bundled JDK / Gradle wrapper are present
./gradlew --version
# Build and install the debug app on a running emulator/device
./gradlew installDebugWrite your first @Composable
BeginnerAnnotate a Kotlin function with @Composable and emit UI by calling other composables like Text.
What @Composable actually means
A @Composable function describes a piece of UI; it doesn’t return a View, it emits UI into the
composition by calling other composables (Text, Column, Button). Composable functions are normal
Kotlin — they take parameters and can call each other — but they may only be called from other composables.
By convention a composable is named in PascalCase like a type (Greeting, not greeting) and returns Unit.
A minimal composable
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name")
}Agent prompt — paste into an agent with repo access
Role: Senior Android engineer working in this Jetpack Compose repo.
Context: A fresh "Empty Activity" Compose project (Material 3, Compose compiler set up). MainActivity uses setContent { }.
Task: Add a Greeting(name: String) composable in MainActivity.kt and show it for name = "Aurora" from setContent.
Requirements:
- Greeting is a top-level @Composable that emits a single Text.
- Wrap the call in the app's MaterialTheme so it picks up Material 3 styling.
- No XML layout; UI lives entirely in setContent.
Tests / acceptance:
- `./gradlew assembleDebug` succeeds with no errors.
- Running the app shows "Hello, Aurora" on screen.
Output: a unified diff plus a one-paragraph summary of what setContent does.Preview composables without an emulator
BeginnerAdd a function annotated with @Preview that calls your composable, then open the design pane in Android Studio.
Why previews change the loop
@Preview renders a composable directly inside Android Studio — no build-install-launch cycle, no
emulator boot. The previewed function takes no parameters, so you write a tiny wrapper that supplies sample
data and calls the real composable. You can declare several previews (light/dark, different font scales,
different states) side by side. Previews are the fastest feedback loop in Compose; lean on them constantly.
A preview wrapper
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.Composable
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Greeting(name = "Aurora")
}Lay out UI with Column, Row, and Modifier
BeginnerArrange composables with Column and Row, and adjust size, padding, and spacing through the modifier parameter.
The Modifier chain is order-sensitive
Column stacks children vertically, Row horizontally, Box overlaps them. Almost every composable
accepts a modifier: Modifier that decorates it — Modifier.padding(16.dp).fillMaxWidth() reads
left-to-right and order matters: padding before a background paints the padding outside the background;
after, inside it. Pass a modifier parameter through your own composables so callers can position them —
that’s the idiomatic Compose API shape.
A simple laid-out card
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ProductRow(name: String, price: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = name)
Text(text = price)
}
}Hold UI state with remember and mutableStateOf
IntermediateStore changing UI state in remember { mutableStateOf(...) }, and update it to trigger a redraw.
State, recomposition, and why remember matters
Compose redraws by recomposition: when a state value a composable reads changes, Compose re-invokes that
composable (and only what depends on it). mutableStateOf creates an observable state holder; remember
keeps it alive across recompositions so it isn’t reset every frame. Use by with the getValue/setValue
delegates to read/write it like a normal variable. For state that must survive configuration changes
(rotation), use rememberSaveable instead of remember.
A counter with local state
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(text = "Clicked $count times")
}
}Hoist state for unidirectional data flow
IntermediateMove state out of a composable: pass the value down as a parameter and pass events up as lambdas.
Stateless composables and the UDF loop
State hoisting moves a composable’s state to its caller, making the composable stateless: it receives
the current value and an onValueChange: (T) -> Unit callback. This is unidirectional data flow — state
flows down, events flow up. Stateless composables are easier to preview, reuse, and test, and they
keep a single source of truth for each piece of state. Hoist state to the lowest common ancestor that needs
to read or change it.
A hoisted, stateless input
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
label = { Text("Search products") },
)
}Chat prompt — paste into a chat to get the code
Role: Jetpack Compose teacher. The reader has no repo access — return complete, compilable code.
Task: Show a stateful "controller" composable that owns query state with rememberSaveable, and a stateless SearchField(query, onQueryChange) it drives, demonstrating state hoisting and unidirectional data flow.
Requirements:
- The stateless composable takes only value + an onValueChange lambda; it owns no state.
- The stateful parent uses rememberSaveable so the query survives rotation.
- Use Material 3 OutlinedTextField; include all imports.
Tests / acceptance (describe, since no repo):
- Typing in the field updates the displayed query.
- Rotating the device preserves the typed text.
Output: the complete Kotlin file, no commentary.Render long lists with LazyColumn
IntermediateReplace a scrolling Column with LazyColumn and emit rows inside its items(...) block.
Why LazyColumn instead of a scrollable Column
A Column with verticalScroll composes every child up front — fine for a handful, ruinous for
thousands. LazyColumn (and LazyRow) only composes and lays out the items currently visible, recycling
as you scroll, the way RecyclerView did for the View system. Provide a stable key per item so Compose
can track items across data changes and animate/reorder correctly. Use LazyColumn for any list whose
length you don’t control.
A keyed lazy list
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
@Composable
fun ProductList(products: List<Product>) {
LazyColumn {
items(products, key = { it.id }) { product ->
ProductRow(name = product.name, price = product.price)
}
}
}Agent prompt — paste into an agent with repo access
Role: Senior Android engineer in this Jetpack Compose repo.
Context: A Product(id: String, name: String, price: String) data class exists, plus a stateless ProductRow(name, price, modifier) composable.
Task: Add a ProductList(products: List<Product>) composable backed by LazyColumn, with a @Preview that feeds it sample data.
Requirements:
- Use LazyColumn with items(products, key = { it.id }) — a stable key per row.
- Add contentPadding and an item spacing so rows don't touch.
- Provide a @Preview that supplies 3+ sample products.
Tests / acceptance:
- `./gradlew assembleDebug` succeeds.
- The Android Studio preview renders the sample rows in a scrollable list.
Output: a unified diff plus a one-paragraph note on why key matters.Theme the app with Material 3
IntermediateWrap your UI in MaterialTheme with a Material 3 colorScheme, and read colors and type from the theme.
Material 3 and dynamic color
Material 3 (androidx.compose.material3) supplies themed components — Button, Card, Scaffold,
TopAppBar — that read their styling from a MaterialTheme you wrap around your app. Pull values from
MaterialTheme.colorScheme and MaterialTheme.typography rather than hard-coding colors, so light/dark and
(on Android 12+) Material You dynamic color from the user’s wallpaper work for free via
dynamicLightColorScheme / dynamicDarkColorScheme. Scaffold gives you the standard app-bar / content /
FAB slots. TopAppBar is still marked ExperimentalMaterial3Api, so a composable that uses it needs
@OptIn(ExperimentalMaterial3Api::class) until the API stabilises.
A themed scaffold
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProductsScreen(products: List<Product>) {
Scaffold(
topBar = { TopAppBar(title = { Text("Aurora") }) },
) { padding ->
ProductList(products = products, modifier = Modifier.padding(padding))
}
}Drive UI from a ViewModel and StateFlow
AdvancedExpose screen state from a ViewModel as a StateFlow, and collect it with collectAsStateWithLifecycle().
Where state lives at scale, and lifecycle-safe collection
Local remember is fine for a widget; screen-level state belongs in a ViewModel, which survives
configuration changes and holds your business logic. Model the screen as an immutable UI-state data class,
expose it as a StateFlow<UiState> (commonly a private MutableStateFlow behind a public read-only
StateFlow), and collect it in the composable with collectAsStateWithLifecycle() from
androidx.lifecycle:lifecycle-runtime-compose. That collector stops gathering when the UI isn’t started,
avoiding wasted work and leaks — preferred over the lifecycle-agnostic collectAsState(). The composable
stays a stateless render of the latest UiState; events call ViewModel methods.
ViewModel exposing a StateFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
data class ProductsUiState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = true,
)
class ProductsViewModel(private val repo: ProductRepository) : ViewModel() {
private val _uiState = MutableStateFlow(ProductsUiState())
val uiState: StateFlow<ProductsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val items = repo.load()
_uiState.update { it.copy(products = items, isLoading = false) }
}
}
}Lifecycle-aware collection in the composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.Composable
@Composable
fun ProductsRoute(viewModel: ProductsViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ProductsScreen(products = state.products)
}Agent prompt — paste into an agent with repo access
Role: Senior Android engineer in this Jetpack Compose repo.
Context: A ProductRepository with suspend fun load(): List<Product> exists. We use androidx.lifecycle ViewModel, kotlinx.coroutines StateFlow, and lifecycle-runtime-compose.
Task: Implement ProductsViewModel exposing a StateFlow<ProductsUiState>, and a stateless ProductsScreen plus a ProductsRoute that collects it with collectAsStateWithLifecycle().
Requirements:
- Immutable ProductsUiState data class with products + isLoading.
- Private MutableStateFlow, public asStateFlow(); update state inside viewModelScope.launch.
- ProductsScreen takes the state as a parameter and owns no ViewModel reference (stays previewable).
- Use collectAsStateWithLifecycle(), not collectAsState().
Tests / acceptance:
- A JUnit test with a fake ProductRepository (returning 2 products) asserts uiState.value.products.size == 2 and isLoading == false after load — use kotlinx-coroutines-test runTest.
- `./gradlew testDebugUnitTest` passes.
Output: a unified diff plus a one-paragraph note on why collectAsStateWithLifecycle is preferred.Keep recomposition cheap and correct
AdvancedProfile recomposition counts, give lists stable keys, and hoist or remember expensive values so composables don’t redo work each frame.
Skipping, stability, and remember(key)
Compose skips recomposing a composable when its inputs are unchanged and “stable”. Unstable inputs (e.g. a
plain List or a class with var fields) defeat skipping and cause needless recompositions. Prefer
immutable data and read-only collections (or mark types with @Immutable/@Stable); use the Layout
Inspector’s recomposition counts to find hotspots. Cache derived work with remember(key) { … } so it
recomputes only when key changes, and derivedStateOf when a value is computed from other State
objects that change more often than the result. Don’t perform expensive work directly in a composable
body — it runs on every recomposition.
Memoise derived work
import androidx.compose.runtime.*
@Composable
fun ProductList(products: List<Product>, query: String) {
// Recomputes only when products or query change, not on every recomposition.
val visible = remember(products, query) {
products.filter { it.name.contains(query, ignoreCase = true) }
}
LazyColumn {
items(visible, key = { it.id }) { ProductRow(it.name, it.price) }
}
}Profile recomposition counts
# In the module's build.gradle.kts, enable Compose compiler reports:
# composeCompiler {
# reportsDestination = layout.buildDirectory.dir("compose_compiler")
# metricsDestination = layout.buildDirectory.dir("compose_compiler")
# }
# then build (release for accurate results) to see which composables are skippable/restartable:
./gradlew assembleReleaseAgent prompt — paste into an agent with repo access
Role: Senior Android performance engineer in this Jetpack Compose repo.
Context: ProductList recomposes too often while typing in the search field; the filter runs on every keystroke and every unrelated recomposition.
Task: Make ProductList recompose efficiently: stabilize inputs, key the list, and memoise the filtered result.
Requirements:
- The filtered list is computed with remember(products, query), not inline in the composable body.
- LazyColumn items use a stable key (it.id).
- If Product or the list type is unstable, make it immutable (immutable List + val fields, or @Immutable) and explain why.
- Do not change visible behaviour; the same items render for a given query.
Tests / acceptance:
- Generate the Compose compiler report (composeCompiler { reportsDestination = ... } in build.gradle.kts) and confirm ProductList is "restartable" and "skippable".
- `./gradlew assembleDebug` succeeds.
Output: a unified diff plus before/after notes on recomposition behaviour from the compiler report.Where to take it next
- Build a real shopping UI on top of these patterns in Aurora Commerce, where Compose screens render the catalogue and checkout flow over a live backend.
- Shipping the same app to iPhone too? Compare the Apple-native equivalent in SwiftUI — the model is nearly identical, the platform is not.
- Want to share the logic beneath the UI across Android and iOS? See the Kotlin track for Kotlin Multiplatform.