Sitemap

Use Coil in advanced

5 min readNov 29, 2025

--

Coil is a perfect image downloading library. If you want to handle images (network and or local images, Coil is a perfect solution.

You can add it to your Android project:

coilCompose = "3.3.0"

coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" }

(Earlier there was one library but from 3.0 they separate the image handling, visualization from networking) Ohh, an important thing: Coil is supporting Kotlin Multi-Platform!

When you want to display an image you can use AsyncImage or asyncPainter:

AsyncImage(
model = "https://coil-kt.github.io/coil/logo.svg",
contentDescription = "image"
)

rememberAsyncImagePainter(
model = "https://coil-kt.github.io/coil/logo.svg"
)
Image(
painter = painter,
contentDescription = "image"
)

That was the easy level. But let’s see what can we do if we have an image service on our backend. We should use POST call (and not GET), we can set a size parameter. (in which size should the service send back the image(s))

If you look for information you will find we need a Custom Fetcher. What is a Fetcher ? Can you ask it.
A Fetcher is a service for Coil which will fetch the image from the network or from the disk. (I would like to create another post from the cacheing later.)

Let’s see what can we code:

sealed interface Image {
data class LocalImage(@param:DrawableRes val resId: Int) : Image
data class NetworkImage(val url: String, val size: ImageSize) : Image
}

data class ImageSize(val width: Int, val heigh: Int)

We have an Image interface with two representations:

  • One for locale images
  • and one for the network (remote) images

It means locale images will be read from our application / resource directory. But network images will come from the network.

Now we need a fetcher:

class LocalImageFetcher(private val options: Options) : Fetcher {
override suspend fun fetch(): FetchResult {
val context = options.context
val drawable: Drawable =
ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground)
?: throw IllegalStateException("Drawable not found")

return ImageFetchResult(
image = drawable.asImage(),
isSampled = false,
dataSource = DataSource.NETWORK
)
}

class Factory : Fetcher.Factory<Image> {
override fun create(
data: Image,
options: Options,
imageLoader: ImageLoader
): Fetcher? {
return if (data is Image.LocalImage) {
return LocalImageFetcher(options)
} else {
null
}
}
}
}

A Fetcher has a Factory class. There will be a cain of Fetchers where the factories could handle the requests. As you maybe saw AsyncImage’s model property is Any. It means: we can define our object(s) and Fetchers will try to handle it. If a Fetcher can not handle it, then its Factory will return a null value. After all the DefaultFetchers try to handle it or nothing will be displayed.

Get Peter Nagy’s stories in your inbox

Join Medium for free to get updates from this writer.

A Fetcher will return a FetcherResult (it is an interface), it could be

  • SourceFetcherResult
  • ImageFetcherResult

If you want to show any result of a network call or from the disk (like cache) the SourceFetchResult is a good option. In case when you will display any resource item you can use the ImageFetcherResult. (In the above code I have used drawable.asImage() extension function.)

Ok, this was awsome but from this point I can use it ? No. We need to define and configure our fetcher(s). You can do it in your Application.

class DemoApplication: Application(), SingletonImageLoader.Factory {

override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(LocalImageFetcher.Factory()
}
.build()
}
}

SingletonImageLoader.Factory interface is mandatory. Normal way Coil will use a SingletonImageLoader this is the default image loader. (We can try to overwrite it in the any Activity what we have.) The oerder of the Fetcher is important. The first Factory which will return the First Fetcher will handle our request. So it means if you have two Fetchers which will handle the same input type it might happen one will hijack the call from the proper one. This is important.

Let’s see the NetworkImageFetcher:

class MockNetworkImageFetcher(
private val imageService: MockImageService,
private val image: Image.NetworkImage
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val byteArray = imageService.downloadImage(image.url, image.size)
val buffer = Buffer().write(byteArray)
val fileSystem = FileSystem.SYSTEM

return SourceFetchResult(
source = ImageSource(source = buffer, fileSystem = fileSystem),
mimeType = null,
dataSource = DataSource.NETWORK
)
}

class Factory(private val imageService: MockImageService) : Fetcher.Factory<Image> {
override fun create(
data: Image,
options: Options,
imageLoader: ImageLoader
): Fetcher? {
return if (data is Image.NetworkImage) {
return MockNetworkImageFetcher(imageService, data)
} else {
null
}
}
}
}

I have used a mocked image service, which will return a ByteArray.

interface ImageService {
fun downloadImage(url: String, size: ImageSize): ByteArray
}
class MockImageService(private val context: Context): ImageService {
override fun downloadImage(
url: String,
size: ImageSize
): ByteArray {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
context.resources.openRawResource(R.raw.sponge_bob).use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
options.inSampleSize = calculateInSampleSize(options, size.width, size.heigh)
options.inJustDecodeBounds = false

val sampledBitmap = context.resources.openRawResource(R.raw.sponge_bob).use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}

return if (sampledBitmap != null) {
val bitmap = Bitmap.createScaledBitmap(sampledBitmap, size.width, size.heigh, true)
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.toByteArray()
} else {
byteArrayOf()
}
}

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.outHeight to options.outWidth
var inSampleSize = 1

if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2

while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
}

Here in the MockImageService I have used an image from the Raw resource folder. The most code lines are about the resize the byte array. The size of the image is in pixel. If you want to use dp then you should convert it in pixel. (because pixel size will be different based on the device density)

Ok, let’s modify the the Application:

class DemoApplication: Application(), SingletonImageLoader.Factory {

override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(LocalImageFetcher.Factory())
add(MockNetworkImageFetcher.Factory(MockImageService(this@DemoApplication)))
}
.build()
}
}

If you check the Factories you can see based on Image interface Factory will create a Fetcher. (If there is a Image.LocalImage then LocalImageFetcher will be created, in case of ImageNetworkImage then NetworkImageFetcher.) If we add any other implementation of Image like FooImage then neither of any presented Fetcher will handle it.

Ok, it could happen none of the Fetcher will handle it. In this case we can use placeholder (in the AsyncImage or in the ImageLoader).

What can we do if the network call was failed ? FetchResult is a class there is no any ErrorFetchResult or something similar. :) Ok, in this case when image was not downloaded but we got an error then we can throw an exception. In this case Coil will display the error image.

In a next post I would like to write about using Cache. So happy coding!

Thanks for reading my article! If you found this post useful? Kindly tap the 👏 button below and follow :) .

--

--