Unleash the Power of Coil 3: Crafting Custom Fetchers and Mappers for Image Loading Mastery

Unleash the Power of Coil 3: Crafting Custom Fetchers and Mappers for Image Loading Mastery
Coil3 image generated by ChatGPT

Coil is a powerful image loading library for Android that has been around for a while now. It's known for its simplicity and ease of use, but it also has many advanced features that can be leveraged to make it even more powerful, which I
recently ended up making use of. Don't get me wrong, using Coil out of the box is good enough for most use cases, but I started looking into leveraging some of its more advanced features for my needs.

Coil3 (currently in alpha) supports Kotlin Multiplatform and still works out of the box with either Okhttp or Ktor, both very popular networking libraries in the Kotlin ecosystem. My use case here was that I had my own networking client in
Kotlin Multiplatform, which didn't depend on any external library directly, but I also wanted to leverage this to load images in Compose Multiplatform. So, how did I go about it?

I'll be covering how I crafted custom fetchers and mappers for Coil3 to load images in Compose Multiplatform.

Fetcher

Fetchers are responsible for converting data into ImageSource or a Drawable. Coil has a NetworkFetcher, which is responsible for fetching an image from a URL. Now, I don't want to mess with this fetcher and the way it works but I want it to use my networking layer instead of using Okhttp or Ktor directly. So, how do I go about doing that?
This is what the NetworkFetcher looks like:

class NetworkFetcher(
    private val url: String,
    private val options: Options,
    private val networkClient: Lazy<NetworkClient>,
    private val diskCache: Lazy<DiskCache?>,
    private val cacheStrategy: Lazy<CacheStrategy>,
)

Notice that it takes in a lazy NetworkClient which is the client that is used to fetch the image. This is where I can plug in my own networking client.

Custom client

Let's assume that this is my networking layer in KMP:

interface HttpClient {
    suspend fun get(request: Request): Response
    class Request(
        val url: String,
        val headers: Map<String, String> = emptyMap()
    )
    class Response(
        val statusCode: Int,
        val body: ByteArray?
    )
}

I want to use this layer to fetch all my images. To do this I need to provide a NetworkClient via a factory to my ImageLoader.

So let's create a custom client:

import coil3.annotation.ExperimentalCoilApi
import coil3.network.NetworkClient
import coil3.network.NetworkFetcher
import coil3.network.NetworkHeaders
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.NetworkResponseBody
import okio.Buffer
import okio.BufferedSource

@OptIn(ExperimentalCoilApi::class)
class CustomNetworkClient(private val internalClient: HttpClient) : NetworkClient {
    override suspend fun <T> executeRequest(
        request: NetworkRequest,
        block: suspend (response: NetworkResponse) -> T
    ): T {
        val internalRequest = HttpClient.Request(
            url = request.url,
            headers = request.headers.asMap().mapValues { it.value.joinToString(",") }
        )
        return block(internalClient.get(internalRequest).asNetworkResponse(request))
    }
    @OptIn(ExperimentalCoilApi::class)
    private fun HttpClient.Response.asNetworkResponse(request: NetworkRequest): NetworkResponse {
        return NetworkResponse(
            request = request,
            code = statusCode,
            headers = headers.toNetworkHeaders(),
            body = body?.let { NetworkResponseBody(it.toBufferedSource()) }
        )
    }
    @OptIn(ExperimentalCoilApi::class)
    private fun Map<String, String>.toNetworkHeaders(): NetworkHeaders = NetworkHeaders.Builder()
        .apply {
            forEach { (key, value) ->
                value.split(",").forEach { newValue ->
                    add(key, newValue)
                }
            }
        }
        .build()
    private fun ByteArray.toBufferedSource(): BufferedSource {
        val buffer = Buffer()
        buffer.write(this)
        return buffer
    }
}

With this we provided a way to fetch an image from the network without messing with how the fetched result is translated to an image. Now we can provide this via a factory as such:

In this example, my client loads the response into memory instead of streaming which is not ideal for loading a lot of content as it will consume a lot of memory. This is just a simple example to get you started, and you should definitely look into streaming the response
@OptIn(ExperimentalCoilApi::class)
fun HttpClient.toNetworkFetcherFactory(): NetworkFetcher.Factory = NetworkFetcher.Factory(
        { CustomNetworkClient(this) },
        { CacheStrategy() }
    )
@Composable
fun getImageLoader(httpClient: HttpClient): ImageLoader {
    return ImageLoader.Builder(LocalPlatformContext.current)
        .components {
            add(httpClient.toNetworkFetcherFactory())
        }
        .build()
}

This way the ImageLoader will use our custom client, which is plugged into the standard NetworkFetcher, to fetch images from the network.

How about if I wanted to fetch images that are the exact size of my container? Let's say I have an image view that is
100dp x 120dp and I want to fetch an image that is exactly that size from my CDN (Imgix forexample). This is where mappers come in.

Mapper

Mappers allow us to map custom data types to other data types. It takes an input data type, which is provided via AsyncImage or its painter version, and converts it to a data type that fetchers can handle.

Keep in mind that if you convert it to a data type that the standard fetchers can't handle, you'll need to provide a
custom fetcher as well.

Coil3 has a common Uri class which is very similar to Android's Uri class and behaves the same as in Coil2. It also knows how to fetch data with Uri types, so we'll stick to that.
Let's say I have this URL wrapper class:

import androidx.compose.ui.layout.ContentScale

data class ImageUrl(
    val url: String,
    val contentScale: ContentScale
)

Now I need a mapper that will convert this class to a Uri:

import androidx.compose.ui.layout.ContentScale
import coil3.Uri
import coil3.map.Mapper
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.pxOrElse
import coil3.toUri

class CustomMapper : Mapper<ImageUrl, Uri> {
    override fun map(data: ImageUrl, options: Options): Uri? {
        return when {
            data.url.isEmpty() -> null
            !data.url.contains(other = "imgix", ignoreCase = true) -> data.url.toUri()
            else -> {
                val queryParameters = buildMap {
                    options.size.width.pxOrNull()?.let { width ->
                        put(IMGIX_WIDTH_KEY, width.toString())
                    }
                    options.size.height.pxOrNull()?.let { height ->
                        put(IMGIX_HEIGHT_KEY, height.toString())
                    }
                    put(IMGIX_FIT_KEY, data.contentScale.toImgixFitValue())
                    put(IMGIX_OPTIONS_KEY, IMGIX_OPTIONS_VALUE)
                }
                return data.url
                    .toUri()
                    .addQueryParameters(queryParameters)
            }
        }
    }

    private fun ContentScale.toImgixFitValue(): String = when (this) {
        ContentScale.Crop -> "crop"
        else -> "clip"
    }
    
    private fun Dimension.pxOrNull(): Int? = pxOrElse { -1 }.takeIf { it != -1 }
    
    private fun Uri.addQueryParameters(queryParameters: Map<String, String>): Uri {
        val queries = queryParameters.map { (key, value) -> "$key=$value" }
            .joinToString("&")
        return if (query.isNullOrEmpty()) {
            "${toString()}?$queries".toUri()
        } else {
            "${toString()}&$queries".toUri()
        }
    }
    
    private companion object {
        const val IMGIX_WIDTH_KEY = "w"
        const val IMGIX_HEIGHT_KEY = "h"
        const val IMGIX_FIT_KEY = "fit"
        const val IMGIX_OPTIONS_KEY = "auto"
        const val IMGIX_OPTIONS_VALUE = "format%2Ccompress"
    }
}


In the mapper, Option gives us the size of the container that the image will be loaded into. We can use this to pass the exact size of the image to our CDN, which gives us two benefits:

  1. We get the exact size of the image we need, which eliminates the need for the image to be resized on the client side.
  2. We can leverage the CDN to resize and compress the image for us, which reduces the size of the image that is fetched.

Now we simply need to provide it to our ImageLoader:

@Composable
fun getImageLoader(httpClient: HttpClient): ImageLoader {
    return ImageLoader.Builder(LocalPlatformContext.current)
        .components {
            add(httpClient.toNetworkFetcherFactory())
            add(CustomMapper())
        }
        .build()
}

And that's it! We've crafted custom fetchers and mappers for Coil3 to load images in Compose Multiplatform. This is just
the tip of the iceberg, and there are many more things you can do with Coil3 to make it work for your needs.

I hope this article has given you some ideas on how to leverage Coil3 for your projects.