Open almost any app on your phone: WhatsApp chats, a bank's transaction history, a shop's product catalogue, your music library. Every one of them is built around a scrolling list (or a grid) of items. If you can build lists well, you can build the core of most apps.
In this lesson you will build Affirmations, an app that shows a scrollable list of cards, each with a picture and an encouraging sentence. Then you will turn the list into a grid, and finish by giving your app its own launcher icon.
This lesson covers four items from Unit 3 of Google's official Android Basics with Compose course: Intro to Affirmations app, Add a scrollable list, Change the app icon and Practice: Build a grid.
By the end you will be able to:
- Model list data with a Kotlin data class.
- Store text in
strings.xmland images inres/drawable, and reference them with@StringResand@DrawableRes. - Build a card with
Card,ImageandText. - Display a long list efficiently with
LazyColumnanditems. - Display a grid with
LazyVerticalGridandGridCells. - Create an adaptive launcher icon with Image Asset Studio.
Big idea: a list on screen is just a list in your code, turned into UI one item at a time. Data first, then a function that draws one item, then a lazy container that repeats it.
- Data classes (lesson 11).
data classgives you a tidy way to bundle values together and automatically addstoString,equalsandcopy. - Lists (lesson 14).
listOf(...)creates a read-only list, and you loop over it. - Row, Column and Modifier (lesson 16). Lists are built from the same layout pieces.
- Resources. Android keeps text, images, colours and sizes in the
resfolder, separate from code. This makes translation and theming easy. You will useres/values/strings.xmlandres/drawabletoday.
You could draw a list of 1,000 items by putting 1,000 Texts inside a Column. It would work for ten items and then your app would freeze, because the Column creates every child immediately, even the 990 that are far off screen.
A lazy container is smarter. It only composes the items you can actually see (plus a few just outside the screen), and it throws away or reuses the rest as you scroll. Think of a waiter at a huge buffet who only brings the dishes you are looking at, instead of piling every dish on your table at once.
| Container | Composes | Use it for |
|---|---|---|
Column |
All children, always | A handful of items |
LazyColumn |
Only visible items | Long or unknown-length vertical lists |
LazyRow |
Only visible items | Horizontal lists (carousels) |
LazyVerticalGrid |
Only visible cells | Grids of cards or photos |
Inside a lazy container you do not write a loop. You describe the items with a small DSL (a mini-language built from lambdas), and the container decides when to run your code:
LazyColumn {
items(myList) { item ->
Text(item)
}
}
The lambda after items(myList) is run once per visible item, and item is the element for that row.
Create a new Empty Activity project called Affirmations (package com.example.affirmations).
The model
Right-click the package, choose New > Kotlin Class/File, name it Affirmation, and write a data class:
package com.example.affirmations.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class Affirmation(
@StringRes val stringResourceId: Int,
@DrawableRes val imageResourceId: Int
)
Notice that it stores numbers, not text and pictures. Android generates a unique integer ID for every resource in the class R. R.string.affirmation1 is an Int. The annotations @StringRes and @DrawableRes are safety labels: Android Studio now warns you if you accidentally pass a drawable ID where a string ID is expected, even though both are just Ints.
(You will need a folder named model under the package. Create it with New > Package, or just keep the file in the main package and adjust the package line.)
The text resources
Open app/src/main/res/values/strings.xml and add ten sentences. Here are the first few:
<resources>
<string name="app_name">Affirmations</string>
<string name="affirmation1">I am strong.</string>
<string name="affirmation2">I believe in myself.</string>
<string name="affirmation3">Each day is a new opportunity to grow.</string>
<string name="affirmation4">I can learn anything I set my mind to.</string>
<string name="affirmation5">Small steps still move me forward.</string>
<string name="affirmation6">I am proud of how far I have come.</string>
<string name="affirmation7">I choose to see the good in today.</string>
<string name="affirmation8">Mistakes are proof that I am trying.</string>
<string name="affirmation9">I am becoming a better developer every day.</string>
<string name="affirmation10">Today I will build something worth sharing.</string>
</resources>
Putting text in strings.xml instead of inside code means you can later translate the app (for example into Yoruba or Hausa) by adding a values-yo or values-ha folder, without touching a single line of Kotlin.
The image resources
You need ten pictures in res/drawable. Pick the route that suits you:
- Your own photos or images. Copy ten small JPG or PNG files into
res/drawable. File names must use only lowercase letters, digits and underscores, for exampleimage1.jpg. Keep them small (under 300 KB each) so the app stays light. - No images? Use free built-in icons. Choose File > New > Vector Asset (or right-click
res/drawable, then New > Vector Asset), click the clip-art button, pick an icon, name itimage1, and finish. Repeat for ten icons. These are scalable and tiny.
Whichever you choose, name the ten resources image1 to image10.
The data source
Create Datasource.kt that builds the list:
package com.example.affirmations.data
import com.example.affirmations.R
import com.example.affirmations.model.Affirmation
class Datasource {
fun loadAffirmations(): List<Affirmation> {
return listOf(
Affirmation(R.string.affirmation1, R.drawable.image1),
Affirmation(R.string.affirmation2, R.drawable.image2),
Affirmation(R.string.affirmation3, R.drawable.image3),
Affirmation(R.string.affirmation4, R.drawable.image4),
Affirmation(R.string.affirmation5, R.drawable.image5),
Affirmation(R.string.affirmation6, R.drawable.image6),
Affirmation(R.string.affirmation7, R.drawable.image7),
Affirmation(R.string.affirmation8, R.drawable.image8),
Affirmation(R.string.affirmation9, R.drawable.image9),
Affirmation(R.string.affirmation10, R.drawable.image10)
)
}
}
Why a class with a function instead of a bare list? Later in this course (lessons 30 and 34) the data will come from a repository, a web service or a database, and a function is the natural seam where that change happens.
Always build one item first and preview it before you worry about the list. Open MainActivity.kt and add this composable. Add the imports as Android Studio suggests (Alt + Enter), or copy this block:
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.affirmations.R
import com.example.affirmations.model.Affirmation
@Composable
fun AffirmationCard(affirmation: Affirmation, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column {
Image(
painter = painterResource(affirmation.imageResourceId),
contentDescription = stringResource(affirmation.stringResourceId),
modifier = Modifier
.fillMaxWidth()
.height(194.dp),
contentScale = ContentScale.Crop
)
Text(
text = stringResource(affirmation.stringResourceId),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.headlineSmall
)
}
}
}
@Preview(showBackground = true)
@Composable
fun AffirmationCardPreview() {
AffirmationCard(Affirmation(R.string.affirmation1, R.drawable.image1))
}
What to notice:
Cardis a Material 3 container with rounded corners and elevation.stringResource(id)turns theIntID into the actual text, and it respects the user's language.painterResource(id)loads the drawable.contentDescriptionis read aloud by screen readers, so we reuse the sentence. (A purely decorative picture can usenull; lesson 47 explains when.)ContentScale.Cropfills the box and crops the extra, so differently-shaped pictures look uniform.MaterialTheme.typography.headlineSmalltakes its size and weight from the theme, so you can restyle the whole app later in one place (lesson 22).
Open the Preview pane. You should see one card: a picture on top, the sentence beneath.
Now repeat that card for every affirmation.
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 com.example.affirmations.data.Datasource
@Composable
fun AffirmationList(
affirmationList: List<Affirmation>,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(affirmationList, key = { it.stringResourceId }) { affirmation ->
AffirmationCard(affirmation = affirmation)
}
}
}
@Composable
fun AffirmationsApp(modifier: Modifier = Modifier) {
AffirmationList(
affirmationList = Datasource().loadAffirmations(),
modifier = modifier
)
}
Then call it from setContent. The Empty Activity template wraps content in a Scaffold so that your content does not hide behind the system bars. Use the padding it gives you:
setContent {
AffirmationsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AffirmationsApp(modifier = Modifier.padding(innerPadding))
}
}
}
(Scaffold needs import androidx.compose.material3.Scaffold and fillMaxSize needs import androidx.compose.foundation.layout.fillMaxSize.)
Understanding each piece
contentPaddingadds space inside the scrolling area, so the first card is not glued to the top and the last card can scroll clear of the bottom edge.Arrangement.spacedBy(8.dp)puts an 8 dp gap between neighbouring items, so you never need margins on each card.items(list) { ... }runs the lambda for each visible element.key = { it.stringResourceId }gives each item a stable identity. When a list changes (an item is added, removed or moved), keys let Compose track which item is which instead of rebuilding everything. Keys must be unique and stable, and the string resource ID is both. Lesson 39 covers keys in depth.
Run the app and scroll. Ten cards, smooth scrolling.
Experiment: change
tentoten thousand. Make a loop that repeatsloadAffirmations()a thousand times (without keys, because duplicates would clash). The app stays smooth, because only a handful of items exist at any moment. That is the entire point of a lazy list.
A grid lays items out in columns. The container takes a columns rule that says how many:
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@Composable
fun AffirmationGrid(
affirmationList: List<Affirmation>,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = modifier,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(affirmationList) { affirmation ->
AffirmationCard(affirmation = affirmation)
}
}
}
There are two ways to say how many columns:
| Rule | Meaning | Example |
|---|---|---|
GridCells.Fixed(2) |
Always exactly two columns. | A catalogue that must look the same everywhere. |
GridCells.Adaptive(minSize = 160.dp) |
As many columns as fit, each at least 160 dp wide. | A grid that shows two columns on a phone and five on a tablet. |
Adaptive is usually the better choice, because it quietly adjusts when the user rotates the phone or opens your app on a large screen. Note that items for the grid comes from a different import (androidx.compose.foundation.lazy.grid.items) than the items for LazyColumn. Importing the wrong one is the most common error with grids.
The default icon is the green Android robot. Every real app needs its own.
Using Image Asset Studio
- In the Android view, right-click the
resfolder. - Choose New > Image Asset.
- For Icon Type choose Launcher Icons (Adaptive and Legacy).
- On the Foreground Layer tab choose a source: Image (your own logo), Clip Art (a built-in icon) or Text. Use Resize to keep the artwork inside the safe zone, the central circle.
- On the Background Layer tab pick a colour or image.
- Check the preview in different shapes (circle, square, squircle), then click Next and Finish.
What "adaptive" means
Since Android 8, an icon is made of two layers, a foreground and a background. Each phone maker applies its own mask (circle, rounded square, and so on) and the system can move the layers slightly for parallax effects. If you put important artwork near the edges it gets cropped. Keeping it inside the safe zone makes the icon look right on every phone.
Newer Android versions also support a themed icon that takes on the user's wallpaper colours. For that, provide a single-colour monochrome layer. Image Asset Studio can generate one from your foreground.
Finally, check AndroidManifest.xml. The <application> tag points at your icon through android:icon="@mipmap/ic_launcher" and android:roundIcon="@mipmap/ic_launcher_round". Run the app and look at the launcher: your icon replaces the robot.
Exercise 1: Build a grid of topics (the Practice: Build a grid exercise)
Scenario: A school wants an app that shows the subjects it teaches as a two-column grid of cards.
Requirements:
- Create a data class
Topic(@StringRes val name: Int, val courseCount: Int, @DrawableRes val image: Int). - Make a list of at least eight topics (for example Mathematics, English, Biology, Chemistry, Physics, Economics, Government, Computer Science).
- Each card is a
Cardwith the picture on the left (68 dp square) and the name plus the course count on the right. Use aRow. - Show exactly two columns using
GridCells.Fixed(2).
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
data class Topic(
@StringRes val name: Int,
val courseCount: Int,
@DrawableRes val image: Int
)
@Composable
fun TopicCard(topic: Topic, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(topic.image),
contentDescription = null,
modifier = Modifier.size(68.dp),
contentScale = ContentScale.Crop
)
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
text = stringResource(topic.name),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${topic.courseCount} courses",
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
@Composable
fun TopicGrid(topics: List<Topic>, modifier: Modifier = Modifier) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(topics) { topic -> TopicCard(topic) }
}
}
Notes: contentDescription = null is correct here because the text beside the picture already says everything, so a screen reader would only repeat itself. Alignment.CenterVertically centres the text against the square picture.
Exercise 2: Click to select
Make the Affirmation list react to taps. When the user taps a card, show a Text banner at the top reading You chose: ... with the sentence. Use rememberSaveable to remember the chosen ID (lesson 17) and the clickable modifier on the card.
import androidx.compose.foundation.clickable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@Composable
fun AffirmationsApp(modifier: Modifier = Modifier) {
var chosenId by rememberSaveable { mutableStateOf<Int?>(null) }
Column(modifier = modifier) {
if (chosenId != null) {
Text(
text = "You chose: " + stringResource(chosenId!!),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium
)
}
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(Datasource().loadAffirmations(), key = { it.stringResourceId }) { affirmation ->
AffirmationCard(
affirmation = affirmation,
modifier = Modifier.clickable { chosenId = affirmation.stringResourceId }
)
}
}
}
}
Why it works: the click lambda changes state, which triggers recomposition, and the banner reads the state. Same loop as the dice roller. Production code would avoid !! by using chosenId?.let { ... }, which you can try as an improvement.
Change GridCells.Adaptive(minSize = 160.dp) to GridCells.Fixed(3) and compare how the grid looks on a phone and in landscape. Which rule would you pick for an app that must look good on tablets too, and why?
Mistake #1: A Column for a long list. What happens: every item is composed immediately, memory climbs, scrolling stutters or the list does not scroll at all unless you add verticalScroll. The fix: LazyColumn.
Mistake #2: Nested vertical scrolling. The wrong code: putting a LazyColumn inside a Column that has verticalScroll, or inside another vertical LazyColumn. What happens: a crash saying vertically scrollable components were measured with an infinite maximum height. The fix: one scrolling container per direction. Put extra items inside the same LazyColumn as separate item { } entries.
Mistake #3: Wrong items import. Symptom: red underline on items inside a grid. The fix: for LazyVerticalGrid, import androidx.compose.foundation.lazy.grid.items; for LazyColumn, androidx.compose.foundation.lazy.items.
Mistake #4: Duplicate or changing keys. What happens: a crash complaining that the key was already used. The fix: keys must be unique and stable. Never use the item's position in the list as the key if the list can change order.
Mistake #5: Wrong resource folder or file names. Symptom: R.drawable.Image1 does not exist. The fix: resource names are lowercase letters, digits and underscores only. Image1.jpg is rejected, image1.jpg works.
Mistake #6: Huge images. What happens: the app is slow and uses a lot of memory because full-size camera photos are decoded. The fix: resize images before adding them, or use vector assets. Lesson 31 shows how to load remote images efficiently.
Mistake #7: Missing contentDescription thinking. The fix: give meaningful pictures a description and pass null only for decorative ones.
Mistake #8: Hard-coded text. What happens: Android Studio warns Hardcoded string. The fix: put text in strings.xml and read it with stringResource.
Question 1. Why is LazyColumn better than Column for a long list?
LazyColumn only composes (and keeps) the items that are visible, plus a few near the screen edge. Column composes all children at once, which wastes memory and time for long lists.
Question 2. What is the purpose of @StringRes and @DrawableRes?
They are annotations that mark an Int as a resource ID of a specific type. Resource IDs are plain integers, so the annotations let Android Studio warn you when you pass the wrong kind of ID.
Question 3. What is the difference between GridCells.Fixed(2) and GridCells.Adaptive(160.dp)?
Fixed(2) always creates exactly two columns. Adaptive(160.dp) creates as many columns as fit when each column is at least 160 dp wide, so the count grows on wider screens.
Question 4. Why give items a key?
A key gives every item a stable identity so Compose can match old and new items when the list changes, preserving item state and animating or reusing correctly. Keys must be unique and stable.
Question 5. What are the two layers of an adaptive icon, and why keep artwork in the safe zone?
A foreground and a background layer. Phone makers apply different masks (circle, square, squircle), which crop the edges, so important artwork must stay inside the central safe zone.
Go deeper: official resources
- Android Basics with Compose, Unit 3, Pathway 2: Build a scrollable list: the official pathway for this lesson, including the Affirmations app and the grid practice.
- Android Basics with Compose course page: all eight units.
- Jetpack Compose for Android developers, pathway 2: Layouts, theming and animation: lazy layouts are covered in more depth, which you will meet in lesson 39.
Project goal: Build a scrollable app of ten Yoruba proverbs. Each card shows a decorative icon, the proverb in Yoruba and its English meaning. It teaches the same pattern you will reuse for products, contacts and messages.
Requirements:
- Create
data class Proverb(@StringRes val yoruba: Int, @StringRes val english: Int, @DrawableRes val icon: Int). - Add ten proverbs and their English meanings to
strings.xml, using resource namesproverb1toproverb10andmeaning1tomeaning10. (Use only proverbs you are confident about, or write your own sayings in any language.) - Show them in a
LazyColumnwith spacing and padding. - Each card has the Yoruba text in
titleMediumstyle and the meaning inbodyMediumstyle underneath. - Add a toggle button in the top bar that switches the layout between the list and a two-column grid.
- Change the launcher icon with Image Asset Studio.
Stretch goal: Add values-yo translations for the app name.
package com.example.proverbs
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
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.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.res.stringResource
import androidx.compose.ui.unit.dp
data class Proverb(
@StringRes val yoruba: Int,
@StringRes val english: Int,
@DrawableRes val icon: Int
)
val proverbs = listOf(
Proverb(R.string.proverb1, R.string.meaning1, R.drawable.icon1),
Proverb(R.string.proverb2, R.string.meaning2, R.drawable.icon2),
Proverb(R.string.proverb3, R.string.meaning3, R.drawable.icon3)
// ... continue to ten
)
@Composable
fun ProverbCard(proverb: Proverb, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(proverb.yoruba),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(proverb.english),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
@Composable
fun ProverbsApp(modifier: Modifier = Modifier) {
var asGrid by rememberSaveable { mutableStateOf(false) }
Column(modifier = modifier) {
Button(
onClick = { asGrid = !asGrid },
modifier = Modifier.padding(8.dp)
) {
Text(if (asGrid) "Show as list" else "Show as grid")
}
if (asGrid) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
gridItems(proverbs, key = { it.yoruba }) { ProverbCard(it) }
}
} else {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(proverbs, key = { it.yoruba }) { ProverbCard(it) }
}
}
}
}
Notes on the solution: both items functions are imported, the grid one under the alias gridItems, which is how you can use both in one file without a clash. The toggle button flips a boolean in state, and Compose swaps the container. Extend the proverbs list to ten entries and add the matching strings.xml lines. The proverbs themselves are yours to choose, and checking meanings with an elder or a reliable source is part of the exercise.
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 21 project”.
On github.com click New repository, name it android-lesson-21-scrollable-lists-and-grids-the-affirmation, 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 21 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Why "Lazy"?
- Step-by-Step Build 1: The Data
- Step-by-Step Build 2: One Card
- Step-by-Step Build 3: The Scrollable List
- Step-by-Step Build 4: A Grid with LazyVerticalGrid
- Changing the App Icon
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check