Text is easy. Pictures are where networking gets interesting: a photo can be a few megabytes, it must be downloaded without freezing the screen, shrunk to fit a small card, kept in a cache so the next visit is instant, and replaced by a friendly placeholder when the network fails. Writing all that yourself takes weeks. A library called Coil does it in one composable.
In this lesson you will use Coil's AsyncImage to show remote images, handle loading and error states, understand caching and memory, and build the two apps that close Pathway 2 of Unit 5: the Amphibians practice app and the Bookshelf project.
This lesson covers three items from the official Android Basics with Compose course: Load and display images from the internet, Practice: Build amphibians app and Project: Create a Bookshelf app.
By the end you will be able to:
- Add Coil 3 to a project and explain why it needs a separate network artifact.
- Display a remote image with
AsyncImage, with a placeholder, an error image and a content description. - Use
ImageRequestfor crossfade and other options. - Explain memory and disk caching, and image sizing.
- Build a grid of remote images with loading and error states.
- Plan a Bookshelf app from a public book-search API, taking care with API keys, terms and data.
Big idea: an image on screen is a URL in your data, plus a library that turns it into pixels at the right size.
- Retrofit,
@Serializable, sealed UI state (lesson 29). - Repositories and the app container (lesson 30).
- Lazy grids (lesson 21).
contentDescriptionandContentScale(lesson 21).- The INTERNET permission (lesson 29). Coil downloads images, so you still need it.
When you ask Coil to display https://example.com/photo.jpg in a 120 dp card, it:
- Checks the memory cache: has this exact image already been decoded at this size? If yes, show it immediately.
- Otherwise checks the disk cache: has the file been downloaded before? If yes, decode it.
- Otherwise downloads it over the network, on a background thread.
- Decodes it to a bitmap, scaled down to the size of the target, not the full resolution.
- Caches it and shows it.
- If you leave the screen while it loads, the work is cancelled.
AsyncImage(url)
|
v
[memory cache?] --hit--> show
| miss
[disk cache?] --hit--> decode --> show
| miss
[network download] --> decode (downsampled) --> cache --> show
| fails
show error image
Why size matters
A 4,000 x 3,000 pixel photo uses about 48 MB of memory when decoded (4,000 x 3,000 x 4 bytes). Ten of them in a list would crash a budget phone with little RAM. Coil decodes images at roughly the size they will be shown, so a 120 dp card gets a small bitmap. That is a major reason to use a library instead of loading bitmaps by hand.
Coil 3 package changes
Coil 3 is a rewrite that supports Kotlin Multiplatform. If you read older tutorials you will see Coil 2 code: be aware of the differences.
| Coil 2 | Coil 3 | |
|---|---|---|
| Group ID | io.coil-kt |
io.coil-kt.coil3 |
| Compose artifact | io.coil-kt:coil-compose |
io.coil-kt.coil3:coil-compose |
Package for AsyncImage |
coil.compose |
coil3.compose |
| Network loading | Built in | Separate artifact, for example coil-network-okhttp |
The last row is the one that trips people up: in Coil 3, the base library no longer includes networking, so a URL will fail to load unless you add a network artifact.
Look up the latest stable Coil 3 version on the Coil releases page (https://github.com/coil-kt/coil/releases) and add:
[versions]
coil = "<latest stable 3.x>"
[libraries]
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}
Sync, and make sure the INTERNET permission is in your manifest. The two artifacts must use the same version.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.unit.dp
import coil3.compose.AsyncImage
@Composable
fun RemotePhoto(url: String, caption: String) {
Column {
AsyncImage(
model = url,
contentDescription = caption,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Text(caption)
}
}
modelis what to load. AStringURL is the simplest. It can also be aUri, aFile, a drawable resource ID or anImageRequest.contentDescriptionis read by screen readers; describe the picture meaningfully (lesson 47).contentScaleworks likeImageView.scaleType:Cropfills and trims,Fitshows the whole picture with gaps.- Always give the image a size (a fixed
heightoraspectRatio). Without one, the layout jumps when the image arrives.
Placeholders and error images
While loading, show something; if it fails, show something else. Pass painters:
import androidx.compose.ui.res.painterResource
import coil3.compose.AsyncImage
AsyncImage(
model = url,
contentDescription = caption,
placeholder = painterResource(R.drawable.ic_loading),
error = painterResource(R.drawable.ic_broken_image),
fallback = painterResource(R.drawable.ic_no_image),
contentScale = ContentScale.Crop,
modifier = Modifier.height(200.dp)
)
| Parameter | When it is shown |
|---|---|
placeholder |
While the request is loading |
error |
When the request fails (no network, 404, undecodable) |
fallback |
When model is null |
Create ic_loading, ic_broken_image and ic_no_image as vector assets (File > New > Vector Asset, lesson 21), for example using the built-in "image" and "broken image" icons.
Controlling the request
For more options, build an ImageRequest:
import androidx.compose.ui.platform.LocalContext
import coil3.request.ImageRequest
import coil3.request.crossfade
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = caption,
modifier = Modifier.height(200.dp)
)
crossfade(true) fades the image in. In Coil 3 the crossfade builder function is an extension in coil3.request, so it needs its own import; if the compiler cannot find a builder method you expect, check the current Coil documentation, because the library is actively developed and some names have moved between 2.x and 3.x.
Observing loading state
AsyncImage also accepts onLoading, onSuccess and onError callbacks, which is useful for logging or for showing a progress indicator:
AsyncImage(
model = url,
contentDescription = caption,
onError = { error -> Log.e("Coil", "Failed: ${error.result.throwable}") }
)
For fully custom loading UI there is also SubcomposeAsyncImage with loading and error slots that accept composables. It is more flexible but heavier, because it composes the slots separately, so avoid it in big grids; simple painters are cheaper.
The official Practice: Build amphibians app asks you to build an app that fetches a list of amphibians (name, type, description, image URL) from a service and shows them in a scrolling list of cards. We will build the same architecture using the pieces from lessons 29 and 30. The data shape:
[
{
"name": "Great Basin Spadefoot",
"type": "Toad",
"description": "This toad spends most of its life underground.",
"img_src": "https://example.com/images/spadefoot.png"
}
]
The Android Developers course uses a small sample server for this. Servers for training courses can change or disappear, so open the practice page in the official pathway for the current address, or point the app at any JSON service of your own with the same shape (even a static amphibians.json file hosted on GitHub Pages works, and costs nothing).
Model
package com.example.amphibians.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Amphibian(
val name: String,
val type: String,
val description: String,
@SerialName("img_src") val imgSrc: String
)
Service, repository, container
These follow lesson 30 exactly:
interface AmphibiansApiService {
@GET("amphibians")
suspend fun getAmphibians(): List<Amphibian>
}
interface AmphibiansRepository {
suspend fun getAmphibians(): List<Amphibian>
}
class NetworkAmphibiansRepository(
private val api: AmphibiansApiService
) : AmphibiansRepository {
override suspend fun getAmphibians(): List<Amphibian> = api.getAmphibians()
}
with retrofit2.http.GET imported and a container that builds Retrofit with the base URL you chose (ending in /).
ViewModel and state
sealed interface AmphibiansUiState {
data class Success(val amphibians: List<Amphibian>) : AmphibiansUiState
data object Error : AmphibiansUiState
data object Loading : AmphibiansUiState
}
The ViewModel is the same as PostsViewModel, with the new repository type and factory.
The screen
package com.example.amphibians.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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.unit.dp
import coil3.compose.AsyncImage
import com.example.amphibians.R
import com.example.amphibians.model.Amphibian
@Composable
fun AmphibianCard(amphibian: Amphibian, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column {
Text(
text = "${amphibian.name} (${amphibian.type})",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
AsyncImage(
model = amphibian.imgSrc,
contentDescription = amphibian.name,
placeholder = painterResource(R.drawable.ic_loading),
error = painterResource(R.drawable.ic_broken_image),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(194.dp)
)
Text(
text = amphibian.description,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
@Composable
fun AmphibiansList(amphibians: List<Amphibian>, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(amphibians, key = { it.name }) { AmphibianCard(it) }
}
}
The when over the sealed state, the retry button and the ViewModel factory are the same as in lessons 29 and 30. Resist copy-pasting without thinking: re-type the ViewModel yourself and notice each line you recognise.
Notice that key = { it.name } assumes names are unique. If the service could return two items with the same name, the app would crash on scroll. Choose a key you know is unique, or omit the key.
Run it with the device online, then in airplane mode: the first run shows images; the second shows the error state for the list (if the JSON cannot load) or the broken-image icon for images that fail (if the JSON came from cache but the pictures did not).
By default Coil keeps a memory cache (a fraction of the app's available memory) and a disk cache (a limited size in the app's cache directory), and respects the server's HTTP caching headers. That means:
- Scrolling back to a card you have seen is instant.
- A second launch of the app reuses downloaded files, saving the user's mobile data, which is a real cost for many users.
- The system may delete cache files when storage is low. A cache is a convenience, never a place for important data.
You can customise this with a global ImageLoader, for example to set the disk cache size or add logging, by creating the loader in your Application class and passing it to Coil's singleton setup. The defaults are good for most apps, so start there. Check the Coil documentation for the current API, as setup differs between Coil 2 and 3.
Memory tips:
- Give
AsyncImagea bounded size so Coil can downsample. - Avoid putting huge full-resolution pictures in
LazyColumnitems if the server offers thumbnails. Many APIs let you request a smaller version, for example via a size parameter in the URL. - Test on a low-RAM device or emulator profile before shipping.
The unit's project is Bookshelf. The brief: a user searches for books by keyword and sees a grid of cover images; tapping one shows details (title, authors, description, link). The data comes from a public book-search API.
Plan it properly before coding:
| Decision | Guidance |
|---|---|
| Which API? | Choose a public API with a free tier whose terms allow your use. The course uses the Google Books API. Read its documentation and terms yourself before you start, and never guess endpoints or field names. |
| Authentication | Some endpoints need an API key. Do not commit keys to a public repository, and do not assume a key inside an APK is secret. For a learning project use a key restricted to your app, store it in local.properties and read it into BuildConfig, and understand that a determined user can still extract it. |
| Architecture | Service, repository, container, ViewModel, sealed UI state, exactly as lesson 30. |
| Screens | Search screen with a text field and a LazyVerticalGrid of covers; a detail screen (navigation from lesson 26). |
| Covers over HTTPS | Some APIs return http:// image links; they will not load, because cleartext traffic is blocked. Convert to https:// where the host supports it. |
| Empty and error states | No results, no network, rate limited (HTTP 429). Handle each. |
| Tests | Fake repository, ViewModel tests, one UI test. |
Treat cover images and book text as third-party content: show attribution where the API requires it.
Exercise 1: Grid with shimmer-free placeholders
Change the Amphibians list to a two-column LazyVerticalGrid of square cards showing only the image and the name. Use AsyncImage with ContentScale.Crop and aspectRatio(1f). Provide a placeholder and an error painter.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
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.Text
import androidx.compose.ui.Alignment
@Composable
fun AmphibiansGrid(amphibians: List<Amphibian>, 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(amphibians) { a ->
Card {
Box {
AsyncImage(
model = a.imgSrc,
contentDescription = a.name,
placeholder = painterResource(R.drawable.ic_loading),
error = painterResource(R.drawable.ic_broken_image),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
)
Text(
a.name,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp)
)
}
}
}
}
}
aspectRatio(1f) fixes the height from the width so the grid does not jump as images load. Text laid directly over a photo can be hard to read; a real design adds a scrim (a semi-transparent background) behind the text for contrast (lesson 46).
Exercise 2: Handle HTTP image links
A service returns image URLs starting with http://. Images silently fail to load. Explain why and write a small helper that upgrades the link, then discuss the risk.
Android blocks cleartext (unencrypted) HTTP traffic by default for modern apps, so the request fails and the error painter appears. A helper can rewrite the scheme:
fun String.toHttps(): String =
if (startsWith("http://")) replaceFirst("http://", "https://") else this
and use model = amphibian.imgSrc.toHttps(). The risk: not every host serves the same resource over HTTPS, so some links will still fail and you should show the error painter. Do not disable the cleartext protection for the whole app to work around one server; that weakens security for every request (anyone on the network can read or alter the traffic).
Turn on airplane mode after your app has loaded the list once, force-stop and reopen it, then scroll. Which images still show, and why? Write down what you learned about the memory and disk caches.
Mistake #1: Coil 2 imports with Coil 3 artifacts. Symptom: Unresolved reference: coil.compose. The fix: Coil 3 uses coil3.compose.AsyncImage; make sure the dependency group is io.coil-kt.coil3.
Mistake #2: No network artifact. Symptom: images stay on the placeholder or error forever and Logcat mentions no fetcher for the URL. The fix: add coil-network-okhttp with the same version as coil-compose.
Mistake #3: Missing INTERNET permission. Symptom: every image fails. The fix: add the permission (lesson 29).
Mistake #4: Layout jumps. What happens: images pop in and push content down. The fix: give AsyncImage a fixed height or aspectRatio.
Mistake #5: Empty or missing contentDescription. What happens: screen-reader users get nothing. The fix: a meaningful description, or null only for purely decorative images.
Mistake #6: Duplicate keys in items. Symptom: a crash on scroll saying a key was already used. The fix: a unique key, or none.
Mistake #7: http:// URLs. The fix: use https (Exercise 2), never disable cleartext protection globally.
Mistake #8: Using SubcomposeAsyncImage everywhere. What happens: slow scrolling in big grids. The fix: use AsyncImage with painters.
Mistake #9: Committing API keys. What happens: a key in a public repo is found by bots in minutes. The fix: keep it out of source control, restrict it, and prefer a server for anything truly secret.
Question 1. What does an image-loading library do that you would not want to write yourself?
Background downloading, decoding at the target size, memory and disk caching, cancelling requests when the UI leaves the screen, and showing placeholders and error states.
Question 2. Why does Coil 3 need two dependencies to load a URL?
The Compose artifact displays images but networking was split into separate artifacts; coil-network-okhttp supplies the HTTP fetcher. Without it, URLs cannot be loaded.
Question 3. When are placeholder, error and fallback used?
placeholder while loading, error when loading fails, and fallback when the model is null.
Question 4. Why give AsyncImage a fixed size or aspect ratio?
So the layout does not shift when the image finishes loading, and so Coil can decode at the right size, saving memory.
Question 5. Why should an API key in an app not be considered secret?
Anything inside the APK can be extracted by a determined person. Real secrets belong on a server; keys that must be in the app should be restricted and kept out of source control.
Go deeper: official resources
- Android Basics with Compose, Unit 5, Pathway 2: Load and display images, the Amphibians practice and the Bookshelf project.
- Android Basics with Compose course page
- Coil releases
- Data layer guide
Project goal: A photo gallery that loads image URLs from a JSON file you host yourself (for example on GitHub Pages) and shows them in an adaptive grid, with loading, error and empty states.
Requirements:
- A JSON file with at least eight entries:
title,imageUrl,credit. Use images you own or that are licensed for reuse. - Retrofit service, repository, container and ViewModel as in lessons 29 and 30.
- A
LazyVerticalGridwithGridCells.Adaptive(minSize = 150.dp); each cell is anAsyncImagewith placeholder and error painters and a meaningful description. - A sealed UI state with loading, success, empty and error; a retry button.
- Tapping an image opens a full-screen view (a dialog or a second destination) showing the credit.
- A fake repository and a ViewModel test for the empty and error states.
Stretch goal: show Image placeholders with a pulsing alpha using rememberInfiniteTransition (lesson 41).
package com.example.gallery.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.example.gallery.R
import com.example.gallery.model.GalleryItem
sealed interface GalleryUiState {
data object Loading : GalleryUiState
data object Empty : GalleryUiState
data class Success(val items: List<GalleryItem>) : GalleryUiState
data class Error(val message: String) : GalleryUiState
}
@Composable
fun GalleryScreen(state: GalleryUiState, onRetry: () -> Unit, modifier: Modifier = Modifier) {
when (state) {
GalleryUiState.Loading -> Column(
modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { CircularProgressIndicator() }
GalleryUiState.Empty -> Column(
modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { Text("No photos yet.") }
is GalleryUiState.Error -> Column(
modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(state.message)
Button(onClick = onRetry, modifier = Modifier.padding(top = 12.dp)) { Text("Retry") }
}
is GalleryUiState.Success -> GalleryGrid(state.items, modifier)
}
}
@Composable
fun GalleryGrid(items: List<GalleryItem>, modifier: Modifier = Modifier) {
var opened by rememberSaveable { mutableStateOf<String?>(null) } // image URL
val openedItem = items.firstOrNull { it.imageUrl == opened }
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
modifier = modifier,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items, key = { it.imageUrl }) { item ->
AsyncImage(
model = item.imageUrl,
contentDescription = item.title,
placeholder = painterResource(R.drawable.ic_loading),
error = painterResource(R.drawable.ic_broken_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable { opened = item.imageUrl }
)
}
}
if (openedItem != null) {
AlertDialog(
onDismissRequest = { opened = null },
confirmButton = { TextButton(onClick = { opened = null }) { Text("Close") } },
title = { Text(openedItem.title) },
text = {
Column {
AsyncImage(
model = openedItem.imageUrl,
contentDescription = openedItem.title,
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
)
Text("Credit: ${openedItem.credit}", modifier = Modifier.padding(top = 8.dp))
}
}
)
}
}
Notes: the ViewModel maps an empty successful response to Empty and failures to Error, with two ViewModel tests using the fake repository: emptyList() gives Empty; a thrown IOException gives Error. The selected image is stored by URL in rememberSaveable (a String fits in a Bundle), which survives rotation. The dialog uses AsyncImage again, so the image is served from Coil's cache and appears instantly.
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 31 project”.
On github.com click New repository, name it android-lesson-31-load-and-display-images-from-the-internet, 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 31 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What an Image Loader Does
- Step-by-Step Build 1: Add Coil
- Step-by-Step Build 2: Your First `AsyncImage`
- Step-by-Step Build 3: The Amphibians App
- Caching, Memory and Data Use
- Project Planning: The Bookshelf App
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check