Load Images from the Internet in Jetpack Compose with Coil
Show real photos from URLs in your Android app. Learn Coil 3 and AsyncImage in Jetpack Compose with placeholders, error images and rounded crops.
Emojis are fun, but real apps show real photos. Food apps show dishes, shop apps show products, and social apps show profile pictures. These photos almost never live inside the app. They come from the internet. In this part you will learn how to load images from the internet in Jetpack Compose with Coil. We will add a photo to every fruit in our app. If you missed the last lesson, read Part 11: Material 3 Themes, Colors and Dark Mode first.
Why Load Images from a URL?
Imagine a shop app with 5,000 products. If you put every photo inside the app, the app would be huge, maybe 2 GB! And every time the shop adds a new product, you would need to publish a new app update.
So apps keep photos on a server. The app only stores a short URL (a web address) like https://example.com/mango.jpg. When the screen opens, the app downloads the photo and shows it.
Doing this by hand is a lot of work. You need to download, decode, resize, cache (save for later) and handle errors. A library does all of this for you. The most popular one for Jetpack Compose is Coil.
What Is Coil?
Coil stands for Coroutine Image Loader. It is:
- Made for Kotlin and Jetpack Compose.
- Fast, because it saves photos in memory and on disk. The second time, the photo shows instantly.
- Easy. One composable,
AsyncImage, does almost everything.
We will use Coil 3, the current version.
Step 1: Add Coil to Your Project
Open app/build.gradle.kts (the one inside the app folder, not the one in the project root). Find the dependencies { ... } block and add these two lines:
dependencies {
// ... your other dependencies
implementation("io.coil-kt.coil3:coil-compose:3.6.3")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.6.3")
}
Let's understand it:
coil-composegives us theAsyncImagecomposable.coil-network-okhttplets Coil download from the internet. In Coil 3 this is a separate piece. If you forget it, images from URLs will not load, and you will not see a clear error.- Both lines must use the same version number.
Now click Sync Now at the top of the editor. Wait until the sync finishes.
Tip: Android Studio may show a yellow line and suggest a newer version. That is fine. Accept the newer version, but keep both Coil lines on the same number.
Step 2: Add the Internet Permission
Android protects users. An app cannot use the internet unless it asks for permission in a special file called the manifest.
- In the Project panel, open
app → manifests → AndroidManifest.xml. - Find the line that starts with
<application. - Above it (but inside
<manifest>), add the permission line.
It should look like this:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
... keep everything else the same ... >
Let's understand it:
<uses-permission ... INTERNET />tells Android "this app needs the internet".- The user does not see a popup for this permission. It is given automatically when the app is installed.
- Only add the one line. Don't delete anything else in the file.
Step 3: Add an Image URL to Each Fruit
Our Fruit class needs a new field: the photo address. Update the data class and the list:
data class Fruit(
val name: String,
val emoji: String,
val price: Int,
val imageUrl: String
)
val fruits = listOf(
Fruit("Mango", "🥭", 120, "https://picsum.photos/seed/mango/400/300"),
Fruit("Banana", "🍌", 60, "https://picsum.photos/seed/banana/400/300"),
Fruit("Apple", "🍎", 250, "https://picsum.photos/seed/apple/400/300"),
Fruit("Watermelon", "🍉", 80, "https://picsum.photos/seed/watermelon/400/300"),
Fruit("Grapes", "🍇", 300, "https://picsum.photos/seed/grapes/400/300"),
Fruit("Pineapple", "🍍", 90, "https://picsum.photos/seed/pineapple/400/300"),
Fruit("Strawberry", "🍓", 450, "https://picsum.photos/seed/strawberry/400/300"),
Fruit("Orange", "🍊", 180, "https://picsum.photos/seed/orange/400/300"),
Fruit("Coconut", "🥥", 70, "https://picsum.photos/seed/coconut/400/300"),
Fruit("Lemon", "🍋", 40, "https://picsum.photos/seed/lemon/400/300")
)
Let's understand it:
imageUrl: Stringis the new field. Every fruit now has a web address for its photo.- We use Lorem Picsum (picsum.photos), a free website for demo photos. The photos are random nature and city pictures, not real fruits. That's OK. We only want to practice loading images.
- The word after
/seed/(likemango) makes sure you get the same photo every time. 400/300means 400 pixels wide and 300 pixels tall.
Tip: Later, when you have a real server or API, just replace these URLs with real fruit photo URLs. Your Compose code does not change at all.
Did you add a preview with Fruit("Mango", "🥭", 120) in an earlier part? Now it needs a fourth value. Add a URL there too, or Android Studio will show a red error.
Step 4: Show a Photo in FruitRow
Now let's replace the emoji with a real photo. Here is the full FruitRow:
@Composable
fun FruitRow(fruit: Fruit, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = fruit.imageUrl,
contentDescription = "Photo of ${fruit.name}",
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.ic_launcher_background),
error = painterResource(R.drawable.ic_launcher_foreground),
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(12.dp))
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "${fruit.emoji} ${fruit.name}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "৳${fruit.price} per kg",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
Let's understand it:
AsyncImageis Coil's composable. "Async" means it loads in the background. Your app does not freeze while the photo downloads.model = fruit.imageUrl– what to load. Here it is a URL.contentDescription– a short text for blind users who use a screen reader. Always write something useful.contentScale = ContentScale.Crop– fill the whole box and cut off the extra edges. Without it, the photo may look small with empty space around it.placeholder– shown while the photo is loading. We use the green background image that the template already made.error– shown if the photo fails (no internet, wrong URL). We use the Android robot image from the template..size(64.dp)makes a 64 by 64 square..clip(RoundedCornerShape(12.dp))gives the photo soft rounded corners.
If you see red words, click on them and press Alt+Enter. Tricky imports:
coil3.compose.AsyncImage(note the 3 incoil3)androidx.compose.ui.layout.ContentScaleandroidx.compose.ui.res.painterResourceandroidx.compose.ui.draw.clipandroidx.compose.foundation.shape.RoundedCornerShapeRmust be your own app'sR(likecom.example.myfirstapp.R), notandroid.R.
Run the app. First you see green squares, then the photos appear one by one. Scroll down and back up. The photos now show instantly, because Coil saved them. 🎉
Tip: You can make your own placeholder image. Right-click
res/drawable→ New → Vector Asset, pick an icon like "image", and name itic_placeholder. Then usepainterResource(R.drawable.ic_placeholder).
Step 5: Make It a Circle
Want round photos, like profile pictures? Change only one line:
.clip(CircleShape)
Import androidx.compose.foundation.shape.CircleShape. Because we used ContentScale.Crop, the photo fills the circle nicely. Without Crop, you might get an oval or empty edges.
Step 6: A Big Photo on FruitDetailsScreen
Now let's show a large photo at the top of the details screen. Here is a full version of FruitDetailsScreen:
@Composable
fun FruitDetailsScreen(fruit: Fruit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
AsyncImage(
model = fruit.imageUrl,
contentDescription = "Big photo of ${fruit.name}",
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.ic_launcher_background),
error = painterResource(R.drawable.ic_launcher_foreground),
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.clip(RoundedCornerShape(24.dp))
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "${fruit.emoji} ${fruit.name}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "৳${fruit.price} per kg",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Let's understand it:
.fillMaxWidth().height(240.dp)makes a wide banner photo.- The same URL is used as in the list. Coil already has this photo saved, so it appears almost instantly. 🎉
- In earlier parts, your navigation found the fruit by name from the route
"details/{fruitName}". Keep that code. Just pass the foundFruitto this screen. If your screen has extra parameters (like a back button callback), keep them too.
Tips for Loading Images
- Don't load giant images. A 4000×3000 photo for a 64 dp box wastes the user's mobile data and memory. Ask your server for small sizes when you can, like we did with
400/300. Coil also resizes images to fit the box, which helps. - Always set a placeholder and an error image. Internet in Bangladesh can be slow on mobile data. An empty white box looks broken. A placeholder looks professional.
- Check the internet. If no photos load, open Chrome on the same phone and visit a website. If Chrome fails too, the problem is the network, not your code.
- Emulator without internet? Sometimes the emulator loses connection after your computer sleeps. Open Device Manager, click the three dots next to your emulator, and choose Cold Boot Now.
- Use
https, nothttp. Android blocks plainhttpURLs by default. Most image links today usehttps, so this is rarely a problem.
Common Errors
- Images never load, no crash. You probably forgot
coil-network-okhttp, or the INTERNET permission. Check both, then rebuild. - "Unresolved reference: AsyncImage." You did not click Sync Now, or you imported from the wrong package. The import is
coil3.compose.AsyncImage. - "No value passed for parameter 'imageUrl'." Some
Fruit(...)call (often in a@Preview) still has only 3 values. Add a URL. - App crashes with "SecurityException: Permission denied (missing INTERNET permission?)". The
<uses-permission>line is missing or placed inside<application>. Move it above<application>. - Photo looks small with empty space. Add
contentScale = ContentScale.Crop.
Try It Yourself
- Change the list photos to circles with
CircleShapeand size56.dp. - Put a wrong URL on one fruit (like
https://picsum.photos/wrong) and check that the error image shows. - Show the photo on the Favorites tab from Part 10 too, using the same
FruitRow.
Frequently Asked Questions
Does Coil download the same photo again every time? No. Coil saves photos in memory and on the phone's disk. The next time, it loads from there, which is much faster and saves mobile data.
Can I load an image from my own app's drawable folder with AsyncImage?
You can, but there is no need. For images inside your app, use the normal Image(painter = painterResource(...)). Use AsyncImage for URLs.
Why are the picsum photos not fruits? Lorem Picsum gives random demo photos for practice. In the next part we will get real data from the internet, and real apps get real photo URLs from their server the same way.
Quick Recap
- Apps keep photos on a server and load them by URL to stay small.
- Add
coil-composeandcoil-network-okhttp(Coil 3) toapp/build.gradle.kts. - Add
<uses-permission android:name="android.permission.INTERNET" />above<application>inAndroidManifest.xml. AsyncImage(model = url, contentDescription = ..., contentScale = ContentScale.Crop)shows the photo.- Use
placeholderanderrorso users never see an empty box. .clip(RoundedCornerShape(...))or.clip(CircleShape)changes the shape.
Our fruit data is still written by hand inside the app. Next, let's download real data from the internet.