DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Image Loading in Jetpack Compose with Coil: A Practical Guide

Image Loading in Jetpack Compose with Coil: A Practical Guide

Loading images efficiently is one of the most critical aspects of modern Android development. In this comprehensive guide, we'll explore how to use Coil, the modern Kotlin-first image loading library, with Jetpack Compose to create smooth, performant image experiences.

What is Coil?

Coil stands for "Coroutine Image Loader." It's a lightweight, fast, and modern image loading library built specifically for Kotlin. Unlike older solutions, Coil is designed from the ground up to work seamlessly with Compose and Kotlin coroutines, making it the natural choice for modern Android apps.

Key advantages of Coil:

  • Kotlin-native: Built with coroutines and suspend functions
  • Compose-native: Perfect integration with Jetpack Compose
  • Lightweight: Smaller library size compared to alternatives
  • Fast: Optimized for performance with built-in caching
  • Extensible: Pluggable interceptors and transformers

Getting Started with AsyncImage

The simplest way to load an image in Compose is using AsyncImage:

import coil.compose.AsyncImage

@Composable
fun SimpleImageLoader() {
    AsyncImage(
        model = "https://example.com/image.jpg",
        contentDescription = "A sample image",
        modifier = Modifier.size(200.dp),
        contentScale = ContentScale.Crop
    )
}
Enter fullscreen mode Exit fullscreen mode

This single composable handles the entire image loading pipeline: fetching, caching, decoding, and displaying. No callback hell, no lifecycle management—just clean, declarative code.

Handling Loading, Success, and Error States

Real-world apps need to handle loading states, errors, and provide visual feedback. Coil makes this straightforward:

import coil.compose.AsyncImage

@Composable
fun RobustImageLoader() {
    AsyncImage(
        model = "https://example.com/image.jpg",
        contentDescription = "User avatar",
        modifier = Modifier
            .size(100.dp)
            .clip(CircleShape),
        contentScale = ContentScale.Crop,
        contentAlignment = Alignment.Center,
        error = painterResource(id = R.drawable.ic_error),
        placeholder = painterResource(id = R.drawable.ic_placeholder)
    )
}
Enter fullscreen mode Exit fullscreen mode

By providing placeholder and error drawables, users see meaningful visual feedback:

  • Placeholder: Shown while the image is loading
  • Error: Displayed if the request fails

This creates a polished, professional appearance without extra complexity.

Circular Crop Pattern

One of the most common UI patterns is circular images (avatars, profile pictures). Coil combined with Compose Modifier makes this trivial:

import coil.compose.AsyncImage
import androidx.compose.foundation.shape.CircleShape

@Composable
fun CircularAvatar(imageUrl: String, size: Dp = 48.dp) {
    AsyncImage(
        model = imageUrl,
        contentDescription = "User avatar",
        modifier = Modifier
            .size(size)
            .clip(CircleShape)
            .border(2.dp, Color.Gray, CircleShape),
        contentScale = ContentScale.Crop
    )
}
Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  1. clip(CircleShape): Clips the image to a perfect circle
  2. border(): Optional—adds a border ring
  3. ContentScale.Crop: Ensures the image fills the circle completely

Perfect for user profiles, comment threads, and social features.

Understanding ContentScale Options

ContentScale controls how the image is fitted to the composable size:

enum class ContentScale {
    // Fill all available space, may crop
    Crop,

    // Fit within bounds, may have empty space
    Fit,

    // Stretch to fill (not recommended—distorts image)
    FillHeight,
    FillWidth,
    FillBounds,

    // Intelligent choices
    Inside,      // Smaller of Fit/Crop
    None,        // Original size
}
Enter fullscreen mode Exit fullscreen mode

For example:

  • User avatars: ContentScale.Crop (fill circle, no distortion)
  • Product images: ContentScale.Fit (show entire image)
  • Background images: ContentScale.Crop (fill screen)

Choose based on your UI intent, not the image dimensions.

Caching Strategy with Coil

Coil's caching is automatic but configurable. It uses three-level caching:

  1. Memory cache: Fast, in-process cache
  2. Disk cache: Persistent cache on device
  3. Network: Fallback to remote URL

You can customize this globally:

import io.coil.ImageLoader
import io.coil.disk.DiskCache
import io.coil.memory.MemoryCache

fun createCustomImageLoader(context: Context): ImageLoader {
    return ImageLoader.Builder(context)
        .memoryCache {
            MemoryCache.Builder(context)
                .maxSizePercent(0.25)  // Use 25% of available memory
                .build()
        }
        .diskCache {
            DiskCache.Builder()
                .directory(context.cacheDir.resolve("image_cache"))
                .maxSizeBytes(50 * 1024 * 1024)  // 50 MB
                .build()
        }
        .build()
}
Enter fullscreen mode Exit fullscreen mode

For specific requests, use ImageRequest:

import coil.request.ImageRequest
import coil.request.CachePolicy

val request = ImageRequest.Builder(context)
    .data("https://example.com/image.jpg")
    .memoryCachePolicy(CachePolicy.ENABLED)
    .diskCachePolicy(CachePolicy.ENABLED)
    .networkCachePolicy(CachePolicy.ENABLED)
    .build()
Enter fullscreen mode Exit fullscreen mode

This gives fine-grained control over which images are cached where.

Advanced: SubcomposeAsyncImage

For more complex layouts that depend on image dimensions, use SubcomposeAsyncImage:

import coil.compose.SubcomposeAsyncImage

@Composable
fun DynamicHeightImage(imageUrl: String) {
    SubcomposeAsyncImage(
        model = imageUrl,
        contentDescription = "Dynamic image"
    ) { state ->
        when (state) {
            is AsyncImagePainter.State.Loading -> {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            is AsyncImagePainter.State.Success -> {
                Image(
                    painter = state.painter,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(state.painter.intrinsicSize.width / state.painter.intrinsicSize.height)
                )
            }
            is AsyncImagePainter.State.Error -> {
                Text("Failed to load image")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

SubcomposeAsyncImage gives you the painter and state directly, enabling:

  • Dynamic aspect ratio based on actual image
  • Custom progress indicators
  • Fine-tuned error handling

Coil vs Glide: A Comparison

Both are excellent libraries, but they serve different eras of Android development:

Feature Coil Glide
First Released 2019 2014
Language Kotlin Java
Coroutines Native Via extension
Compose Support Built-in Extension needed
Bundle Size ~150 KB ~400 KB
Learning Curve Easier (Kotlin devs) Steeper (more options)
Configuration Fluent builders Complex builders
Community Growing fast Mature, stable
Best For Modern Compose apps Legacy/large projects

Bottom line: If you're building a new Compose app, Coil is the clear choice. If you're maintaining a large legacy codebase, Glide is battle-tested and reliable.

Practical Example: Image Gallery

Here's a complete example combining everything:

@Composable
fun ImageGallery(imageUrls: List<String>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(2),
        modifier = Modifier.padding(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(imageUrls.size) { index ->
            AsyncImage(
                model = imageUrls[index],
                contentDescription = "Gallery image $index",
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f)
                    .clip(RoundedCornerShape(8.dp)),
                contentScale = ContentScale.Crop,
                placeholder = painterResource(id = R.drawable.ic_placeholder),
                error = painterResource(id = R.drawable.ic_error)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This creates a 2-column grid with proper spacing, placeholders, and error handling—all without manual image lifecycle management.

Best Practices

  1. Always provide contentDescription: Required for accessibility
  2. Use appropriate ContentScale: Match your UI design intent
  3. Cache strategically: Balance memory usage vs. user experience
  4. Handle errors gracefully: Placeholder + error states build trust
  5. Use Modifier.size() before clip(): Prevents unexpected behavior
  6. Test with slow networks: Use Android Studio Network Profiler

Conclusion

Coil brings modern, Kotlin-native image loading to Jetpack Compose. Its intuitive API, excellent performance, and seamless coroutine integration make it the go-to choice for contemporary Android development. Whether you're building a simple profile screen or a complex image gallery, Coil handles the complexity so you can focus on creating great user experiences.


All 8 templates are ready for image integration. https://myougatheax.gumroad.com

Top comments (0)