Jetpack Compose is Android's declarative UI toolkit: you write @Composable Kotlin functions that describe the UI for the current state, and Compose calls them again when that state changes.
Declarative, not imperative: with Views you mutate widgets by hand (textView.text = ..., view.visibility = GONE) and keep them in sync with the data; in Compose you change the state and the UI follows.
Kotlin only: no XML layouts, no findViewById, no view binding. Layouts, lists and styles are plain functions.
Composition over inheritance: you combine small functions instead of subclassing View.
Two-way interop: AndroidView hosts a View inside Compose and ComposeView hosts Compose inside XML, so an app can migrate screen by screen.
Setup: the org.jetbrains.kotlin.plugin.compose Gradle plugin (the Compose compiler ships with Kotlin since 2.0) plus the androidx.compose:compose-bom platform, which keeps all Compose library versions compatible.
AndroidJunior
Jetpack Compose Fundamentals
Composable functions, modifiers, layout, recomposition, Material 3
Questions closed
0 / 23
- 0Review
- 10Asked often
Questions
A composable is a Kotlin function marked @Composable that emits UI into the composition instead of returning it. The Compose compiler plugin adds a hidden Composer parameter to it, and only another composable has a Composer to pass on.
**Returns Unit, emits UI: and is named in PascalCase, like a type.
Why only from another composable**: the hidden $composer parameter is the calling rule, the same idea as suspend and its hidden Continuation; regular code has nothing to pass.
Runs often: in any order, and may be cancelled, so the body must be fast and free of side effects; side effects go into LaunchedEffect and friends.
Entering Compose: from regular code through an entry point that owns a composition: setContent in an Activity or ComposeView.setContent.
Recomposition is Compose calling composable functions again when state they read has changed, so the UI matches the new state. Only the scopes that read the changed state re-run, and children whose arguments did not change are skipped.
Trigger: a write to a Compose State (mutableStateOf, collectAsStateWithLifecycle, mutableStateListOf) that was read during composition. A plain var or a regular class field never triggers it.
Tracking: the snapshot system records which restart scope read which state. A write invalidates exactly those scopes, and they re-run before the next frame.
Skipping: a composable called with arguments equal to the previous call is skipped.
Inline layouts have no scope of their own: Column, Row and Box are inline, so a state read in their content belongs to the enclosing composable.
Consequences: a composable can run many times and recomposition can be cancelled midway, so keep the body fast and without side effects.
A state read inside a layout or draw lambda (Modifier.offset { }, drawBehind { }) does not trigger recomposition at all; only that later phase re-runs.
A Modifier is an ordered, immutable chain of decorations (size, padding, background, click handling, semantics) applied to a composable. Each modifier wraps everything after it, so changing the order changes the result.
Read it from the outside in: padding(16.dp).background(Red) leaves the padding transparent and paints only the inner area; background(Red).padding(16.dp) paints the padding too.
Click area: clickable { }.padding(16.dp) makes the padding tappable and the ripple covers it; padding(16.dp).clickable { } does not.
Size constraints flow down the chain: a later size() cannot override an earlier fixed one.
The convention: a reusable composable takes modifier: Modifier = Modifier as its first optional parameter and applies it first on its root, so the caller's decorations sit on the outside.
Scoped modifiers: weight exists only inside RowScope/ColumnScope and align inside BoxScope, which is why the IDE does not offer them elsewhere.
Column stacks children vertically, Row horizontally, and Box places them on top of each other. Arrangement controls spacing along the main axis, alignment controls the cross axis.
Arrangement: verticalArrangement on a Column, horizontalArrangement on a Row: Arrangement.spacedBy(8.dp), SpaceBetween, Center.
Alignment: horizontalAlignment on a Column, verticalAlignment on a Row; Modifier.align(...) overrides it for one child.
Weight: Modifier.weight(1f) gives a child the space left after the unweighted children are measured, for example a title that takes everything except the icon and the price.
Box: contentAlignment sets the default position and Modifier.align(Alignment.TopEnd) places one child, which is how you do badges and overlays.
Gaps and wrapping: Spacer(Modifier.height(8.dp)) adds a fixed gap; FlowRow wraps children onto the next line, the usual chip group.
Nesting is cheap: Compose measures each child once, so a Row inside a Column does not carry the cost that deep View hierarchies did.
Column composes and measures every child at once, while LazyColumn composes only the items that are visible plus a few ahead. Use Column for a short, fixed set of children and LazyColumn for any long or unbounded list.
Compared to RecyclerView
No Adapter, ViewHolder or LayoutManager; you describe items in a DSL: items(list, key = { it.id }) { ... }.
No DiffUtil: when the list changes, Compose recomposes only the items whose data changed, and key plays the role of the item's stable identity.
RecyclerView recycles View objects; LazyColumn reuses the composition of items that scrolled away, matched by contentType (the analogue of a view type).
Scroll position lives in rememberLazyListState(); LazyRow, LazyVerticalGrid and LazyVerticalStaggeredGrid replace the other LayoutManagers.
Common mistakes
Without a key, items are identified by position, so row state and Modifier.animateItem() break after an insert or reorder.
item { list.forEach { Row(it) } } puts the whole list into one item and loses laziness.
A LazyColumn inside a Column with verticalScroll crashes, because it gets an infinite height.
Use a slot: a parameter of type @Composable () -> Unit that the component calls wherever the caller's content belongs. The component owns the layout and styling, the caller owns the content; this is how Scaffold, Button and TopAppBar are built.
Put content: @Composable () -> Unit last so callers get trailing-lambda syntax.
Give the slot a receiver when the caller needs the parent's scope: content: @Composable RowScope.() -> Unit makes Modifier.weight and align available inside the block.
Make an optional area a nullable slot (leading: (@Composable () -> Unit)? = null) and skip its space when it is null.
Follow the framework's parameter order: required parameters, then modifier: Modifier = Modifier, then optional ones, then the content lambda.
Slots replace the growing list of flags (showIcon, iconRes, subtitleColor) that a component otherwise collects one bug report at a time.
A Material 3 theme is one composable, MaterialTheme, that holds a color scheme, typography and shapes; every Material component below it reads them through MaterialTheme.colorScheme, .typography and .shapes.
lightColorScheme(...) and darkColorScheme(...) build the schemes. Each role has an onX partner (primary/onPrimary) for content drawn on it, which keeps contrast correct.
dynamicLightColorScheme(context) / dynamicDarkColorScheme(context) take colors from the wallpaper on Android 12+; check the SDK level and keep a static fallback.
isSystemInDarkTheme() picks the scheme; an in-app setting can override it.
Surface sets the background and the matching content color, so Text and Icon inside get the right color automatically.
Custom tokens (spacing, brand colors) go in your own CompositionLocal next to MaterialTheme, not hacked into its color roles.
Scaffold lays out a Material 3 screen with named slots (topBar, bottomBar, floatingActionButton, snackbarHost) and passes the remaining content area as PaddingValues. SnackbarHostState.showSnackbar is a suspend function because it returns only after the snackbar is dismissed.
Apply the padding: the content lambda gets innerPadding covering the bars and system insets; ignoring it hides content under the top bar.
FAB in the slot: not in a Box over the content, so it stays clear of the bottom bar and insets.
Snackbar: val hostState = remember { SnackbarHostState() }, snackbarHost = { SnackbarHost(hostState) }, then showSnackbar(...) returns SnackbarResult.ActionPerformed or Dismissed.
Where to call it: from rememberCoroutineScope().launch { } in a click handler; never from the composable body.
One Scaffold per screen; nested Scaffolds double the insets and put snackbars in the wrong place.
Each field is an OutlinedTextField whose text lives in state you own. KeyboardOptions picks the keyboard and its action key, and KeyboardActions decides what that key does: move focus to the next field, or submit the form.
State: each field's text sits in rememberSaveable { mutableStateOf("") }, and onValueChange writes it back at once with nothing asynchronous in between, or fast typing loses characters.
Keyboard: KeyboardType.Email shows the @ key, KeyboardType.Password turns off suggestions, PasswordVisualTransformation() masks the text, and singleLine = true stops Enter from adding a line.
Action key: ImeAction.Next on the email, ImeAction.Done on the password. By default Next moves focus forward and Done hides the keyboard; once onDone submits the form, call clearFocus() there so the keyboard still closes.
Focus: focusManager.moveFocus(FocusDirection.Down) moves it explicitly; a FocusRequester on Modifier.focusRequester() focuses a chosen field when the screen opens.
Newer Material 3 versions add the state-based TextField(state = rememberTextFieldState()) with a single onKeyboardAction handler, and SecureTextField for passwords.
A @Preview is a composable that Android Studio renders without running the app: no Activity, no ViewModel, no DI graph. So a screen previews only if everything it needs comes in as parameters.
Split the screen in two
A stateful route (HomeRoute(viewModel: HomeViewModel = hiltViewModel())) collects state and passes plain values and lambdas to a stateless HomeScreen(state, onRefresh).
You preview only the stateless half, with sample data.
Useful extras
@Preview(showBackground = true, fontScale = 1.5f, uiMode = UI_MODE_NIGHT_YES) checks configurations that break layouts; @PreviewLightDark, @PreviewFontScales and @PreviewScreenSizes generate several at once.
@PreviewParameter with a PreviewParameterProvider renders one preview per value, for example loading, empty and error states.
LocalInspectionMode.current is true inside the tooling; use it to draw a placeholder instead of a network image.
Dependencies: ui-tooling-preview as implementation and ui-tooling as debugImplementation.
For a new feature in 2026 Compose is the default: less code, UI driven by state, and Google ships new UI APIs (Material 3, adaptive layouts) for Compose first. XML still makes sense inside a large View-based module the team will not touch soon.
Why Compose
No adapters, ViewHolders or XML-to-code glue, so a list screen is a fraction of the code.
@Preview and live edit shorten the feedback loop; UI tests query the semantics tree instead of view ids.
New screens can live in an old app: host them in a Fragment or Activity through ComposeView.
What to watch out for
Performance needs understanding: careless state reads cause extra recompositions; you have to know how recomposition and skipping work.
Debug builds are slow: a debuggable build skips R8 and many runtime optimizations, so judge performance only in a release build with a Baseline Profile.
Startup and size: the library adds to APK size and the first frame is slower without a Baseline Profile.
Interop has a cost: two layout systems in one tree, and some View-only libraries need an AndroidView wrapper.
Rewriting stable XML screens only to migrate is rarely worth it; new code goes in Compose, old screens move when they change anyway.
A frame runs up to three phases in order: composition decides what to show, layout measures and places each node, and drawing paints it. Compose records which phase read a state, so a value read only in a layout or draw lambda re-runs that phase and the later ones, never composition.
What each phase does
Composition: runs the @Composable functions and updates the tree of layout nodes and their modifiers. Recomposition repeats only this phase, and only for the scopes that read the changed state.
Layout: two steps per node. Measure: the node measures each child once, under constraints it chooses, then picks its own size. Place: it positions the children.
Drawing: each node paints itself and then its children into a canvas.
Why a phase can be skipped
A state read subscribes the phase it happens in. Modifier.background(color) reads an animated colour while composing, so every frame recomposes the scope; drawBehind { drawRect(color) } reads it while drawing, so every frame only redraws.
Layout reads happen in the measure and placement blocks of Layout or Modifier.layout { } and in the Modifier.offset { } lambda; draw reads happen in drawBehind { }, drawWithContent { }, Canvas { } and the graphicsLayer { } block, the cheapest place to animate alpha, rotation or translation. A value that changes every frame goes down as a lambda such as () -> Float and is read inside one of these blocks.
A is 100×100 dp all red; B is 50×50 with a 30×30 green centre; C is 70×70 with a 50×50 blue square inset by 10 dp; D takes a 200 dp wide slot with an 80×40 grey box centred in it.
Size constraints flow down the chain from left to right, and an ordinary size modifier can only narrow what it received:
A: the first size(100.dp) fixes the constraints at exactly 100. The second size(50.dp) must fit inside them, so it is coerced to 100.
B: size(50.dp) sets the outer box; padding(10.dp) leaves 30 dp for everything after it, so the background paints only the inner 30×30.
C: padding comes first, so the 50 dp box gets a 10 dp margin on each side: 70×70 in total, blue only in the middle.
D: width(200.dp) fixes the width; requiredWidth(80.dp) ignores incoming constraints and measures at 80. The parent still sees 200, and the content is centred inside that space.
The rule to remember: the first fixed size wins; use required* only when a child must break its parent's constraints.
items(messages) has no key, so each row's remember state is tied to its position, not to the message. When a message is inserted at the top, every message moves down one slot, but expanded = true stays in its slot and opens whichever message is there now.
Why: without a key, LazyColumn identifies an item by its index. Everything in the item's composition (remember, rememberSaveable, a running LaunchedEffect, a TextField's text) belongs to that index, so an insert, delete or reorder hands it to another message.
Fix: key = { it.id } gives each item a stable identity: state follows the message, and LazyListState keeps the scroll anchored to the same message. The key must be unique (a duplicate crashes) and Bundle-storable for rememberSaveable: Long, String, Int.
When state must outlive the row: with a key the state is still dropped when the row scrolls out of the composition. Use rememberSaveable, or keep expandedIds: Set<Long> in the ViewModel and pass expanded down.
Catch it: a Compose UI test that expands one row, inserts an item at the top and asserts the same message is still expanded.
A composable enters the composition, recomposes zero or more times, and leaves it. This lifecycle is separate from the Activity's: going to the background does not remove anything from the composition, so screen-level events like ON_START must be observed explicitly.
The composition lifecycle
Enter: the first call; remember creates its values, LaunchedEffect starts its coroutine, DisposableEffect runs its setup.
Recompose: the function runs again; remembered values are kept, and effects restart only when their keys change.
Leave: an if stops calling it, a navigation destination is popped, or a list item scrolls away. Remembered values are dropped, onDispose runs, effect coroutines are cancelled.
Reacting to the Activity lifecycle
collectAsStateWithLifecycle() stops collecting a Flow below STARTED, unlike collectAsState().
LifecycleStartEffect(key) { ...; onStopOrDispose { } } and LifecycleResumeEffect pair start/stop work with the lifecycle; LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { } handles a single event. They live in lifecycle-runtime-compose.
Inside a NavHost, LocalLifecycleOwner is the destination's back stack entry, not the Activity.
A LaunchedEffect keeps running while the app is in the background; anything that must pause, like camera preview or location updates, belongs in a lifecycle-aware effect.
LaunchedEffect is tied to the composition, not to the Activity's lifecycle. Pressing Home only stops the Activity; the composition stays alive, so the while (true) loop keeps calling refresh() every 10 seconds until the Activity is destroyed or the process dies.
Why: an effect is cancelled only when it leaves the composition or its key changes. Compose pauses its frame clock when the Activity is stopped, but a loop that only uses delay never waits for a frame, so nothing stops it. The OS may eventually freeze or kill a cached process, but code cannot rely on that.
The red herring: collectAsStateWithLifecycle() stops collecting scores below STARTED, but it does nothing for other effects on the screen.
Fix: repeatOnLifecycle(Lifecycle.State.STARTED) inside the LaunchedEffect cancels the block on ON_STOP and restarts it on ON_START (LifecycleStartEffect suits non-suspend work). Inside a NavHost the owner is the back stack entry, so polling also pauses under another screen.
Better home: the data layer: a flow { while (true) { emit(api.scores()); delay(10.seconds) } } with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ...) stops 5 seconds after the UI unsubscribes.
Apps targeting Android 15 (API 35) are drawn edge to edge, so content sits under the status and navigation bars until you apply window insets, through Scaffold's innerPadding or inset modifiers such as safeDrawingPadding().
What changed: targetSdk 35 enforces edge to edge; targetSdk 36 (Android 16) removes the windowOptOutEdgeToEdgeEnforcement opt-out. Still call enableEdgeToEdge() in onCreate so older versions behave the same and bar icons get the right contrast.
Scaffold: it passes the insets to its content as PaddingValues; apply them, or content hides under the bars.
Modifiers: safeDrawingPadding() for the general case (it already includes the keyboard and the cutout); statusBarsPadding(), navigationBarsPadding(), displayCutoutPadding() for one side.
Lists: pass insets as contentPadding, not as padding on the list itself. Then rows scroll under the bars, while the first and last rows still stay clear of them.
Keyboard: imePadding() after systemBarsPadding() when you pad only for the bars, plus android:windowSoftInputMode="adjustResize".
No double padding: insets are consumed once applied, so a child asking for safeDrawing again gets zero; Modifier.consumeWindowInsets does it explicitly.
Branch on the window size class, not on the device: read currentWindowAdaptiveInfoV2().windowSizeClass and switch layouts at the 600 dp and 840 dp width breakpoints. With targetSdk 36, Android 16 ignores orientation and resizability locks on displays at least 600 dp wide.
Adapting the layout
The window, not the device: in split screen a tablet app can be 400 dp wide, so an isTablet flag is wrong; the size class follows the current window and updates on every resize.
Breakpoints: compact below 600 dp, medium to 840 dp, expanded above it, checked with isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND); the V2 function also reports large (1200 dp) and extra-large (1600 dp).
State across resizes: a fold or a window resize is a configuration change that recreates the Activity. Keep state in a ViewModel or rememberSaveable, and hoist it above the layout branch so a LazyListState keeps the scroll when one pane becomes two.
What Android 16 changes
On sw600dp and wider, screenOrientation, resizeableActivity="false", minAspectRatio, maxAspectRatio and setRequestedOrientation() are ignored and the app fills the window: a portrait-only layout is stretched across a landscape tablet, and a rotation, once blocked, now recreates the Activity.
Exempt: games (android:appCategory="game"), screens below sw600dp, and users who pick the app default in the aspect ratio settings. The PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY opt-out is temporary: it stops working at targetSdk 37.
Navigation Compose keeps a back stack of composable destinations inside a NavHost, driven by a NavController. Since Navigation 2.8 routes are @Serializable classes, so arguments are typed properties instead of string templates.
rememberNavController() creates the controller; NavHost(navController, startDestination = Home) declares the graph; composable<Profile> { } registers a destination.
navController.navigate(Profile(id = 42)) pushes, popBackStack() pops. In the destination, backStackEntry.toRoute<Profile>() returns the typed route; in its ViewModel, savedStateHandle.toRoute<Profile>() does the same.
Keep the controller at the top: pass lambdas such as onOpenProfile to screens instead of the NavController, so screens stay previewable and testable.
Older code uses string routes like "profile/{id}" with navArgument; they still work. Navigation 3, where the app owns the back stack as a plain list, is the newer alternative.
AndroidView hosts a View inside a composable, and ComposeView hosts composables inside a View hierarchy. Together they let an app migrate one screen or one component at a time.
A View inside Compose
In AndroidView(factory = { context -> WebView(context) }, update = { it.loadUrl(url) }), factory runs once, update runs again whenever the state it reads changes.
onRelease frees resources when the view leaves the composition, for example WebView.destroy().
A View with its own lifecycle, such as MapView, needs LocalLifecycleOwner observed in a DisposableEffect and forwarded to its onStart/onStop.
AndroidViewBinding(MyLayoutBinding::inflate) { } reuses a whole XML layout.
Compose inside Views
Add a ComposeView to the layout or create one in code and call setContent { }.
In a Fragment, set ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, so the composition is disposed with the Fragment's view, not when the view is detached from the window.
Theme and text styles do not cross the boundary by themselves, and two layout systems in one tree cost something, so keep interop at the leaves or at screen boundaries.
Compose builds a semantics tree next to the UI tree that describes what each element means: text, role, state, available actions. TalkBack and the Compose testing APIs read only that tree, not your layout.
Where it comes from: Text adds text, clickable adds a click action and a role, Modifier.semantics { } adds the rest.
Merging: clickable and Modifier.semantics(mergeDescendants = true) collapse a subtree into one node: one item for TalkBack and one node for a test.
Tests: onNodeWithText and onNodeWithTag search the same tree; Modifier.testTag adds a tag.
Touch target: Material components keep a 48 dp target; a bare Icon with clickable does not, so use IconButton.
The Layout composable gives you the children as measurables plus the incoming constraints; you measure each child, choose your own size and place the children. The rule: each child may be measured only once per layout pass.
How it works
Measure every child with constraints you choose: pass them through unchanged, or copy(minWidth = 0, maxWidth = half) to give a child less room.
Decide your own size within the incoming constraints, then place the resulting placeables inside layout(width, height) { } with placeRelative, which mirrors for right-to-left languages.
Measuring a child twice throws an exception. That single-pass rule is why deep nesting stays cheap in Compose, where the View system could measure exponentially.
Lighter and heavier tools
One child: Modifier.layout { measurable, constraints -> } is enough for a custom offset or a size that depends on the parent.
Intrinsics: Modifier.height(IntrinsicSize.Min) asks children for their preferred size before measuring, for example to make a divider as tall as the taller of two texts. It costs an extra pass.
SubcomposeLayout: when the content itself depends on the measurement, as in BoxWithConstraints and the lazy lists. It composes during layout and is slower, so use it last.