Coil: Enable native support for SVG format without boilerplate

Created on 13 Aug 2019  路  18Comments  路  Source: coil-kt/coil

Currently using Glide to load SVG images and the implementation is quite clunky in order to do so. See: https://stackoverflow.com/questions/35507893/does-glide-have-a-method-for-loading-both-png-and-svg

As a fresh new library hot off the presses, I'd like to see Coil gracefully (and optimally) handle these file formats without a ton of boilerplate.

EDIT: WebP is already supported, so this enhancement request is more about formats that require an external library for decoding (and a Decoder).

Thanks!

enhancement

Most helpful comment

@rharter Looks good! Minor correction: I would replace SVG.getFromInputStream(source.inputStream()) with source.use { SVG.getFromInputStream(it.inputStream()) }. Coil doesn't automatically close the decoder's source in case a decoder wants to return a Drawable, but continue to read the source after returning.

All 18 comments

Android (and Coil) already supports WebP from API 14+. Are you looking for it to support animated WebP on pre-P?

For SVGs, I think this might be a good candidate for an external library. However, it should be straightforward to write a custom Decoder for this using AndroidSVG. Something like:

class SvgDecoder : Decoder {

    override fun handles(source: BufferedSource, mimeType: String?) = mimeType == "image/svg+xml"

    override suspend fun decode(pool: BitmapPool, source: BufferedSource, size: Size, options: Options): DecodeResult {
        return DecodeResult(
            drawable = SvgDrawable(source.use { SVG.getFromInputStream(it.inputStream()) }),
            isSampled = false
        )
    }
}

class SvgDrawable(private val svg: SVG) : Drawable() {

    override fun draw(canvas: Canvas) {
        svg.renderToCanvas(canvas)
    }

    override fun setAlpha(alpha: Int) {
        // Unsupported.
    }

    override fun getOpacity() = PixelFormat.TRANSLUCENT

    override fun setColorFilter(colorFilter: ColorFilter?) {
        // Unsupported
    }
}

should work well.

Disclaimer: The above code is completely untested.

I am currently using SVGDecoder via Glide, so it wasn't a heavy lift to add some of the sample code you wrote and try it out myself. From what I can tell, the SVG is being decoded fine, but it's not scaling correctly within the ImageView bounds.

Before (SvgModule.kt):

@GlideModule
class SvgModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
    registry
        .register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder())
        .append(InputStream::class.java, SVG::class.java, SvgDecoder())
}

class SvgDecoder : ResourceDecoder<InputStream, SVG> {
    override fun handles(source: InputStream, options: Options): Boolean = true
    override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource<SVG> =
        SimpleResource(SVG.getFromInputStream(source))
}

class SvgDrawableTranscoder : ResourceTranscoder<SVG, PictureDrawable> {
    override fun transcode(toTranscode: Resource<SVG>, options: Options): Resource<PictureDrawable> =
        SimpleResource(PictureDrawable(toTranscode.get().renderToPicture()))
}

(SvgLayerTypeListener.kt):

class SvgLayerTypeListener : RequestListener<PictureDrawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<PictureDrawable>?, isFirstResource: Boolean): Boolean {
    if (target is ImageViewTarget<*>) {
        val view = (target as ImageViewTarget<*>).view
        view.setLayerType(ImageView.LAYER_TYPE_NONE, null)
    }

    return false
}

override fun onResourceReady(resource: PictureDrawable, model: Any, target: Target<PictureDrawable>, dataSource: DataSource, isFirstResource: Boolean): Boolean {
    if (target is ImageViewTarget<*>) {
        val view = (target as ImageViewTarget<*>).view
        view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
    }

    return false
}

Usage (Extensions.kt):

fun ImageView.load(url: String?) {
    Glide.with(context)
        .`as`(PictureDrawable::class.java)
        .load(url)
        .listener(SvgLayerTypeListener())
        .into(this)`

After (SvgDecoder.kt):

class SvgDecoder : Decoder {

override fun handles(source: BufferedSource, mimeType: String?) = mimeType == "image/svg+xml"

override suspend fun decode(pool: BitmapPool, source: BufferedSource, size: Size, options: Options): DecodeResult =
    DecodeResult(
        drawable = SvgDrawable(SVG.getFromInputStream(source.inputStream())),
        isSampled = false
    )

class SvgDrawable(private val svg: SVG) : Drawable() {
    override fun draw(canvas: Canvas) {
        canvas.drawPicture(svg.renderToPicture())
    }

    override fun setAlpha(alpha: Int) {
        // Unsupported.
    }

    override fun getOpacity() = PixelFormat.TRANSLUCENT

    override fun setColorFilter(colorFilter: ColorFilter?) {
        // Unsupported
    }
}

Usage (Extensions.kt):

fun ImageView.loadSvg(url: String?) = load(url, ImageLoader(context) {
    componentRegistry {
        add(SvgDecoder())
    }
})

How would you recommend using decoders and other components via the componentRegistry without instantiating a new ImageLoader every time? The default "load" extension does allow it to be passed in as a parameter, but needs to be refactored because of the excessive loading in my ViewHolders:

2019-08-12 22:28:44.661 28475-28475/? E/AndroidRuntime: FATAL EXCEPTION: main Process: com.frostrocket.myapp, PID: 28475 android.net.ConnectivityManager$TooManyRequestsException at android.net.ConnectivityManager.convertServiceException(ConnectivityManager.java:3378) at android.net.ConnectivityManager.sendRequestForNetwork(ConnectivityManager.java:3564) at android.net.ConnectivityManager.registerNetworkCallback(ConnectivityManager.java:3885) at android.net.ConnectivityManager.registerNetworkCallback(ConnectivityManager.java:3866) at coil.network.NetworkObserverStrategyApi21.start(NetworkObserverStrategy.kt:102) at coil.network.NetworkObserver.<init>(NetworkObserver.kt:27) at coil.RealImageLoader.<init>(RealImageLoader.kt:94) at coil.ImageLoaderBuilder.build(ImageLoaderBuilder.kt:177) at com.frostrocket.myapp.core.ExtensionsKt.loadSvg(Extensions.kt:181)

I thought about calling Coil.setDefaultImageLoader() in the application class but that seems like a meh way to handle it.

I can wait until there's further documentation around this topic, but I figured it wouldn't hurt to start a conversation to get some examples out there 馃憤

You should re-use ImageLoader instances as much as possible - similar to OkHttpClient. From your stack trace it looks like you're instantiating new ImageLoaders, but not calling ImageLoader.shutdown() when you're done with them.

Adding SvgDecoder to your ImageLoader's component registry should automatically handle any new SVGs (assuming the file ends with .svg) without having to specify that it's an SVG when creating the request.

If you're using the Coil singleton (probably best to put this in your Application class):

val imageLoader = ImageLoader(context) {
    componentRegistry {
        add(SvgDecoder())
    }
}
Coil.setDefaultImageLoader(imageLoader)

then all subsequent calls to imageView.load should automatically handle SVGs.

From what I can tell, the SVG is being decoded fine, but it's not scaling correctly within the ImageView bounds.

Can you post a screenshot of the issue?

@FrostRocket It doesn't look like you're handling scaling in either case. Both libraries give you the sizing information, you just need to pass it to the SVG library.

class SvgDecoder : Decoder {

    override fun handles(source: BufferedSource, mimeType: String?) = mimeType == "image/svg+xml"

    override suspend fun decode(pool: BitmapPool, source: BufferedSource, size: Size, options: Options): DecodeResult {
        try {
            val svg = source.use { SVG.getFromInputStream(it.inputStream()) }
            return DecodeResult(
                    drawable = PictureDrawable(svg.renderToPicture()),
                    isSampled = false
            )
        } catch (e: SVGParseException) {
            throw IllegalStateException("Failed to load SVG.", e)
        }
    }
}

@rharter Looks good! Minor correction: I would replace SVG.getFromInputStream(source.inputStream()) with source.use { SVG.getFromInputStream(it.inputStream()) }. Coil doesn't automatically close the decoder's source in case a decoder wants to return a Drawable, but continue to read the source after returning.

I'm using a singleton for the ImageLoader (thanks @colinrtwhite) and now accommodating for the size options (thanks @rharter). Performance has greatly improved, but assets are still not resizing properly. Some are "blank" (likely getting a transparent corner of the image), some are blown up, and some are perfect. I'm wondering if the metadata is weird, and the way Glide was decoding the image accommodated for that?

@colinrtwhite I can't give you a sample screenshot publicly but I can point you toward the SVGs.

Sample asset that is "blank" (loaded, but scaled out of view): https://cdn.coinranking.com/Sy33Krudb/btc.svg

Sample asset that scales fine: https://cdn.coinranking.com/B1oPuTyfX/xrp.svg

Sample asset that is blown up (can see part of it but it's very large, scaling issues): https://cdn.coinranking.com/behejNqQs/trx.svg

Let me try a few more things in case I'm missing something in my implementation, but it's basically a cleaned up version of the examples you both have provided.

Try updating the documentWidth and documentHeight of the SVG. I see that the assets that don't work correctly have a huge width/height property set on the root element, and the one that works correctly doesn't. Rendering into a 500x500 viewport when the document is hard coded at 2378x2500 would explain what you are seeing.

I've updated the code in the original comment to reflect this.

Scratch that, I did some testing and you actually don't want to do that with a PictureDrawable (in my app I'm drawing to a custom canvas since I have to use a special paint, so I grab the target size).

When you render to a Picture, it's simply storing drawing commands, not any actual pixels. The PictureDrawable will then render that to a properly sized canvas, so things should be scaled appropriately.

device-2019-08-15-162557

I've updated the comment above with the tested code.

@colinrtwhite I created an external SVG lib, similar to the Gif one, in a branch on my fork. If that's something you'd be open to adding, I'll submit a PR.

SVGs are now being properly rendered in the ImageViews. Thanks for the help @rharter!

Parting thought: it'd be nice to include the SVG decoder (and other custom decoders) in the default ImageLoader, similar to how Retrofit handles Converters via an extra dependency. So it won't bloat the library but allows for additional customization. i.e:

implementation 'io.coil-kt:coil:0.6.0'
implementation 'io.coil-kt:decoder-svg:0.6.0'

I'll leave it up to you to decide to investigate this option or to close this issue down and add it to the documentation instead.

Kudos for setting the bar high on a 100% Kotlin lib with native language features! 馃帀

@rharter I think that's a good idea. Feel free to submit a PR, thanks. To keep things consistent, we should use the module name coil-svg.

On it.

Adding auto-discovery to automatically add decoders that are on the classpath to the default ImageLoader adds the requirement of a service locator, which adds overhead to an otherwise simple architecture. That's part of what led Glide to all of it's extra bloat, so I'd argue keeping it a one line addition.

@rharter On pre-Nougat devices I'm noticing extreme performance issues while loading in RecyclerViews. I noticed the same with Glide until I realized that I was using hardware rendering.

I can take a look to see if we can disable hardware rendering when loading SVGs, but if you have a quick fix in mind you can also address it. 馃憤

https://bigbadaboom.github.io/androidsvg/faq.html

@FrostRocket I think I have a fix for this here: https://github.com/coil-kt/coil/pull/81, though do you know why there are performance issues pre-Nougat? According to the PictureDrawable docs, Canvas doesn't support rendering Pictures/SVGs on hardware canvases until Marshmallow.

Looks like even on Marshmallow, rendering an SVG on a hardware canvas will end up blurry. #81 should handle this limitation if you don't manually disable software rendering on your view.

I wasn't seeing blurry SVGs on my Marshmallow Nexus 5x, but I was running into those performance issues I mentioned. Took a look at the diff for your change so when you release 0.70 I'll update you if this resolves it. Thanks!

@FrostRocket Thanks, you can also test out the 0.7.0 snapshots by following the instructions here if you'd like. The full 0.7.0 release should also be out later today.

@colinrtwhite Latest version of Coil unusable for the SVG use case, still seeing performance issues and a number of crashes now:

https://github.com/coil-kt/coil/issues/92
https://github.com/coil-kt/coil/issues/91

I'll continue the discussion on these other issues. 馃憤

Was this page helpful?
0 / 5 - 0 ratings

Related issues

gastsail picture gastsail  路  4Comments

vegeta2102 picture vegeta2102  路  6Comments

dafi picture dafi  路  3Comments

cioccarellia picture cioccarellia  路  3Comments

RubenGonzalezGonzalez picture RubenGonzalezGonzalez  路  6Comments