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
)
}
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)
)
}
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
)
}
Breaking this down:
-
clip(CircleShape): Clips the image to a perfect circle -
border(): Optional—adds a border ring -
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
}
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:
- Memory cache: Fast, in-process cache
- Disk cache: Persistent cache on device
- 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()
}
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()
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")
}
}
}
}
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)
)
}
}
}
This creates a 2-column grid with proper spacing, placeholders, and error handling—all without manual image lifecycle management.
Best Practices
- Always provide contentDescription: Required for accessibility
- Use appropriate ContentScale: Match your UI design intent
- Cache strategically: Balance memory usage vs. user experience
- Handle errors gracefully: Placeholder + error states build trust
- Use Modifier.size() before clip(): Prevents unexpected behavior
- 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)