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,LazyHorizontalGridand staggered grids. - Build lists from mixed content with
item { },items { },itemsIndexedandstickyHeader. - 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.
- Basic
LazyColumnandLazyVerticalGrid(lesson 21). - Recomposition and the three phases (lesson 37).
- State hoisting and immutable data classes (lesson 24).
- Layouts and modifiers (lesson 38).
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.
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:
- 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. - 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.
- The lazy DSL (
LazyListScope) is not composable code. InsideLazyColumn { ... }you callitem { }anditems { }; you cannot callText(...)directly there. The lambda you pass toitem { }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) |
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:
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.)
The problem
Imagine a list of tasks, each with a checkbox whose state is held inside the row with remember:
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
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.
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:
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.
You met LazyVerticalGrid with GridCells.Fixed and GridCells.Adaptive in lesson 21. Two more capabilities:
Items that span columns
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:
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.
LazyListState holds the scroll position and lets you read it or move it:
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 usesrememberSaveableinternally so the scroll position survives rotation.animateScrollToItem(index)andscrollToItem(index)are suspend functions: launch them from a coroutine scope (rememberCoroutineScope, lesson 28).derivedStateOfis the key trick:firstVisibleItemIndexchanges on almost every scroll frame, butshowScrollToTopchanges only when the answer flips between true and false. Wrapping the calculation inderivedStateOfmakes the composable recompose only when the result changes, not on every scroll pixel. ReadinglistState.firstVisibleItemIndex > 0directly 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:
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
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.
Never nest same-direction scrolling
// 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:
- One lazy container. Put the header as the first
item { }of theLazyColumn. - Give the inner list a fixed or bounded height (
Modifier.height(300.dp)), when a small inner scroller is intentional. - For a short, known-size list, use a plain
Column(no laziness) inside the scrollingColumn.
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.
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.
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.
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.
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.
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.
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.
// 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
}
}
}
// 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.
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.
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.
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
- Jetpack Compose for Android developers, Pathway 2: Layouts, theming and animation
- Jetpack Compose course page
- Jetpack Compose performance codelab
- Jetpack Compose layouts codelab
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:
- A sealed
FeedRowtype withHeader(title),Notice(id, title, body, pinned), andHighlight(id, title)rows. - One
LazyColumnwith akeyand acontentTypeper row type; headers are sticky. - An Add notice button that inserts a new notice at the top of its section with
Modifier.animateItem(). - A Pin toggle on each notice stored in the data (ViewModel), which moves pinned notices to a "Pinned" section.
- A scroll-to-top FAB that only appears after the first screenful, using
derivedStateOf. - 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.
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.
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.
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”.
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.
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.
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