SHAKIL.DEV

SHAKIL.DEV

Load Images from the Internet in Jetpack Compose with Coil
← BACK TO TRANSMISSIONS
October 16, 2026

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-compose gives us the AsyncImage composable.
  • coil-network-okhttp lets 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.

  1. In the Project panel, open app → manifests → AndroidManifest.xml.
  2. Find the line that starts with <application.
  3. 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: String is 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/ (like mango) makes sure you get the same photo every time.
  • 400/300 means 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:

  • AsyncImage is 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 in coil3)
  • androidx.compose.ui.layout.ContentScale
  • androidx.compose.ui.res.painterResource
  • androidx.compose.ui.draw.clip
  • androidx.compose.foundation.shape.RoundedCornerShape
  • R must be your own app's R (like com.example.myfirstapp.R), not android.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 it ic_placeholder. Then use painterResource(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 found Fruit to 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, not http. Android blocks plain http URLs by default. Most image links today use https, 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

  1. Change the list photos to circles with CircleShape and size 56.dp.
  2. Put a wrong URL on one fruit (like https://picsum.photos/wrong) and check that the error image shows.
  3. 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-compose and coil-network-okhttp (Coil 3) to app/build.gradle.kts.
  • Add <uses-permission android:name="android.permission.INTERNET" /> above <application> in AndroidManifest.xml.
  • AsyncImage(model = url, contentDescription = ..., contentScale = ContentScale.Crop) shows the photo.
  • Use placeholder and error so 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.

Part 13: Get Data from the Internet with Retrofit