Loading Images with Coil
Async image loading in Compose.
Loading Images with Coil is a free Android Academy lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Android Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why You Need an Image Loader
Most modern apps show images from the internet: profile photos, product pictures, article thumbnails. Downloading and decoding those images correctly is harder than it looks.
You have to fetch bytes over the network on a background thread, decode them into a bitmap, cache them so you don't refetch, and cancel the request if the user scrolls away. Doing this by hand is error-prone.
Coil (Coroutine Image Loader) handles all of this for you with a tiny, Compose-friendly API. In this lesson you'll load your first remote image.
Adding Coil to Your Project
Coil ships as a Gradle dependency. For Jetpack Compose you want the coil-compose artifact, which gives you the AsyncImage composable.
Add it to your module's build.gradle.kts. Coil 3 is the current major version and works across platforms; the Compose integration is what we use here.
// build.gradle.kts (module level)
dependencies {
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
}Internet Permission
To download images from a URL your app needs the INTERNET permission. This is a normal (install-time) permission, so you only declare it in the manifest. There is no runtime prompt.
Without it, every network request fails with a security exception.
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
...
</application>
</manifest>Your First AsyncImage
The simplest way to show a remote image in Compose is AsyncImage. Give it a model (usually a URL string) and a contentDescription, and Coil does the rest: background fetch, decode, and display.
It automatically uses coroutines tied to the composition, so the request is cancelled if the composable leaves the screen.
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun Avatar() {
AsyncImage(
model = "https://example.com/avatar.png",
contentDescription = "User avatar",
modifier = Modifier.size(96.dp)
)
}contentScale: How the Image Fits
Images rarely match the exact size of your layout slot. ContentScale controls how the bitmap is scaled inside its bounds.
ContentScale.Cropfills the box and clips overflow (great for avatars and thumbnails).ContentScale.Fitkeeps the whole image visible, possibly leaving empty space.ContentScale.FillBoundsstretches to fill (can distort).
Crop is the most common choice for fixed-size slots.
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun CircleAvatar(url: String) {
AsyncImage(
model = url,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
)
}Building a Request with ImageRequest
Passing a URL string is the quick path. For more control, build an ImageRequest. This lets you set options like crossfade, headers, transformations, and cache keys.
You need a Context, which Compose provides via LocalContext.current.
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun ProductImage(url: String) {
val context = LocalContext.current
val request = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.build()
AsyncImage(
model = request,
contentDescription = "Product photo"
)
}Crossfade for Smooth Loads
By default an image pops in instantly when it finishes loading, which can feel abrupt. crossfade(true) fades the image in over a short animation.
You can also pass a duration in milliseconds for a slower or faster fade. This small touch makes lists feel much more polished.
import coil3.request.ImageRequest
import coil3.request.crossfade
import android.content.Context
fun smoothRequest(context: Context, url: String) =
ImageRequest.Builder(context)
.data(url)
.crossfade(durationMillis = 300)
.build()Loading from Different Sources
The model in AsyncImage is flexible. Coil can load from many source types, not just remote URLs:
- A
StringorUrifor network or local files. - A drawable resource id (
R.drawable.logo). - A
Filefrom the device. - A content
Urifrom the photo picker.
This means the same composable handles remote and local images seamlessly.
import android.net.Uri
import androidx.compose.runtime.Composable
import coil3.compose.AsyncImage
@Composable
fun FlexibleImage(source: Any) {
// source can be a URL String, a Uri, a File, or a resource id
AsyncImage(
model = source,
contentDescription = null
)
}
// Examples:
// FlexibleImage("https://example.com/pic.jpg")
// FlexibleImage(Uri.parse("content://media/external/images/1"))Reacting to Load State
Sometimes you want to know whether the image is loading, succeeded, or failed so you can show different UI. AsyncImagePainter exposes that state, but the simplest approach is SubcomposeAsyncImage, which lets you provide content per state.
We'll go deeper into placeholders next lesson; here is the shape of state-aware loading.
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import coil3.compose.SubcomposeAsyncImage
@Composable
fun StatefulImage(url: String) {
SubcomposeAsyncImage(
model = url,
contentDescription = "Photo",
loading = { CircularProgressIndicator() }
)
}Cancellation Comes for Free
One of Coil's biggest wins is automatic cancellation. Because AsyncImage launches its request inside the composition, when the composable leaves the screen (for example a row scrolls out of a LazyColumn), the in-flight request is cancelled.
This saves bandwidth and CPU and prevents wasted work. You don't write any cancellation code yourself, which is why Coil scales so well in long lists.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun PhotoFeed(urls: List<String>) {
LazyColumn {
items(urls) { url ->
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
}
}
}A Complete Image Card
Let's combine what we've learned: a card with a remote image that fills the top, uses crossfade, and crops to a fixed height. This is the bread-and-butter pattern you'll reuse constantly.
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun ImageCard(title: String, imageUrl: String) {
val context = LocalContext.current
Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column {
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(180.dp)
)
Text(text = title, modifier = Modifier.padding(16.dp))
}
}
}Quick Check
Which composable from coil-compose is the simplest way to display a remote image by URL in Jetpack Compose?
Recap
You loaded your first remote images with Coil. Key takeaways:
- Add
coil-composeand an OkHttp network dependency, plus theINTERNETpermission. AsyncImage(model = url, contentDescription = ...)is the quick path.- Use
ContentScale.Cropwith a fixed size for avatars and thumbnails. - Build an
ImageRequestfor crossfade and more control. - Coil cancels in-flight requests automatically when composables leave the screen.
Next: making loading graceful with placeholders and error states.
Frequently asked questions
Is the “Loading Images with Coil” lesson free?
Yes — the full text of “Loading Images with Coil” is free to read here on the web, and the Android Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Android Academy course, upgrade to CoddyKit PRO.
What will I learn in “Loading Images with Coil”?
Async image loading in Compose. You practise Android Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Android Academy?
No prior experience is required. Android Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Loading Images with Coil” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Android Academy lesson?
Yes. Every Android Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Loading Images with Coil
- Placeholders and Error States
- Caching and Performance
- Playing Audio and Video