Image Loading with Coil
Introduction
Loading images efficiently is one of the most common challenges in Android development. For years, developers relied on heavy, XML-centric libraries that required significant boilerplate. Enter Coil (Compose Image Loader), a library built from the ground up with Kotlin coroutines. It is lightweight, fast, and integrates seamlessly with both Jetpack Compose and the traditional View system. Unlike its predecessors, Coil requires minimal setup, automatically handles image scaling, and respects the Android lifecycle to prevent memory leaks. This tutorial will guide you through the modern approach to image loading, focusing on the AsyncImage composables, building complex requests, and mastering the library's caching and transformation capabilities.
Why Choose Coil?
Coil distinguishes itself by being truly reactive and coroutine-native. In the Jetpack Compose world, this is critical because images must update dynamically without causing layout thrashing or jank. When you observe older libraries, you often see a disconnect between the image loading logic and the state management of the UI. Coil bridges this gap by returning StateFlow<ImageResult> internally, allowing the UI to react instantly to loading states. Furthermore, Coil 2.x introduces a powerful ImageRequest builder that makes chaining options like interceptors, transformations, and placeholders much more readable than the method-chaining style found in older libraries. This combination of performance and modern language features makes it the preferred choice for new Android projects.
Setting Up Coil
To begin using Coil, you need to add the appropriate dependencies to your build.gradle.kts file. For Jetpack Compose projects, you will primarily use the Compose integration library. If you are still using XML layouts, you will need the coil-compose-base and coil libraries separately. It is important to note that as of recent versions, the single coil-compose artifact includes the base library, simplifying the dependency tree. You should also configure ProGuard rules if you are shrinking your code, although modern versions of the library often come with pre-defined rules.
dependencies {
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
}
By including the coil-gif dependency, you enable support for animated GIFs and WebP animations out of the box. Without this specific module, Coil will load the first frame of an animated image as a static picture, which is often undesirable. The version number 2.6.0 represents a stable release that supports the latest Android APIs. Ensure your project is using Kotlin 1.9.x or higher to fully leverage coroutines and the suspend functions used throughout the library.
Loading Images with AsyncImage
The AsyncImage composable is the primary entry point for loading images in Jetpack Compose. It works similarly to an ImageView but is designed to work within the Compose lifecycle. You provide a URL, and optionally a model, along with modifiers to control size and alignment. The simplest use case requires only the content description and the URL. However, in production apps, you should almost always provide placeholder and error images to give users visual feedback while data is being fetched from the network.
@Composable
fun SimpleImageLoader(url: String) {
AsyncImage(
model = url,
contentDescription = "User profile picture",
modifier = Modifier
.size(100.dp)
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.placeholder),
error = painterResource(id = R.drawable.error),
imageFilter = ImageFilter.blur(radiusDp = 2f)
)
}
In this example, the AsyncImage composable automatically handles the download, decoding, and caching of the image. The modifier chain applies a circular clip, which is essential for profile pictures, and sets the dimensions. The placeholder and error parameters accept Painter objects, which are drawn immediately while the actual image loads or if the request fails. Notice that we also applied a blur filter via imageFilter; this is a built-in Compose feature that works beautifully with Coil to create a "skeleton" loading effect before the sharp image arrives.
Building Complex Requests with ImageRequest
While passing a simple string URL works for basic needs, most applications require more control. This is where the ImageRequest.Builder comes into play. It allows you to configure memory cache keys, set time limits for loading, and add custom interceptors. For instance, if you are loading the same image URL but want to display different sizes or transformations based on the context, you can use the builder to override these parameters. This flexibility is why Coil is preferred for complex list views and detail pages where image requirements vary significantly.
@Composable
fun AdvancedImageLoader(url: String, size: Int) {
val request = remember(url, size) {
ImageRequest.Builder(LocalContext.current)
.data(url)
.size(size)
.memoryCacheKey("${url}_$size")
.diskCacheKey("${url}_$size")
.placeholder(ColorPaint(Color.LightGray))
.error(ColorPaint(Color.Red))
.build()
}
AsyncImage(
model = request,
contentDescription = "Detailed product image",
modifier = Modifier.size(size.dp)
)
}
Here, we use remember to ensure the ImageRequest is only rebuilt when the url or size changes, preventing unnecessary object creation during recomposition. By explicitly setting the memoryCacheKey and diskCacheKey, we ensure that the cached image corresponds exactly to the requested size and transformation, avoiding stale cache hits. The ColorPaint placeholders provide immediate visual feedback, with red indicating an error state. This approach gives you granular control over how the library handles concurrency and caching for specific UI elements.
Disk and Memory Caching Strategies
Coil manages caching automatically, but understanding how it works is vital for performance. By default, Coil uses LruCache for memory and a disk cache in the app's cache directory. Memory caching is fast and prevents reloading images that are currently visible or were recently viewed. Disk caching persists images across app restarts, saving bandwidth and improving startup time. However, for apps dealing with dynamic content, you might want to disable caching for specific requests or configure the disk cache size to prevent the app from consuming too much storage on the user's device.
val request = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCache(
DiskCache.Builder()
.directory(File(context.cacheDir, "coiled_images"))
.maxSizePercent(0.02)
.build()
)
.build()
In this snippet, we explicitly enable disk caching but disable memory caching. This is useful for scenarios where you want to save network traffic but avoid holding large bitmaps in RAM, such as when loading high-resolution images that are only viewed once. The DiskCache.Builder allows you to specify a custom directory and limit the cache size to 2% of the available internal storage. This ensures your app remains a good citizen on the user's device, respecting storage constraints while still delivering a fast experience.
Image Transformations
Transformations allow you to modify images before they are drawn on screen, which is essential for achieving consistent design systems. Coil supports both built-in transformations like CircleCrop and RoundedCorners, as well as custom transformations. You can chain multiple transformations, and Coil will apply them in order. For example, you might want to round the corners of an image and then apply a blur effect. These transformations are applied on a background thread, ensuring they do not block the main thread and cause UI jank.
val request = ImageRequest.Builder(LocalContext.current)
.data(url)
.transformations(
RoundedCornersTransformation(radius = 16.dp.toPx()),
BlurTransformation(radius = 10f, samples = 4)
)
.build()
AsyncImage(
model = request,
contentDescription = "Decorated image",
contentScale = ContentScale.Crop,
modifier = Modifier.size(200.dp)
)
The code above applies a rounded corner effect followed by a Gaussian blur. The RoundedCornersTransformation takes a radius in pixels, which is why we convert the DP value using toPx(). The BlurTransformation is a custom transformation that requires you to implement the Transformation interface if it is not provided by a library. By using contentScale = ContentScale.Crop, we ensure the image fills the specified dimensions without distortion, which works in tandem with the transformations to produce a polished result.
Handling Placeholder and Error States
Visual feedback is crucial for user experience. When an image is loading, the user should see something, and if it fails, they should understand why. Coil provides robust mechanisms for handling these states. Beyond simple drawables, you can use PainterResource for static images or create custom painters. For error states, it is often helpful to provide a retry mechanism. You can achieve this by observing the AsyncImagePainter state and exposing a retry function to the user via a click listener.
@Composable
fun RetryableImage(url: String) {
var retryCount by remember { mutableStateOf(0) }
val painter = rememberImagePainter(
data = url,
builder = {
placeholder(R.drawable.loading_spinner)
error(R.drawable.retry_icon)
}
)
AsyncImage(
model = painter,
contentDescription = "Content image",
modifier = Modifier
.size(150.dp)
.clickable {
retryCount++
painter.reset()
},
contentScale = ContentScale.Fit
)
}
In this example, we use rememberImagePainter to get direct access to the painter, which exposes the reset() function. When the user clicks the image, typically displayed in an error state, we increment a counter and call reset(), which triggers a new load request. This provides a seamless retry experience without needing to rebuild the entire composable. The placeholder shows a spinner during the initial load, ensuring the user knows something is happening. This pattern is widely used in social media apps and e-commerce platforms to handle unstable network conditions gracefully.
Common Pitfalls
Even with a robust library like Coil, developers can run into issues. One common mistake is reusing image views or composables without clearing the request when the item is recycled. In RecyclerView or LazyColumn, this can lead to memory leaks or images appearing in the wrong rows. Always ensure you are using remember correctly to dispose of requests when the composable leaves the composition. Another pitfall is not handling configuration changes properly; while Coil handles rotation well, large images can still cause OutOfMemoryError if not scaled down appropriately.
// Bad practice: creating request without remember
val badRequest = ImageRequest.Builder(context)
.data(url)
.build()
// Good practice: using remember to tie lifecycle to composition
val goodRequest = remember(url) {
ImageRequest.Builder(LocalContext.current)
.data(url)
.build()
}
Creating requests outside of remember can cause them to persist unnecessarily or be recreated on every recomposition, wasting resources. Additionally, never pass a null or empty string as the model without handling it, as Coil will attempt to load it and may throw an exception or show an error. Finally, be cautious with the Crossfade duration; setting it too high can make the UI feel sluggish when images load. A duration of 300ms is usually sufficient for a smooth transition without delay.
Conclusion
Coil represents the modern standard for image loading on Android, offering a perfect blend of performance, simplicity, and power. By leveraging Kotlin coroutines and Jetpack Compose integration, it eliminates much of the boilerplate associated with older libraries. We have covered the essentials, from basic setup and AsyncImage usage to advanced configurations like custom caching and transformations. Mastering these concepts allows you to build responsive, visually rich applications that handle network images efficiently. As you continue your Android development journey, keep Coil in your toolkit; it will serve you well in creating smooth and engaging user experiences.