Skip to content
Commitwise
Menu
Android Development

Jetpack Compose Basics

Build your first Compose screens from composable functions, modifiers, Row, Column and Box, understand why modifier order matters and how constraints decide sizes, and make a screen react to taps.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You write Modifier.padding(16.dp).clickable { … }, and taps near the edge of the button do nothing. You swap the two calls, and the whole button works. Compose isn’t being picky: a modifier chain is applied from the outside in, and once you see that, layout and touch areas stop being guesswork.

New to some of these? Open what you need, or skip ahead.

Basics:What are Kotlin functions and default arguments?

A Kotlin function is declared with fun. Parameters can have default values and can be passed by name, in any order:

fun greet(name: String, excited: Boolean = false) =
    if (excited) "Hello, $name!" else "Hello, $name"

greet("Ana")                         // "Hello, Ana"
greet(name = "Ana", excited = true)  // "Hello, Ana!"

Compose uses this everywhere: most UI functions have many optional parameters, and you only pass the ones you need.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is a trailing lambda?

When a function’s last parameter is a lambda, Kotlin lets you write it after the parentheses, and drop the parentheses if nothing else is passed:

Button(onClick = { save() }) {   // the braces after ) are the button's content
    Text("Save")
}
Column {                          // Column() { … } without the empty parentheses
    Text("One")
}

That’s why Compose code looks like nested blocks: each block is a lambda passed to a layout or component.

Part Not started

Composables and modifiers

Think of ordering a coffee. You don’t operate the machine; you say “a large latte with oat milk”, and the barista makes it. If you change your mind, you describe the new drink.

Jetpack Compose is declarativeDeclarative UIA UI style where you describe what the screen should look like for the current data, and the framework updates the screen when the data changes.Open in glossary → in the same way. You write functions that describe what the screen should show for the current data, and Compose builds it. When the data changes, you don’t find a TextView and call setText: Compose calls your function again (recompositionRecompositionJetpack Compose running a composable function again because state it reads has changed.Open in glossary →) and updates what changed.

A @Composable function doesn’t return UI; it emits nodes into the composition while it runs. The Compose compiler plugin rewrites it to take a hidden Composer, wraps its body in groups recorded in the slot table, and makes it restartable: when state it read changes, only that function’s scope is re-executed. With strong skipping (the default since Kotlin 2.0.20), a composable whose arguments are equal to last time is skipped.

So composables must be fast, idempotent and free of side effects: they may run on every frame of an animation, in any order, or be skipped entirely.

Your first composable

@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello, $name!",
        style = MaterialTheme.typography.headlineSmall,
        color = MaterialTheme.colorScheme.primary,
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting(name = "Ana")
}

Line by line:

  • @Composable marks a function that describes UI. Composables are named with a capital letter, like classes, because they act like UI elements.
  • Text(…) is a composable from the Material library. Calling it puts a text on the screen; it doesn’t return anything.
  • MaterialTheme.typography and colorScheme take styles from your app’s theme, so the text follows dark mode and the user’s font size.
  • @Preview shows the composable in Android Studio’s preview pane, without running the app.

Modifiers: applied from the outside in

Size, padding, background and click handling go in a Modifier chain. Each call wraps everything that comes after it:

// Blue fills the padding too: the background is outside, the padding inside it
Text(
    text = "Continue",
    modifier = Modifier
        .background(Color.Blue)
        .padding(16.dp),
)

// Blue only behind the text: the padding is outside the blue
Text(
    text = "Continue",
    modifier = Modifier
        .padding(16.dp)
        .background(Color.Blue),
)

Read the chain from the top, as layers from the outside in: in the first example the outer layer is a blue background, and inside it there’s 16 dp of padding around the text. In the second, the outer layer is the padding, and only what’s inside it is painted blue.

The same goes for taps. Modifier.clickable { }.padding(16.dp) makes the padding part of the button; Modifier.padding(16.dp).clickable { } leaves the padding outside, where taps do nothing.

Common composables and modifiers

ComposableWhat it shows
Text("Hi")Text
Icon(Icons.Filled.Favorite, contentDescription = "Like")A Material icon
Image(painterResource(R.drawable.logo), contentDescription = null)An image from your resources
Button(onClick = { … }) { Text("Save") }A button; the lambda runs on tap
Spacer(Modifier.height(8.dp))Empty space
ModifierEffect
.padding(16.dp), .padding(horizontal = 24.dp)Space around the content
.size(48.dp), .fillMaxWidth()A fixed size, or all the width offered
.background(Color.Yellow, RoundedCornerShape(8.dp))Paint behind the content, optionally in a shape
.clip(CircleShape)Cut the content to a shape
.clickable { … }React to taps, with a ripple

By convention, every composable you write takes a modifier: Modifier = Modifier parameter and applies it to its outermost element, so callers can add padding or a size without changing your code.

Predict the output

How big is the yellow area?

Text(
    text = "Hi",
    modifier = Modifier
        .size(200.dp, 100.dp)
        .background(Color.Yellow),
)

What a modifier chain really is

Modifier is an immutable, ordered list of elements; then (and every extension like padding) appends to it. At layout time the chain becomes a linked list of Modifier.Nodes around the layout node: the first modifier is the outermost. Layout modifiers (padding, size) transform constraints on the way in and sizes on the way out; draw modifiers (background, clip) draw what’s inside them; pointer modifiers (clickable) get the bounds of everything after them.

That ordering explains the classic reviews:

  • clickable before padding gives the larger touch target and a ripple that covers the padding. After it, the touch target is only the content.
  • clip must come before background to round the background, and before clickable to clip the ripple.
  • Never put a fresh Modifier object in a remembered default. Pass modifier through as the first element of the root’s chain (modifier.padding(…)), not Modifier.padding(…).then(modifier), so callers’ size and padding wrap yours.
Deep diveWhy Modifier.Node replaced composed {}Interview favorite

Older custom modifiers used Modifier.composed { … }, which ran a composable lambda per usage to create state. That allocation happened during composition, made modifiers impossible to compare, and broke skipping.

Modifier.Node (stable since Compose 1.5) moved the work to long-lived nodes: a ModifierNodeElement is a cheap, comparable data class created in composition, and the node it creates is updated in place instead of rebuilt. Built-in modifiers like clickable, padding and background were migrated, which is a large part of why Compose 1.5+ is noticeably faster on long lists.

  • Composables emit into the composition; the compiler makes them restartable and skippable. No side effects in the body; it may run often, in any order, or not at all.
  • A Modifier chain is applied outside in: the first element is the outermost. background().padding() paints the padding; padding().background() doesn’t.
  • clickable before padding for a bigger touch target; clip before background and clickable.
  • Every public composable takes modifier: Modifier = Modifier and applies it first on its root.

Check this part

  1. Which chain gives a blue background that includes the padding?

  2. What happens when the data a composable shows changes?

  3. You want the whole padded area of a row to respond to taps. Which order?

  4. Why must composables be free of side effects?

UI is written as @Composable functions that describe what to show. A Modifier chain is applied from the outside in, so its order changes both the look and the touch area.

Part Not started

Row, Column and Box

Real screens combine composables. Three layouts do most of the work:

@Composable
fun ProductRow(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Icon(Icons.Filled.ShoppingCart, contentDescription = null)
        Column(modifier = Modifier.weight(1f)) {        // takes the free space
            Text("Flat white", style = MaterialTheme.typography.titleMedium)
            Text(
                "Double shot, steamed milk",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
        Text("€3.40", fontWeight = FontWeight.Bold)
    }
}
  • Row puts composables side by side, Column stacks them vertically, and Box layers them on top of each other (for a badge over an icon).
  • Modifier.weight(1f) inside a Row or Column says “take the space that’s left”. Here it pushes the price to the right edge.
  • verticalAlignment lines up children across the row; horizontalArrangement decides how they’re spread along it (spacedBy(12.dp) puts 12 dp between each).

Who decides the size?

Layout in Compose is a short conversation between a parent and each child:

  1. The parent passes limits, called constraints: “you can be between 0 and 360 dp wide”.
  2. The child picks its own size within those limits: a Text takes what its words need; fillMaxWidth() takes the maximum.
  3. The parent places the child (in a Row, one after another; in a Box, in the top-left corner unless you set an alignment).

That’s why a Text in a big Box sits in the corner: the box offered lots of room, the text took only what it needed, and the box put it at the start. Box(contentAlignment = Alignment.Center) centers it.

Compose measures in a single pass: constraints (min/max width and height) go down, sizes come up, and the parent places children. Measuring a child twice throws, which keeps deep trees linear; when a parent needs information before measuring (all children as tall as the tallest), it asks for intrinsic sizes (Modifier.height(IntrinsicSize.Min)).

Size modifiers respect incoming constraints: size(100.dp) in a 50 dp slot becomes 50 dp. requiredSize ignores them and overflows, centered. weight children in a Row are measured after the unweighted ones, with what’s left.

Filling the width

Button(
    onClick = { checkout() },
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 16.dp),
) {
    Text("Pay now")
}

fillMaxWidth() takes all the width the parent allows; the padding after it keeps 16 dp free on each side. Material’s Button already draws its own background and rounded shape, so there’s nothing to paint by hand.

Box for layers

Box {
    Icon(
        Icons.Filled.Notifications,
        contentDescription = "Notifications",
        modifier = Modifier.size(40.dp),
    )
    Text(
        text = "3",
        color = Color.White,
        style = MaterialTheme.typography.labelSmall,
        modifier = Modifier
            .align(Alignment.TopEnd)              // top-right corner of the Box
            .background(Color.Red, CircleShape)
            .padding(horizontal = 6.dp, vertical = 2.dp),
    )
}

Children of a Box are drawn in order, so the badge (second) is on top of the icon (first). Modifier.align is only available inside a Box, Row or Column, because only they know how to place a child.

Tools beyond Row and Column

NeedTool
Long or dynamic listsLazyColumn / LazyRow with items(list, key = { it.id }); a Column composes every child
GridsLazyVerticalGrid, LazyVerticalStaggeredGrid
Wrapping chips or tagsFlowRow
Layout depending on available sizeBoxWithConstraints (subcomposition: costs more, use sparingly)
A custom arrangementLayout { measurables, constraints -> … } with measure and layout(w, h) { place(…) }
Equal-height siblingsModifier.height(IntrinsicSize.Min) on the Row

A Column with verticalScroll is fine for a settings screen; for anything data-sized, use a lazy list with stable keys, so items keep their state and animations when the list changes.

  • Single-pass measurement: constraints down, sizes up, parent places. Measuring twice throws; use intrinsics when needed.
  • size() is coerced into incoming constraints; requiredSize() overflows. weight children are measured last with the remaining space.
  • LazyColumn with key for data-sized lists; FlowRow, lazy grids, Layout for custom placement; BoxWithConstraints only when you really need the size.
Row(verticalAlignment = Alignment.CenterVertically) {
    Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
    Spacer(Modifier.width(8.dp))
    Text(price)
}

LazyColumn(contentPadding = PaddingValues(16.dp)) {
    items(products, key = { it.id }) { ProductRow(it) }
}

FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    tags.forEach { AssistChip(onClick = {}, label = { Text(it) }) }
}

Check this part

  1. A Row has a title, then a price. How do you push the price to the right edge?

  2. Which layout puts a badge on top of an icon?

  3. A child has Modifier.size(100.dp) inside a 50 dp Box. How big is it?

  4. Why do items in a LazyColumn need a stable key?

Column, Row and Box arrange composables; weight and Arrangement share the space. A parent passes constraints down, the child picks a size within them, and the parent places it.

Part Not started

Making it react

To react to taps, a composable needs state: a value Compose watches, so it knows to call the function again when the value changes.

@Composable
fun LikeButton(modifier: Modifier = Modifier) {
    var isLiked by remember { mutableStateOf(false) }

    IconButton(onClick = { isLiked = !isLiked }, modifier = modifier) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
            contentDescription = if (isLiked) "Unlike" else "Like",
            tint = if (isLiked) Color.Red else LocalContentColor.current,
        )
    }
}

Two pieces work together:

  • mutableStateOf(false) creates a value Compose watches. When it changes, Compose re-runs every composable that read it.
  • remember { … } keeps that value between runs. Without it, every re-run would create a fresh false.

by lets you use isLiked like a normal variable instead of writing isLiked.value. You never write “change the icon”: you describe which icon to show for each value, and Compose does the rest. Where state should live, and how to keep it across screen rotation, is in State and Recomposition.

mutableStateOf is a snapshot state object: reads during composition are recorded, and a write invalidates exactly the restart scopes that read it. remember stores the object in the slot table at this call site, so it lives as long as this composable stays in the composition (not across configuration changes; use rememberSaveable or a ViewModel). Ownership, hoisting and stability are in State and Recomposition.

Showing, hiding and repeating

@Composable
fun OrderSummary(items: List<String>) {
    var showNote by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        for (item in items) {
            Text("• $item")
        }
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text("Add a note", modifier = Modifier.weight(1f))
            Switch(checked = showNote, onCheckedChange = { showNote = it })
        }
        if (showNote) {
            Text("Please no sugar", fontStyle = FontStyle.Italic)
        }
    }
}

Because composables are plain Kotlin functions, a normal if shows a composable only when a condition is true, and a normal for loop emits one per item. For long lists that scroll, use LazyColumn instead, which only creates the rows on screen.

The Switch doesn’t keep its own on/off value: you give it checked, and it tells you about taps in onCheckedChange. Most Compose components work this way, so there’s one copy of the truth.

Practice

Build a profile card, step by step

  1. Start with the content. An Icon(Icons.Filled.AccountCircle, …) and two Texts for the name and job title.
  2. Arrange it. Put the texts in a Column and that next to the icon in a Row with verticalAlignment = Alignment.CenterVertically.
  3. Style it. Modifier.size(56.dp) on the icon, MaterialTheme.typography.titleMedium for the name, colorScheme.onSurfaceVariant for the title.
  4. Box it. On the Row: Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(MaterialTheme.colorScheme.surfaceVariant).padding(16.dp). Background outside, padding inside, so the color includes the padding.
  5. Preview it. Add a @Preview function, and a second one with uiMode = Configuration.UI_MODE_NIGHT_YES to check dark mode.

Predict the output

The user taps the button three times. What does it say?

@Composable
fun TapCounter() {
    var count = 0

    Button(onClick = { count++ }) {
        Text("Tapped $count times")
    }
}
Spot the bugA button with a thin blue line

Tap the line with the bug.

Text(
    text = "Continue",
    color = Color.White,
    modifier = Modifier
        .padding(16.dp)
        .background(Color.Blue, RoundedCornerShape(12.dp)),
)

Exerciseeasy

A coffee order card

Build a card for one drink: an icon, the drink’s name and price, and a size picker with three round buttons, S, M and L. The selected size has a filled background; the price updates with the size (S €2.80, M €3.40, L €3.90).

›

There’s one piece of state: the selected size, a String like "M". The price is calculated from it, so it’s not state.

›

Use a for loop over listOf("S", "M", "L"). Each button is a Box with Modifier.size(44.dp).clip(CircleShape).background(…).clickable { size = option }, and its background depends on whether option == size.

SolutionTry it yourself first
private val prices = mapOf("S" to 2.80, "M" to 3.40, "L" to 3.90)

@Composable
fun DrinkCard(modifier: Modifier = Modifier) {
    var size by remember { mutableStateOf("M") }
    val price = prices.getValue(size)

    Column(
        modifier = modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(16.dp))
            .background(MaterialTheme.colorScheme.surfaceVariant)
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(Icons.Filled.ShoppingCart, contentDescription = null)
            Text(
                "Flat white",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier.weight(1f).padding(start = 12.dp),
            )
            Text("€%.2f".format(price), fontWeight = FontWeight.Bold)
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            for (option in listOf("S", "M", "L")) {
                val selected = option == size
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier
                        .size(44.dp)
                        .clip(CircleShape)
                        .background(
                            if (selected) MaterialTheme.colorScheme.primary
                            else MaterialTheme.colorScheme.surface,
                        )
                        .clickable { size = option },
                ) {
                    Text(
                        option,
                        color = if (selected) MaterialTheme.colorScheme.onPrimary
                        else MaterialTheme.colorScheme.onSurface,
                    )
                }
            }
        }
    }
}

One remembered state, one computed price, and every visual difference is described as a function of size. clip comes before background and clickable, so both the color and the ripple are round.

Production puzzles

Predict the output

Users say taps near the edges of a settings row do nothing. Where does a tap call onClick?

@Composable
fun SettingRow(title: String, onClick: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .clickable(onClick = onClick),
    ) {
        Text(title)
    }
}
Spot the bugProduct views counted forty times

Tap the line with the bug.

@Composable
fun ProductScreen(product: Product, analytics: Analytics) {
    analytics.track("product_viewed", product.id)

    Column {
        ProductHeader(product)
        PriceView(product)
    }
}

Exercisemedium

A reusable card component

Your app has a dozen screens that wrap content in the same card: rounded corners, a surface-variant background, 16 dp of padding and an optional title. Each screen copies the chain, and they’ve drifted apart. Design a composable so a screen writes:

AppCard(title = "Delivery", modifier = Modifier.padding(horizontal = 16.dp)) {
    AddressView(order.address)
    Text("Arrives tomorrow")
}

What does the content parameter look like, and where does the caller’s modifier go?

›

The content is a slot: a @Composable lambda. Giving it a ColumnScope receiver lets callers use Modifier.align and weight as if they wrote the Column themselves.

›

The caller’s modifier goes first on the root, before your own clip, background and padding, so their size and outer padding wrap your card.

SolutionTry it yourself first
@Composable
fun AppCard(
    modifier: Modifier = Modifier,
    title: String? = null,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = modifier                            // caller's modifiers are the outer layers
            .fillMaxWidth()
            .clip(MaterialTheme.shapes.large)
            .background(MaterialTheme.colorScheme.surfaceVariant)
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        if (title != null) {
            Text(title, style = MaterialTheme.typography.titleMedium)
        }
        content()
    }
}
  • Slot API. content: @Composable ColumnScope.() -> Unit is the same shape Material uses (Card, Scaffold). Callers compose anything inside, and the receiver gives them ColumnScope modifiers.
  • Modifier conventions. modifier is the first optional parameter, defaults to Modifier, and is applied once, at the start of the root’s chain. Applying it to an inner element, or twice, surprises every caller.
  • Theme, not literals. MaterialTheme.shapes and colorScheme keep dark mode, dynamic color and design-system changes in one place; in a real app you’d likely wrap Material’s Card instead of hand-drawing one.
  • mutableStateOf is snapshot state: reads are tracked per restart scope, writes invalidate them. remember keys it to the call site; it doesn’t survive configuration changes (rememberSaveable, ViewModel).
  • if, for and when work directly; LazyColumn for long lists. Components are stateless: checked + onCheckedChange.
  • Side effects go in LaunchedEffect(key), DisposableEffect, SideEffect, never in the body.
  • Reusable components: modifier: Modifier = Modifier applied first on the root, content: @Composable ColumnScope.() -> Unit slots, theme values over literals.
@Composable
fun ItemRow(item: Item, modifier: Modifier = Modifier) {
    var expanded by rememberSaveable { mutableStateOf(false) }

    Column(modifier.clickable { expanded = !expanded }.padding(16.dp)) {
        Text(item.title)
        if (expanded) Text(item.details)
    }
    LaunchedEffect(item.id) { prefetchThumbnail(item) }   // side effect, keyed
}

Check this part

  1. What does remember do in var liked by remember { mutableStateOf(false) }?

  2. var count = 0 in a composable, and a button does count++. What does the screen show after taps?

  3. Where should a "screen viewed" analytics event be sent?

  4. Where does a component apply the caller's modifier?

remember { mutableStateOf() } gives a composable a value Compose watches; changing it recomposes. if and for work directly in a composable. Side effects go in LaunchedEffect, not the function body.

Key takeaways

  • A composable is a function that describes what to show; Compose calls it again when its data changes.
  • A Modifier chain is read from the outside in: background before padding to paint the padding, clickable before padding to make it tappable.
  • Row, Column and Box arrange composables; weight shares the free space.
  • remember { mutableStateOf(…) } holds values that change; change the state and the screen follows.
  • Composables emit into the composition and are restartable and skippable: keep them fast and free of side effects.
  • The first modifier is the outermost layer; that rule explains paint, clip and touch-target bugs.
  • Single-pass measurement: constraints down, sizes up; size respects constraints, requiredSize doesn’t.
  • Effects go in LaunchedEffect and friends; components take modifier first and expose slots.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.