Techbase Academy
Android · Lesson 37

Thinking in Compose: Composable Functions and the Three Phases

11 phasesHabit CardsAndroid Studio
Phase 1 of 11
Lesson Introduction

You have built many Compose screens by now, but you may still catch yourself thinking "how do I change the text of that label?" That question belongs to the old way of building Android UIs. Compose asks a different question: "what should the screen look like for this state?" Once that shift clicks, everything else in Compose becomes predictable.

This lesson is the foundation for the Jetpack Compose for Android developers course (the official programme at https://developer.android.com/courses/jetpack-compose/course). It covers Thinking in Compose, Composable functions, Write your first Compose app (the Compose basics codelab) and Compose phases. The next five lessons (38 to 41 and 43) continue with layouts, lazy lists, theming, animation and interoperability, and the Jetpack Compose programme's later pathways return in lessons 54 to 59.

By the end you will be able to:

  • Explain declarative versus imperative UI, and why Compose is declarative.
  • Describe recomposition, and why composable functions must be fast, idempotent and free of side effects.
  • Explain the three phases of every frame: composition, layout and drawing.
  • Use that knowledge to avoid unnecessary work (deferring state reads, lambda-based modifiers).
  • Explain stability and skipping in simple terms.
  • Build a small two-screen app (onboarding plus an expandable list) using only the basics.

Big idea: UI = f(state). The screen is a function of the data. When the data changes, Compose calls the function again.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 11
Prerequisite Concepts
  • State (remember, mutableStateOf) and recomposition at a basic level (lessons 17 and 18).
  • State hoisting and unidirectional data flow (lesson 24).
  • Kotlin lambdas and trailing-lambda syntax (lesson 12).
  • @Composable functions and @Preview (lessons 15 and 16).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 11
Conceptual Understanding: Declarative UI

The old way: imperative

With the older View system you built an object tree of views, then mutated each view by calling methods:

kotlin
// View-system style (imperative); not Compose
val label = findViewById<TextView>(R.id.label)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
 count++
 label.text = "Clicked $count times"
 if (count >= 5) {
 button.isEnabled = false
 label.setTextColor(Color.RED)
 }
}

You must remember to update every affected view in every code path. Add a third widget and a fourth rule, and it is easy to forget one, leaving the UI inconsistent with the data. These are among the most common bugs in classic apps.

The new way: declarative

In Compose you describe the UI for the current state and let the framework work out what changed:

kotlin
@Composable
fun ClickCounter() {
 var count by remember { mutableIntStateOf(0) }
 Column {
 Text(
 text = "Clicked $count times",
 color = if (count >= 5) Color.Red else Color.Unspecified
 )
 Button(onClick = { count++ }, enabled = count < 5) {
 Text("Click me")
 }
 }
}

There is no "update the label" code. The click changes count; Compose recomposes the function with the new value; the text, the colour and the enabled flag are all derived from the same variable in one place. They cannot disagree.

Imperative (Views) Declarative (Compose)
You write Steps to mutate widgets A description of the UI for a state
On a data change You find the affected views and update them You change state; the UI is re-described
Bugs of the kind Forgotten update paths Mostly impossible, because the UI derives from state
Mental model "How do I change it?" "What should it be?"

Composable functions

A composable function is a function annotated @Composable that emits UI into a tree rather than returning it. Calling Text("Hi") does not draw anything immediately; it tells the Compose runtime "there should be a Text here".

The conventions you should follow:

Convention Why
Name UI-emitting composables with PascalCase nouns (ProfileCard) They describe a thing in the UI
Name composables that return a value with lower-case (rememberScrollState) They behave like ordinary functions
Accept a modifier: Modifier = Modifier as the first optional parameter Callers can adjust size, padding and so on
Hoist state: take values and callbacks as parameters Reusable, testable, previewable
Do not return a Unit UI element and also emit; either emit or return Keeps the model clear
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Declarative UI. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Step-by-Step Build 1: The Contract of a Composable

Compose may call your composable many times, in any order, even on different threads, and may skip calls that it knows are unnecessary. To make that safe, a composable must behave like a pure function of its inputs.

Rule Meaning A violation
Idempotent Same inputs, same result, however often it runs Using the current time or a random number directly in the body
Free of side effects No writes to shared variables, no network, no logging that matters, no database Incrementing a global counter inside a composable
Fast Recomposition can happen on every frame during an animation Sorting a 10,000-item list in the body
Order-independent Siblings may run in any order, and in parallel Writing code where one sibling reads what another set

A bad composable

kotlin
var totalClicks = 0 // shared variable outside Compose
@Composable
fun BadCounter(onClick: () -> Unit) {
 totalClicks++ // side effect during composition!
 Text("Composed $totalClicks times")
 Button(onClick = onClick) { Text("Tap") }
}

totalClicks++ runs each time the function runs, which is not the same as each time the user taps. Compose might call BadCounter several times for a single frame, or skip it entirely, so the number is meaningless, and it also produces unpredictable behaviour when composition runs on several threads.

Where side effects belong

Side effects belong in event handlers (onClick, run when the user acts) or inside effect APIs that run at well-defined moments:

kotlin
@Composable
fun GoodCounter(onClick: () -> Unit) {
 Text("Tap the button")
 Button(onClick = {
 onClick() // a side effect in an event handler: fine
 }) { Text("Tap") }
}

For effects that must happen as the screen appears or when a value changes (loading data, starting a timer, subscribing), use LaunchedEffect, DisposableEffect and SideEffect, covered in lesson 55. For now remember: the body of a composable only describes UI.

Do not read hidden mutable state

A composable that reads a plain var instead of Compose State will not recompose when it changes:

kotlin
@Composable
fun Broken() {
 var count = 0 // ordinary variable
 Button(onClick = { count++ }) {
 Text("Clicked $count times") // never updates on screen
 }
}

Compose only knows to re-run code when it reads a snapshot state object such as mutableStateOf. And even with mutableStateOf, writing var count = mutableIntStateOf(0) without remember would reset to 0 on every recomposition, because the function body runs from the top each time. That is why remember { mutableIntStateOf(0) } exists: it stores the value in the composition so it survives recomposition.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: The Contract of a Composable. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Step-by-Step Build 2: Recomposition and Skipping

When a state value changes, Compose finds the composables that read that value and re-runs only those, and only as much as needed.

kotlin
@Composable
fun Dashboard() {
 var count by remember { mutableIntStateOf(0) }
 Column {
 Header() // does not read count
 CounterText(count) // reads count, so it recomposes
 Footer() // does not read count
 Button(onClick = { count++ }) { Text("Add") }
 }
}

When count changes, CounterText(count) gets a new argument and re-runs. Header() and Footer() take no changing inputs, so Compose skips them: it reuses the previous result. Skipping is what makes Compose fast.

When can Compose skip?

A composable can be skipped if all its parameters are unchanged and stable. Roughly:

  • Unchanged: the new value is equals() the old one (for primitives, strings and data classes with equal fields).
  • Stable: the type is guaranteed to tell Compose when it changes. Primitives, String, function types and classes with only stable val properties (and Compose State) are stable.

Types the compiler cannot prove stable include classes with var properties and the standard collection interfaces List, Set and Map, because the compiler cannot know whether the object behind a List is mutable. Recent Compose compiler versions have strong skipping mode enabled by default, which relaxes this: composables with unstable parameters can also be skipped when the instances are the same object (identity equal), and lambdas are remembered automatically. If you target an older compiler, you may need to use immutable collections or the @Immutable or @Stable annotations. You will measure this properly with the Layout Inspector's recomposition counts in lesson 44 and learn performance rules in lesson 59. For now:

  • Prefer immutable data classes (val only) for UI state, as you have been doing.
  • Pass the smallest data a composable needs, not a huge object.
  • Do not worry about skipping for ordinary screens; measure before optimising.

Keep recomposition scopes small

Recomposition happens at the level of the composable (more precisely, the nearest enclosing restartable function or lambda) that reads the state. If you read a fast-changing value high in the tree, a large subtree re-runs. Reading it in a small child composable limits the work. We will exploit this in the next section.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Recomposition and Skipping. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Step-by-Step Build 3: The Three Phases of a Frame

Every time Compose produces a frame, it goes through three phases:

text
        state changes (e.g. count++, scroll offset)
                     |
                     v
 +-----------------------------------------------+
 | 1. COMPOSITION   What to show?                |
 |    Runs your @Composable functions,           |
 |    builds / updates the UI tree.              |
 +-----------------------------------------------+
                     |
 +-----------------------------------------------+
 | 2. LAYOUT        Where to put it?             |
 |    Measures each node (size), then places it  |
 |    (x, y). Parent passes constraints down,    |
 |    child reports size up.                     |
 +-----------------------------------------------+
                     |
 +-----------------------------------------------+
 | 3. DRAWING       How does it look?            |
 |    Draws every node onto a canvas,            |
 |    pixels reach the screen.                   |
 +-----------------------------------------------+

The same three words describe a house: decide what rooms there are (composition), decide where each wall and room goes (layout), paint and furnish (drawing).

Phase 1: Composition

Runs the composable functions you wrote and produces the tree of UI nodes. This is where Text("Clicked $count times") is evaluated. Reading a State here subscribes that scope to changes.

Phase 2: Layout

Two steps, applied to the tree from the top down and the bottom up:

  1. Measure: a parent gives each child constraints (minimum and maximum width and height). The child measures itself within those constraints and reports a size. Children are measured once (a rule that makes layouts fast, unlike the older View system where nested layouts could measure repeatedly).
  2. Place: once all sizes are known, the parent places each child at an (x, y) position.

You will experiment with constraints and modifier order in lesson 38.

Phase 3: Drawing

Each node draws itself onto a canvas in order, parents first, then children on top. Modifier.background, drawBehind, graphicsLayer and Canvas participate here.

Why the phases matter: skip the earlier ones

Each phase reads state independently. If a state read happens only in the layout phase, a change to that state re-runs layout and drawing but not composition. If it happens only in the drawing phase, only drawing re-runs. That is a big performance lever, and it is how smooth animations work.

Consider moving a box horizontally based on a fast-changing scroll offset.

Slow: state read during composition

kotlin
@Composable
fun SlowBox(scrollOffset: State<Int>) {
 Box(
 Modifier.offset(x = scrollOffset.value.dp) // read in composition
 ) { Text("Hello") }
}

Reading scrollOffset.value as a function argument happens during composition, so every change recomposes the function, then lays out and draws again: all three phases, many times a second.

Fast: defer the read to the layout phase

kotlin
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.unit.IntOffset
@Composable
fun FastBox(scrollOffset: State<Int>) {
 Box(
 Modifier.offset { IntOffset(x = scrollOffset.value, y = 0) } // lambda: read in layout
 ) { Text("Hello") }
}

The offset { } overload that takes a lambda reads the state inside the layout phase. When the value changes, only layout and drawing re-run; composition is skipped. Many modifiers come in a "lambda" flavour for exactly this reason:

Reads state in composition (slower for fast-changing values) Defers to a later phase (faster)
Modifier.offset(x = ...) Modifier.offset { IntOffset(...) }
Modifier.alpha(a) Modifier.graphicsLayer { alpha = a }
Modifier.rotate(deg) Modifier.graphicsLayer { rotationZ = deg }
Modifier.background(color) where color animates Modifier.drawBehind { drawRect(color) }

The rule of thumb: for values that change on every frame (animations, scrolling, dragging), read them as late as possible, in a lambda. For values that change only on a user's tap, the ordinary form is fine and clearer. Do not rewrite everything; do this where profiling or an animation demands it (lessons 41 and 59).

Backwards writes

A related pitfall: never write to a state after you have read it in the same composition. For example, reading count and then setting count++ inside the composable body (not inside an event handler) creates an endless recomposition loop. Compose logs a warning about a "backwards write". Changes to state belong in callbacks and effects.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: The Three Phases of a Frame. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Step-by-Step Build 4: Your First Compose Essentials App

This is the Compose basics codelab from the programme, rebuilt with our own code. The app has an onboarding screen with a Continue button, then a list of greetings whose items expand with an animation. It uses only what you have learned.

Create an Empty Activity project named Basics (package com.example.basics).

The structure

text
MyApp
 +-- if (shouldShowOnboarding) OnboardingScreen(onContinueClicked)
 +-- else Greetings(names)
        +-- LazyColumn
              +-- Greeting(name)   (expandable card)

Step 1: The entry point with hoisted state

MainActivity.kt
package com.example.basics
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) {
 MyApp()
 }
 }
 }
 }
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
 var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }
 Surface(modifier) {
 if (shouldShowOnboarding) {
 OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
 } else {
 Greetings()
 }
 }
}

shouldShowOnboarding lives in MyApp, the lowest common ancestor that needs it, and is saved with rememberSaveable so rotation does not send the user back to the welcome screen. OnboardingScreen is stateless: it receives a callback and does not know how the flag is stored. This is state hoisting (lesson 24) in miniature. (For a real "show once ever" onboarding you would save the flag in DataStore, lesson 35.)

Step 2: The onboarding screen

kotlin
@Composable
fun OnboardingScreen(
 onContinueClicked: () -> Unit,
 modifier: Modifier = Modifier
) {
 Column(
 modifier = modifier.fillMaxSize(),
 verticalArrangement = Arrangement.Center,
 horizontalAlignment = Alignment.CenterHorizontally
 ) {
 Text("Welcome to the Basics Codelab!")
 Button(
 modifier = Modifier.padding(vertical = 24.dp),
 onClick = onContinueClicked
 ) {
 Text("Continue")
 }
 }
}
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
 MaterialTheme { OnboardingScreen(onContinueClicked = {}) }
}

Step 3: The expandable greeting card

kotlin
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
 Card(modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)) {
 CardContent(name)
 }
}
@Composable
private fun CardContent(name: String) {
 var expanded by rememberSaveable { mutableStateOf(false) }
 Row(
 modifier = Modifier
 .padding(12.dp)
 .animateContentSize(
 animationSpec = spring(
 dampingRatio = Spring.DampingRatioMediumBouncy,
 stiffness = Spring.StiffnessLow
 )
 )
 ) {
 Column(
 modifier = Modifier
 .weight(1f)
 .padding(12.dp)
 ) {
 Text(text = "Hello,")
 Text(
 text = name,
 style = MaterialTheme.typography.headlineMedium
 )
 if (expanded) {
 Text(
 text = "Composem ipsum color sit lazy, padding theme elit, sed do bouncy. ".repeat(4)
 )
 }
 }
 ElevatedButton(onClick = { expanded = !expanded }) {
 Text(if (expanded) "Show less" else "Show more")
 }
 }
}

Notice how expanded belongs to each card: because it is created with remember inside CardContent, every card in the list has its own independent flag. Expanding one does not expand the others.

In a long list, rememberSaveable state is stored against the item's position in the composition. In a LazyColumn, scrolling an item away and back keeps rememberSaveable values (plain remember would reset them when the item leaves the composition). Lesson 39 explains keys, which make this robust when the list changes.

Step 4: The list

kotlin
@Composable
fun Greetings(
 modifier: Modifier = Modifier,
 names: List<String> = List(1000) { "$it" }
) {
 LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
 items(items = names) { name ->
 Greeting(name = name)
 }
 }
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingsPreview() {
 MaterialTheme { Greetings() }
}

List(1000) { "$it" } creates a thousand strings, "0" to "999". A Column would struggle; LazyColumn composes only what is visible (lesson 21). Run the app: tap Continue, scroll, expand a card (watch the springy animation from animateContentSize), rotate the phone (the expanded cards and the screen choice survive).

What you just used

Concept Where
Declarative UI Each screen is a description of state
Hoisted state OnboardingScreen(onContinueClicked)
remember / rememberSaveable Per-card expanded; screen flag
Lazy list LazyColumn
Modifier chain padding, weight, animateContentSize
Previews Three @Preview functions
✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Your First Compose Essentials App. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Reading the Phases in the Layout Inspector

Run the app, then open Tools > Layout Inspector (lesson 44 covers it fully). In the options, enable Show recomposition counts. Tap "Show more" on one card and watch the counts: only that card's subtree recomposes, not its neighbours. That is skipping in action, and an early taste of how you will verify performance with data instead of guesses.

✏️ Your Task
Practise what you just learned about Reading the Phases in the Layout Inspector. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 11
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Spot the problems

List every problem with this composable and rewrite it correctly:

kotlin
@Composable
fun Timer() {
 val start = System.currentTimeMillis()
 var seconds = 0
 Text("Started at $start, ${seconds}s elapsed")
 Button(onClick = { seconds++ }) { Text("+1") }
}

Problems:

  1. System.currentTimeMillis() in the body is not idempotent: every recomposition shows a different "start", which is meaningless.
  2. var seconds = 0 is a plain variable: the button changes it but nothing tells Compose to recompose, so the text never updates, and it would reset to 0 anyway.
  3. The "start" is not remembered, so it cannot be a stable fact about the screen.

Correct version:

kotlin
@Composable
fun Timer() {
 val start = rememberSaveable { System.currentTimeMillis() } // computed once, survives rotation
 var seconds by rememberSaveable { mutableIntStateOf(0) }
 Column {
 Text("Started at $start, ${seconds}s elapsed")
 Button(onClick = { seconds++ }) { Text("+1") }
 }
}

remember { } runs its block once, in the first composition, and stores the result. Using the current time inside it is acceptable because it is evaluated a single time. In a real app the start time would come from a ViewModel. Also wrap the two elements in a Column; otherwise their placement is unspecified.

🎯 Your Challenge

Exercise 2: Move the read to the right phase

A Box should fade in and out following a Float animation value alpha (0f to 1f) that changes every frame. The first version uses Modifier.alpha(alpha). Rewrite it so the change does not recompose the composable, and explain the phases involved.

kotlin
import androidx.compose.ui.graphics.graphicsLayer
@Composable
fun FadingBox(alpha: State<Float>) {
 Box(
 Modifier
 .size(100.dp)
 .graphicsLayer { this.alpha = alpha.value } // read inside the draw/layer phase
 .background(Color.Blue)
 )
}

With Modifier.alpha(alpha.value) the state is read in composition, so each frame re-runs composition, layout and drawing. In the graphicsLayer { } lambda the state is read when the layer is drawn, so a change only invalidates the drawing phase: composition and layout are skipped. For an animation that changes sixty times a second, that is a big saving. (animateFloatAsState in lesson 41 returns a State<Float> exactly for this use.) Note that .size() and .background() are unaffected by the alpha.

🎯 Your Challenge

Exercise 3: Extend the app

Add a third feature to the Basics app: a favourite star on each card that toggles on tap and survives rotation, and a counter at the top of the list showing how many cards are favourites. Decide where the favourites state must live and why.

The counter needs to know about every card's favourite flag, so the state must live above both the cards and the counter: in Greetings (or better, a ViewModel). Per-card remember would hide it from the counter.

kotlin
@Composable
fun Greetings(modifier: Modifier = Modifier, names: List<String> = List(1000) { "$it" }) {
 var favourites by rememberSaveable { mutableStateOf(setOf<String>()) }
 Column(modifier) {
 Text("Favourites: ${favourites.size}", modifier = Modifier.padding(16.dp))
 LazyColumn {
 items(names, key = { it }) { name ->
 val isFavourite = name in favourites
 Greeting(
 name = name,
 isFavourite = isFavourite,
 onToggleFavourite = {
 favourites = if (isFavourite) favourites - name else favourites + name
 }
 )
 }
 }
 }
}

Greeting now receives isFavourite and onToggleFavourite and draws an IconButton with a star icon. A Set<String> is saveable on most Compose versions through the default saver only if it is a Serializable or Parcelable; if your build complains, store a List<String> or move the state into a ViewModel (lesson 24), which is where it belongs in a real app anyway. The data is immutable: we create a new set with + and -, which is what triggers recomposition.

✏️ Your Task

In the Layout Inspector with recomposition counts enabled, toggle a card's expansion ten times and note the counts for that card and for its neighbours. Then change expanded from rememberSaveable to a plain variable var expanded = false and observe what happens. Describe it in two sentences.

Phase 10 of 11
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Side effects in the body. What happens: actions repeat unpredictably (extra network calls, duplicate analytics). The fix: event handlers or effect APIs (lesson 55).

Mistake #2: mutableStateOf without remember. What happens: the state resets on every recomposition. The fix: remember { mutableStateOf(...) }, or rememberSaveable.

Mistake #3: A plain var. What happens: the UI never updates. The fix: Compose state.

Mistake #4: Doing heavy work in a composable. What happens: janky scrolling and animation. The fix: compute in the ViewModel, or use remember(key) { ... } or derivedStateOf.

Mistake #5: Reading fast-changing state in composition. What happens: unnecessary recompositions. The fix: the lambda versions of modifiers (offset { }, graphicsLayer { }) or drawBehind.

Mistake #6: Writing state while composing (backwards write). What happens: an infinite loop warning. The fix: write state only in callbacks and effects.

Mistake #7: Making every composable depend on the whole UI state. What happens: everything recomposes on any change. The fix: pass only what each composable needs.

Mistake #8: Forgetting the modifier parameter. What happens: callers cannot position or size your composable. The fix: modifier: Modifier = Modifier as the first optional parameter, applied to the root element.

Mistake #9: Prematurely optimising. What happens: complicated code with no measured benefit. The fix: measure with the Layout Inspector and the profiler first (lessons 44 and 59).

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 11
Knowledge Check

Question 1. What is the difference between declarative and imperative UI?

Imperative UI mutates widgets step by step (label.text = ...). Declarative UI describes what the screen should look like for the current state, and the framework updates it when state changes.

Question 2. Why must a composable be idempotent and free of side effects?

Compose may call it many times, skip it, run it in any order or on other threads. Side effects or hidden inputs would then occur unpredictably and produce inconsistent results.

Question 3. What are the three phases of a frame?

Composition (what to show), layout (measure and place) and drawing (render to the canvas).

Question 4. Why is Modifier.offset { } with a lambda faster than Modifier.offset(x = ...) for a rapidly changing value?

The lambda reads the state during the layout phase, so a change skips composition and only re-runs layout and drawing. The non-lambda form reads the state during composition and triggers all three phases.

Question 5. What does it mean for Compose to skip a composable?

When a composable's inputs have not changed (and are stable or identical), Compose reuses its previous output instead of running it again.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Habit Cards

Project goal: Build a habit tracker screen using only this lesson's ideas: declarative UI, hoisted state, per-item state and phase-aware code.

Requirements:

  1. A list of at least six habits (names you choose). Each habit card shows its name, a streak count (starts at 0) and a Done today button.
  2. Pressing the button increments that habit's streak and disables the button for that habit until the next day (for this exercise, a Reset day button at the top re-enables all buttons).
  3. A header composable reading Completed today: X of N. Because it needs data from all cards, hoist the state accordingly.
  4. A card can expand to show a short note, animated with animateContentSize.
  5. State survives rotation (rememberSaveable) and every composable has a modifier parameter and a preview.
  6. One element animates with a read deferred to the draw phase: a progress bar under the header drawn with drawBehind from the completed fraction.

Stretch goal: open the Layout Inspector and record recomposition counts before and after pressing one habit's button; write down the numbers and explain them.

HabitCards.kt
package com.example.habits
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
val habits = listOf("Drink water", "Read 10 pages", "Walk 20 minutes", "Practise Kotlin", "Sleep by 11pm", "Call family")
@Composable
fun HabitsScreen(modifier: Modifier = Modifier) {
 // Hoisted: the header and every card need these.
 var done by rememberSaveable { mutableStateOf(setOf<String>()) }
 var streaks by rememberSaveable { mutableStateOf(List(habits.size) { 0 }) }
 val fraction = done.size / habits.size.toFloat()
 Column(modifier) {
 Text(
 "Completed today: ${done.size} of ${habits.size}",
 style = MaterialTheme.typography.titleLarge,
 modifier = Modifier.padding(16.dp)
 )
 ProgressBar(fractionProvider = { fraction })
 OutlinedButton(onClick = { done = emptySet() }, modifier = Modifier.padding(16.dp)) {
 Text("Reset day")
 }
 LazyColumn {
 items(habits.size, key = { habits[it] }) { index ->
 val name = habits[index]
 HabitCard(
 name = name,
 streak = streaks[index],
 isDone = name in done,
 onDone = {
 done = done + name
 streaks = streaks.toMutableList().also { it[index] = it[index] + 1 }
 }
 )
 }
 }
 }
}
@Composable
fun ProgressBar(fractionProvider: () -> Float, modifier: Modifier = Modifier) {
 Row(
 modifier
 .fillMaxWidth()
 .padding(horizontal = 16.dp)
 .height(8.dp)
 .drawBehind {
 drawRect(Color.LightGray)
 // The state is read here, in the draw phase: composition is skipped on change.
 drawRect(Color(0xFF3DDC84), size = Size(size.width * fractionProvider(), size.height))
 }
 ) {}
}
@Composable
fun HabitCard(
 name: String,
 streak: Int,
 isDone: Boolean,
 onDone: () -> Unit,
 modifier: Modifier = Modifier
) {
 var expanded by rememberSaveable(name) { mutableStateOf(false) }
 Card(modifier.padding(horizontal = 16.dp, vertical = 4.dp).animateContentSize()) {
 Column(Modifier.padding(16.dp)) {
 Text(name, style = MaterialTheme.typography.titleMedium)
 Text("Streak: $streak")
 Row {
 Button(onClick = onDone, enabled = !isDone) { Text(if (isDone) "Done" else "Done today") }
 OutlinedButton(onClick = { expanded = !expanded }, modifier = Modifier.padding(start = 8.dp)) {
 Text(if (expanded) "Hide note" else "Note")
 }
 }
 if (expanded) Text("Small, repeatable steps beat big, rare ones.", modifier = Modifier.padding(top = 8.dp))
 }
 }
}

Notes: the progress bar takes a lambda (fractionProvider) rather than a Float, so the value is read inside drawBehind, in the draw phase, which is the technique from the lesson. In a real app both done and streaks would live in a ViewModel (lesson 24); they are in the screen composable here to keep the exercise focused on the lesson's ideas. A Set<String> and a List<Int> inside rememberSaveable rely on your Compose version's default saver accepting them; if your build rejects them, convert to ArrayLists or move the state into a ViewModel. The header and the cards agree by construction, since both derive from the same done set.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 37 project”.

3

On github.com click New repository, name it android-lesson-37-thinking-in-compose-composable-functions-a, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 37 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Declarative UI
  • Step-by-Step Build 1: The Contract of a Composable
  • Step-by-Step Build 2: Recomposition and Skipping
  • Step-by-Step Build 3: The Three Phases of a Frame
  • Step-by-Step Build 4: Your First Compose Essentials App
  • Reading the Phases in the Layout Inspector
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close