Techbase Academy
Android · Lesson 39

Lazy Layouts: Lists, Grids and Scrolling Performance

13 phasesMixed Feed with Sticky SectionsAndroid Studio
Phase 1 of 13
Lesson Introduction

In lesson 21 you built a list with LazyColumn and a grid with LazyVerticalGrid, and that took you a long way. This lesson goes deeper, because scrolling lists are where apps feel fast or feel broken. A list that stutters, loses its scroll position, resets an expanded item when you add a row, or jumps when images load is a list built without understanding keys, content types and state.

This lesson covers the Lazy layouts item of Pathway 2 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-2).

By the end you will be able to:

  • Choose among LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid and staggered grids.
  • Build lists from mixed content with item { }, items { }, itemsIndexed and stickyHeader.
  • Explain what keys do and choose good ones.
  • Use content types to help Compose reuse composables.
  • Control and observe scrolling with LazyListState.
  • Animate insertions, removals and reordering with animateItem().
  • Avoid the classic crashes: nested scrolling and zero-size items.
  • Apply the main performance rules, and measure.

Big idea: a lazy list is a window onto a large data set. Keys give each item an identity so that state, animations and scroll position follow the right item when the data changes.

✏️ 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 13
Prerequisite Concepts
  • Basic LazyColumn and LazyVerticalGrid (lesson 21).
  • Recomposition and the three phases (lesson 37).
  • State hoisting and immutable data classes (lesson 24).
  • Layouts and modifiers (lesson 38).
✏️ 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 13
Conceptual Understanding: How Lazy Lists Work

A normal Column composes all its children. A LazyColumn composes only the items visible plus a small buffer, and disposes of items that scroll far away.

text
data: [item 0][item 1][item 2] ... [item 998][item 999]

screen window:       +--------+
                     | item 40|   <- composed
                     | item 41|   <- composed
                     | item 42|   <- composed
                     +--------+
 composed in a small buffer above and below; everything else does not exist as UI.

Three consequences you must keep in mind:

  1. Item state is not kept when an item scrolls away, unless it is stored with rememberSaveable (which survives item disposal in a lazy list) or, better, in your data/ViewModel.
  2. Items are identified by position unless you give a key. If the data changes (an item is inserted at the top), position-based identity makes every item below look as if its content changed.
  3. The lazy DSL (LazyListScope) is not composable code. Inside LazyColumn { ... } you call item { } and items { }; you cannot call Text(...) directly there. The lambda you pass to item { } is composable.

The lazy family

Layout Direction Typical use
LazyColumn Vertical list Feeds, settings, messages
LazyRow Horizontal list Carousels, chip rows
LazyVerticalGrid Vertical grid, columns by GridCells Photo gallery, product catalogue
LazyHorizontalGrid Horizontal grid, rows Category shelves
LazyVerticalStaggeredGrid Vertical grid with varied item heights Pinterest-style feeds
LazyHorizontalStaggeredGrid Horizontal variant Rare
HorizontalPager/VerticalPager One page at a time, swipeable Onboarding, image pagers (lesson 59)
✏️ Your Task
Practise what you just learned about Conceptual Understanding: How Lazy Lists Work. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 13
Step-by-Step Build 1: The DSL
kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun MixedList(favourites: List<String>, recent: List<String>) {
 LazyColumn(
 contentPadding = PaddingValues(16.dp),
 verticalArrangement = Arrangement.spacedBy(8.dp)
 ) {
 item { Text("Favourites") } // one item
 items(favourites) { name -> Text(name) } // one item per element
 item { Text("Recent") }
 itemsIndexed(recent) { index, name -> Text("${index + 1}. $name") }
 item { Text("End of list") }
 }
}
Function Use
item { } A single item (header, footer, button)
items(list) { element -> } One item per list element
items(count) { index -> } One item per integer, when you have no list
itemsIndexed(list) { index, element -> } When the position matters

The "mixed list" pattern, with headers and several kinds of rows in one scrolling container, is why you should have a single LazyColumn for a whole screen instead of nesting scrolling containers (see the nesting section).

Sticky headers

stickyHeader keeps the current section header pinned to the top while its section scrolls. It is currently marked experimental in many Foundation versions:

kotlin
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<String>>) {
 LazyColumn {
 grouped.forEach { (initial, names) ->
 stickyHeader {
 Text(
 initial.toString(),
 modifier = Modifier
 .fillMaxWidth()
 .background(MaterialTheme.colorScheme.surfaceVariant)
 .padding(8.dp)
 )
 }
 items(names, key = { it }) { name ->
 Text(name, Modifier.padding(16.dp))
 }
 }
 }
}

Give the sticky header an opaque background, otherwise the items scrolling underneath show through it. (If your Compose version no longer needs the opt-in, the compiler will warn that it is unnecessary; remove it.)

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

The problem

Imagine a list of tasks, each with a checkbox whose state is held inside the row with remember:

kotlin
data class Task(val id: Int, val title: String)
@Composable
fun TaskRow(task: Task) {
 var done by rememberSaveable { mutableStateOf(false) }
 Row {
 Checkbox(checked = done, onCheckedChange = { done = it })
 Text(task.title)
 }
}
LazyColumn {
 items(tasks) { task -> TaskRow(task) } // no key
}

Check "Buy rice" (position 0), then add a new task at the top. Without keys, Compose identifies rows by position. The row at position 0 keeps its done = true state but now displays the new task, so the new task appears ticked while "Buy rice" (now position 1) is not. The state stuck to the slot, not the item.

The fix

kotlin
LazyColumn {
 items(tasks, key = { it.id }) { task -> TaskRow(task) }
}

With key = { it.id }, state and animation follow the id. Insert, delete or reorder, and each row keeps its own state.

Rules for keys:

Rule Reason
Unique within that lazy container Duplicates crash with Key was already used
Stable: the same item always has the same key Not index, not a random value generated each time
Saveable: a type that can go in a Bundle (Int, Long, String, Parcelable, Serializable) Keys are used to restore scroll position and rememberSaveable state
Based on identity (database id, UUID), not on content (a title can repeat) Content changes should not change identity

In Room entities, the primary key is the natural key (lesson 34). For static lists that never change, keys matter less, but adding them is a good habit.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Keys. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 13
Step-by-Step Build 3: Content Types

When a list mixes different kinds of rows (headers, photos, ads), Compose can reuse the composition of an item that scrolled off for a new item that scrolls on, if they are the same type of item. By default it assumes all items are the same type. Tell it explicitly:

kotlin
sealed interface Row {
 data class Header(val title: String) : Row
 data class Product(val id: Int, val name: String) : Row
}
LazyColumn {
 items(
 items = rows,
 key = { row ->
 when (row) {
 is Row.Header -> "header_${row.title}"
 is Row.Product -> "product_${row.id}"
 }
 },
 contentType = { row ->
 when (row) {
 is Row.Header -> "header"
 is Row.Product -> "product"
 }
 }
 ) { row ->
 when (row) {
 is Row.Header -> HeaderRow(row.title)
 is Row.Product -> ProductRow(row)
 }
 }
}

Notice the two string prefixes in the keys, which keep a header named "42" from clashing with a product with id 42. contentType is purely a performance hint: reusing a compatible composition is cheaper than creating a different one. It is worth adding to any list with clearly different row layouts; it does nothing for single-type lists.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Content Types. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 13
Step-by-Step Build 4: Grids and Staggered Grids

You met LazyVerticalGrid with GridCells.Fixed and GridCells.Adaptive in lesson 21. Two more capabilities:

Items that span columns

kotlin
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 140.dp)) {
 item(span = { GridItemSpan(maxLineSpan) }) { // a full-width header across all columns
 Text("Photos", style = MaterialTheme.typography.titleLarge)
 }
 items(photos, key = { it.id }) { PhotoTile(it) }
}

maxLineSpan is "all columns of this row", however many there are.

Staggered grids

When items have different heights (cards with varying text), a normal grid leaves gaps. A staggered grid packs each item under the shortest column:

kotlin
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
LazyVerticalStaggeredGrid(
 columns = StaggeredGridCells.Adaptive(minSize = 160.dp),
 contentPadding = PaddingValues(8.dp),
 horizontalArrangement = Arrangement.spacedBy(8.dp),
 verticalItemSpacing = 8.dp
) {
 items(notes, key = { it.id }) { note -> NoteCard(note) }
}

(Note verticalItemSpacing rather than verticalArrangement.) Use staggered grids for notes, photos of mixed aspect ratio and Pinterest-style feeds.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Grids and Staggered Grids. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 13
Step-by-Step Build 5: Controlling and Observing Scroll

LazyListState holds the scroll position and lets you read it or move it:

kotlin
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun MessagesScreen(messages: List<Message>) {
 val listState = rememberLazyListState()
 val scope = rememberCoroutineScope()
 // true when the first visible item is not at the very top
 val showScrollToTop by remember {
 derivedStateOf { listState.firstVisibleItemIndex > 0 }
 }
 Box {
 LazyColumn(state = listState) {
 items(messages, key = { it.id }) { MessageRow(it) }
 }
 if (showScrollToTop) {
 FloatingActionButton(
 onClick = { scope.launch { listState.animateScrollToItem(0) } },
 modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
 ) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Back to top") }
 }
 }
}

Important details:

  • rememberLazyListState() creates and remembers the state; it uses rememberSaveable internally so the scroll position survives rotation.
  • animateScrollToItem(index) and scrollToItem(index) are suspend functions: launch them from a coroutine scope (rememberCoroutineScope, lesson 28).
  • derivedStateOf is the key trick: firstVisibleItemIndex changes on almost every scroll frame, but showScrollToTop changes only when the answer flips between true and false. Wrapping the calculation in derivedStateOf makes the composable recompose only when the result changes, not on every scroll pixel. Reading listState.firstVisibleItemIndex > 0 directly in the composable would recompose on every index change.
  • Other useful properties: firstVisibleItemScrollOffset, layoutInfo.visibleItemsInfo, canScrollForward, isScrollInProgress.

To react to scroll events (for example, loading the next page), use a flow:

kotlin
LaunchedEffect(listState) {
 snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
 .filterNotNull()
 .distinctUntilChanged()
 .collect { lastVisible ->
 if (lastVisible >= messages.size - 5) onLoadMore()
 }
}

snapshotFlow turns Compose state reads into a Flow (lessons 33 and 55). This is the standard way to implement infinite scrolling: when the user is within five items of the end, ask the ViewModel for another page (lesson 62 covers Paging 3, which automates this).

Item animations

kotlin
LazyColumn {
 items(tasks, key = { it.id }) { task ->
 TaskRow(task, modifier = Modifier.animateItem())
 }
}

Modifier.animateItem() animates placement (when an item moves because others were added or removed), fade-in and fade-out. It requires keys, because it needs to know which item is which. On older Compose versions the modifier is called animateItemPlacement() and is experimental; check which your version provides.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Controlling and Observing Scroll. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 13
Step-by-Step Build 6: Nesting and Zero-Size Pitfalls

Never nest same-direction scrolling

kotlin
// CRASH: a vertically scrolling LazyColumn inside a vertically scrolling Column
Column(Modifier.verticalScroll(rememberScrollState())) {
 Text("Header")
 LazyColumn { items(100) { Text("Row $it") } }
}

This throws Vertically scrollable component was measured with an infinity maximum height constraints. The Column gives its child unlimited height, and a lazy list cannot decide how many items to compose in an infinite space. Fixes, in order of preference:

  1. One lazy container. Put the header as the first item { } of the LazyColumn.
  2. Give the inner list a fixed or bounded height (Modifier.height(300.dp)), when a small inner scroller is intentional.
  3. For a short, known-size list, use a plain Column (no laziness) inside the scrolling Column.

Different directions are fine: a LazyRow carousel inside a LazyColumn item is a common, supported pattern.

Never create 0-pixel items

If an item has no content on first composition (for example, an image that loads asynchronously with no size set), its height is 0. The list may then compose hundreds of items at once to fill the screen, which defeats laziness and lags badly. Always give items a minimum size (Modifier.heightIn(min = 72.dp), aspectRatio, or a placeholder of the right height) while their content loads.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Nesting and Zero-Size Pitfalls. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 13
Performance Checklist

Measure first (Layout Inspector recomposition counts and the profiler, lessons 44 and 59), then apply the relevant rules:

Rule Why
Provide stable, unique keys Avoid rebuilding and mis-assigned state
Add contentType for mixed lists Composition reuse
Use immutable data classes for items Allows skipping unchanged rows (lesson 37)
Keep per-item work small: no sorting, formatting or filtering in the item body The body runs while scrolling
Precompute in the ViewModel or with remember(key) Done once, not on every pass
Use derivedStateOf for values derived from fast-changing state Fewer recompositions
Give images a fixed size; use Coil AsyncImage (lesson 31) No layout jumps; correct decoding size
Avoid Modifier.fillParentMaxHeight surprises and 0-size items Prevent composing everything
Test with a release build Debug builds are much slower; do not judge scroll performance in debug
Put clickable lambdas in the item, not in a shared mutable thing Avoid accidental capture of changing state

A very important point: always judge scrolling performance in a release (or benchmark) build, ideally on a real, modest phone. Debug builds include checks that make Compose look janky even when your code is fine.

✏️ Your Task
Practise what you just learned about Performance Checklist. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 13
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Find and fix the state bug

A shopping list uses remember { mutableStateOf(false) } in each row for a "bought" checkbox, no keys, and a button that adds a new item at the top. Describe the bug and fix it so each item keeps its own checkbox, and so the checkbox state also survives scrolling away and back.

Without keys, rows are identified by position. After inserting at the top, each existing row's remembered state stays in its position slot, so ticks appear on the wrong items. Also remember is lost when a row scrolls out of the composition.

Best fix: keep the bought flag in the data, not in the row, and give keys.

kotlin
data class ShoppingItem(val id: Int, val name: String, val bought: Boolean = false)
@Composable
fun ShoppingList(
 items: List<ShoppingItem>,
 onToggle: (Int) -> Unit,
 modifier: Modifier = Modifier
) {
 LazyColumn(modifier) {
 items(items, key = { it.id }) { item ->
 Row(Modifier.animateItem(), verticalAlignment = Alignment.CenterVertically) {
 Checkbox(checked = item.bought, onCheckedChange = { onToggle(item.id) })
 Text(item.name)
 }
 }
 }
}

The ViewModel owns the list and flips bought with copy. This also satisfies the "survives scrolling away" requirement for free, since the state is not in the composition at all. If you must keep it local, use rememberSaveable with keys, which restores per key.

🎯 Your Challenge

Exercise 2: Sections with sticky headers

Given a list of students with a className property, show them grouped by class with sticky headers, a stable key for every row, and an alphabetical order within each class. Use groupBy and sortedBy.

kotlin
data class Student(val id: Int, val name: String, val className: String)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StudentsByClass(students: List<Student>, modifier: Modifier = Modifier) {
 val grouped = remember(students) {
 students.groupBy { it.className }
 .toSortedMap()
 .mapValues { (_, list) -> list.sortedBy { it.name } }
 }
 LazyColumn(modifier) {
 grouped.forEach { (className, list) ->
 stickyHeader(key = "header_$className") {
 Text(
 className,
 style = MaterialTheme.typography.titleMedium,
 modifier = Modifier
 .fillMaxWidth()
 .background(MaterialTheme.colorScheme.secondaryContainer)
 .padding(12.dp)
 )
 }
 items(list, key = { it.id }) { student ->
 Text(student.name, Modifier.padding(16.dp))
 }
 }
 }
}

remember(students) recomputes the grouping only when the list changes, not on every scroll. Keys are prefixed so headers and rows never collide. The stickyHeader(key = ...) overload exists in recent versions; if yours does not accept a key, omit it.

🎯 Your Challenge

Exercise 3: Infinite scroll

A feed shows 20 items and should load 20 more when the user reaches within 5 items of the end, but not while a load is already in progress. Sketch the ViewModel function and the LaunchedEffect that triggers it.

kotlin
// ViewModel
private var loading = false
fun loadMore() {
 if (loading) return
 loading = true
 viewModelScope.launch {
 try {
 val next = repository.loadPage(page = currentPage + 1)
 _uiState.update { it.copy(items = it.items + next) }
 currentPage++
 } finally {
 loading = false
 }
 }
}
kotlin
// Composable
@Composable
fun Feed(items: List<FeedItem>, onLoadMore: () -> Unit) {
 val listState = rememberLazyListState()
 LaunchedEffect(listState, items.size) {
 snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
 .distinctUntilChanged()
 .collect { lastVisible ->
 if (lastVisible >= items.size - 5) onLoadMore()
 }
 }
 LazyColumn(state = listState) {
 items(items, key = { it.id }) { FeedRow(it) }
 }
}

The loading guard stops overlapping requests. In a real app you would also handle the last page (no more data) and errors, and use Paging 3 (lesson 62) to get all of this without hand-written code.

✏️ Your Task

Build a list of 5,000 rows, each with a checkbox. Scroll quickly in a debug build and then in a release build on the same device, and compare the smoothness. Write down what you observe about build types and performance.

Phase 12 of 13
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: No keys on a list that changes. What happens: state attaches to the wrong item; animations look wrong. The fix: a stable, unique key based on identity.

Mistake #2: Using the list index as the key. What happens: the same problem as no key. The fix: use the item id.

Mistake #3: Duplicate keys. Symptom: Key ... was already used. The fix: make keys unique, prefixing by type if you mix item kinds.

Mistake #4: A lazy list inside a vertical scroll. Symptom: the infinite-height crash. The fix: a single lazy container.

Mistake #5: Zero-height items on first composition. What happens: hundreds of items composed at once. The fix: a minimum size or placeholder.

Mistake #6: Expensive work in the item body. What happens: jank while scrolling. The fix: precompute in the ViewModel, or remember.

Mistake #7: Reading firstVisibleItemIndex directly for UI conditions. What happens: recomposition on every index change. The fix: derivedStateOf.

Mistake #8: Item state in remember inside the row. What happens: it is lost when the row scrolls away. The fix: keep it in your data or use rememberSaveable with proper keys.

Mistake #9: Judging performance in a debug build. What happens: you "fix" problems that do not exist. The fix: test a release build.

Mistake #10: Forgetting contentPadding. What happens: the first and last items stick to the edges, and a bottom FAB hides the last row. The fix: contentPadding (not outer padding), so items scroll through the padded area.

✏️ 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 13 of 13
Knowledge Check

Question 1. Why is LazyColumn more efficient than Column with a loop?

It composes and lays out only the visible items (plus a small buffer) and disposes of far-away ones, while Column composes all children at once.

Question 2. What problem do keys solve?

They give items a stable identity so that state, scroll position and animations follow the item when the list changes, rather than sticking to a position.

Question 3. What is contentType for?

It tells Compose which items are of the same kind so it can reuse their compositions when scrolling, which improves performance in lists with different row types.

Question 4. Why is derivedStateOf useful with scroll state?

It exposes a value computed from a fast-changing state and only notifies readers when the computed result changes, avoiding a recomposition on every scroll position change.

Question 5. Why does a LazyColumn inside a vertically scrolling Column crash?

The parent gives it infinite height, and a lazy list cannot compose "just the visible items" in infinite space. Use one lazy container, bound the height, or use a plain Column for short content.

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
Mixed Feed with Sticky Sections

Project goal: Build a school notice board feed that mixes headers, notices and an image-style card, with proper keys, content types, scroll-to-top button and animated insertion.

Requirements:

  1. A sealed FeedRow type with Header(title), Notice(id, title, body, pinned), and Highlight(id, title) rows.
  2. One LazyColumn with a key and a contentType per row type; headers are sticky.
  3. An Add notice button that inserts a new notice at the top of its section with Modifier.animateItem().
  4. A Pin toggle on each notice stored in the data (ViewModel), which moves pinned notices to a "Pinned" section.
  5. A scroll-to-top FAB that only appears after the first screenful, using derivedStateOf.
  6. 200 sample notices generated in code. Test scrolling in a release build.

Stretch goal: add a LazyRow carousel of highlights as the first item and confirm that nesting a horizontal lazy list is safe.

NoticeBoard.kt
package com.example.notices
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
sealed interface FeedRow {
 data class Header(val title: String) : FeedRow
 data class Notice(val id: Int, val title: String, val body: String, val pinned: Boolean) : FeedRow
 data class Highlight(val id: Int, val title: String) : FeedRow
}
private fun FeedRow.key(): Any = when (this) {
 is FeedRow.Header -> "header_$title"
 is FeedRow.Notice -> "notice_$id"
 is FeedRow.Highlight -> "highlight_$id"
}
private fun FeedRow.type(): String = when (this) {
 is FeedRow.Header -> "header"
 is FeedRow.Notice -> "notice"
 is FeedRow.Highlight -> "highlight"
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoticeBoard(
 rows: List<FeedRow>,
 onTogglePin: (Int) -> Unit,
 modifier: Modifier = Modifier
) {
 val listState = rememberLazyListState()
 val scope = rememberCoroutineScope()
 val showTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }
 Box(modifier) {
 LazyColumn(state = listState) {
 items(rows, key = { it.key() }, contentType = { it.type() }) { row ->
 when (row) {
 is FeedRow.Header -> Text(
 row.title,
 style = MaterialTheme.typography.titleMedium,
 modifier = Modifier
 .fillMaxWidth()
 .background(MaterialTheme.colorScheme.secondaryContainer)
 .padding(12.dp)
 )
 is FeedRow.Notice -> Card(
 Modifier.animateItem().padding(horizontal = 12.dp, vertical = 4.dp)
 ) {
 Column(Modifier.padding(16.dp)) {
 Text(row.title, style = MaterialTheme.typography.titleSmall)
 Text(row.body, maxLines = 2)
 TextButton(onClick = { onTogglePin(row.id) }) {
 Text(if (row.pinned) "Unpin" else "Pin")
 }
 }
 }
 is FeedRow.Highlight -> Text(row.title, Modifier.padding(16.dp))
 }
 }
 }
 if (showTop) {
 FloatingActionButton(
 onClick = { scope.launch { listState.animateScrollToItem(0) } },
 modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
 ) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Back to top") }
 }
 }
}

Notes: stickyHeader was avoided here to keep the example free of experimental API opt-ins; to make the headers sticky, replace the header branch with a stickyHeader { } call outside the shared items block, as shown in Build 1. The ViewModel builds the rows list from the notices (pinned ones first under a "Pinned" header, then the rest under "All notices") and exposes it as StateFlow<List<FeedRow>>. Because every row has a unique, type-prefixed key, pinning moves a notice between sections with a placement animation instead of a jump. The 200 sample notices come from List(200) { Notice(it, "Notice $it", "Body text ...", false) }. ExperimentalAnimationApi is imported only in case your Compose version still requires an opt-in for animateItem; remove the import if the compiler reports it as unused.

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 39 project”.

3

On github.com click New repository, name it android-lesson-39-lazy-layouts-lists-grids-and-scrolling-per, 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 39 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: How Lazy Lists Work
  • Step-by-Step Build 1: The DSL
  • Step-by-Step Build 2: Keys
  • Step-by-Step Build 3: Content Types
  • Step-by-Step Build 4: Grids and Staggered Grids
  • Step-by-Step Build 5: Controlling and Observing Scroll
  • Step-by-Step Build 6: Nesting and Zero-Size Pitfalls
  • Performance Checklist
  • 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