Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animated transformation support #659

Merged
20 changes: 20 additions & 0 deletions coil-gif/api/coil-gif.api
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public final class coil/decode/GifDecoder : coil/decode/Decoder {
public static final field ANIMATED_TRANSFORMATION_KEY Ljava/lang/String;
public static final field Companion Lcoil/decode/GifDecoder$Companion;
public static final field REPEAT_COUNT_KEY Ljava/lang/String;
public fun <init> ()V
Expand All @@ -10,6 +11,7 @@ public final class coil/decode/GifDecoder$Companion {
}

public final class coil/decode/ImageDecoderDecoder : coil/decode/Decoder {
public static final field ANIMATED_TRANSFORMATION_KEY Ljava/lang/String;
public static final field Companion Lcoil/decode/ImageDecoderDecoder$Companion;
public static final field REPEAT_COUNT_KEY Ljava/lang/String;
public fun <init> ()V
Expand All @@ -30,6 +32,7 @@ public final class coil/drawable/MovieDrawable : android/graphics/drawable/Drawa
public synthetic fun <init> (Landroid/graphics/Movie;Lcoil/bitmap/BitmapPool;Landroid/graphics/Bitmap$Config;Lcoil/size/Scale;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun clearAnimationCallbacks ()V
public fun draw (Landroid/graphics/Canvas;)V
public final fun getAnimatedTransformation ()Lcoil/transform/AnimatedTransformation;
public final fun getConfig ()Landroid/graphics/Bitmap$Config;
public fun getIntrinsicHeight ()I
public fun getIntrinsicWidth ()I
Expand All @@ -39,6 +42,7 @@ public final class coil/drawable/MovieDrawable : android/graphics/drawable/Drawa
public fun isRunning ()Z
public fun registerAnimationCallback (Landroidx/vectordrawable/graphics/drawable/Animatable2Compat$AnimationCallback;)V
public fun setAlpha (I)V
public final fun setAnimatedTransformation (Lcoil/transform/AnimatedTransformation;)V
public fun setColorFilter (Landroid/graphics/ColorFilter;)V
public final fun setRepeatCount (I)V
public fun start ()V
Expand Down Expand Up @@ -75,7 +79,23 @@ public final class coil/drawable/ScaleDrawable : android/graphics/drawable/Drawa
}

public final class coil/request/Gifs {
public static final fun animatedTransformation (Lcoil/request/ImageRequest$Builder;Lcoil/transform/AnimatedTransformation;)Lcoil/request/ImageRequest$Builder;
public static final fun animatedTransformation (Lcoil/request/Parameters;)Lcoil/transform/AnimatedTransformation;
public static final fun repeatCount (Lcoil/request/ImageRequest$Builder;I)Lcoil/request/ImageRequest$Builder;
public static final fun repeatCount (Lcoil/request/Parameters;)Ljava/lang/Integer;
}

public abstract interface class coil/transform/AnimatedTransformation {
public abstract fun transform (Landroid/graphics/Canvas;)Lcoil/transform/AnimatedTransformation$PixelFormat;
}

public final class coil/transform/AnimatedTransformation$PixelFormat : java/lang/Enum {
public static final field OPAQUE Lcoil/transform/AnimatedTransformation$PixelFormat;
public static final field TRANSLUCENT Lcoil/transform/AnimatedTransformation$PixelFormat;
public static final field TRANSPARENT Lcoil/transform/AnimatedTransformation$PixelFormat;
public static final field UNKNOWN Lcoil/transform/AnimatedTransformation$PixelFormat;
public final fun getOpacity ()I
public static fun valueOf (Ljava/lang/String;)Lcoil/transform/AnimatedTransformation$PixelFormat;
public static fun values ()[Lcoil/transform/AnimatedTransformation$PixelFormat;
}

4 changes: 4 additions & 0 deletions coil-gif/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import coil.Library
import coil.addAndroidTestDependencies
import coil.setupLibraryModule
import org.jetbrains.kotlin.config.KotlinCompilerVersion

plugins {
id("com.android.library")
Expand All @@ -15,4 +17,6 @@ dependencies {

implementation(Library.ANDROIDX_CORE)
implementation(Library.ANDROIDX_VECTOR_DRAWABLE_ANIMATED)

addAndroidTestDependencies(KotlinCompilerVersion.VERSION)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package coil.transform

import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.os.Build.VERSION.SDK_INT
import androidx.core.graphics.drawable.toBitmap
import androidx.test.core.app.ApplicationProvider
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.animatedTransformation
import coil.util.RoundedCornerTransformation
import coil.util.decodeBitmapAsset
import coil.util.isSimilarTo
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import kotlin.test.assertTrue

class AnimatedTransformationTest {

private lateinit var context: Context
private lateinit var transformation: RoundedCornerTransformation
private lateinit var imageLoader: ImageLoader
private lateinit var imageRequestBuilder: ImageRequest.Builder

@Before
fun before() {
context = ApplicationProvider.getApplicationContext()
transformation = RoundedCornerTransformation()
imageLoader = ImageLoader.Builder(context)
.crossfade(false)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.build()
imageRequestBuilder = ImageRequest.Builder(context)
.bitmapConfig(Bitmap.Config.ARGB_8888)
.animatedTransformation(transformation)
}

@Test
fun gifTransformationTest() {
val actual = runBlocking {
val decoder = if (SDK_INT >= 28) {
ImageDecoderDecoder()
} else {
GifDecoder()
}
val imageRequest = imageRequestBuilder
.decoder(decoder)
.data("${ContentResolver.SCHEME_FILE}:///android_asset/animated.gif")
.build()
imageLoader.execute(imageRequest)
}
val expected = context.decodeBitmapAsset("animated_gif_rounded.png")
assertTrue(actual.drawable?.toBitmap()?.isSimilarTo(expected) ?: false)
}

@Test
fun heifTransformationTest() {
assumeTrue(SDK_INT >= 28)
val actual = runBlocking {
val imageRequest = imageRequestBuilder
.decoder(ImageDecoderDecoder())
.data("${ContentResolver.SCHEME_FILE}:///android_asset/animated.heif")
.build()
imageLoader.execute(imageRequest)
}
val expected = context.decodeBitmapAsset("animated_heif_rounded.png")
assertTrue(actual.drawable?.toBitmap()?.isSimilarTo(expected) ?: false)
}

@Test
fun webpTransformationTest() {
assumeTrue(SDK_INT >= 28)
val actual = runBlocking {
val imageRequest = imageRequestBuilder
.decoder(ImageDecoderDecoder())
.data("${ContentResolver.SCHEME_FILE}:///android_asset/animated.webp")
.build()
imageLoader.execute(imageRequest)
}
val expected = context.decodeBitmapAsset("animated_webp_rounded.png")
assertTrue(actual.drawable?.toBitmap()?.isSimilarTo(expected) ?: false)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package coil.util

import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.os.Build.VERSION.SDK_INT
import coil.transform.AnimatedTransformation

class RoundedCornerTransformation : AnimatedTransformation {
override fun transform(canvas: Canvas): AnimatedTransformation.PixelFormat {
val path = Path()
path.fillType = Path.FillType.INVERSE_EVEN_ODD
val width = canvas.width
val height = canvas.height
if (SDK_INT >= 21) {
path.addRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 20f, 20f, Path.Direction.CW)
} else {
path.addRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), 20f, 20f, Path.Direction.CW)
}
val paint = Paint()
paint.isAntiAlias = true
paint.color = Color.TRANSPARENT
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
canvas.drawPath(path, paint)
return AnimatedTransformation.PixelFormat.TRANSLUCENT
}
}
5 changes: 5 additions & 0 deletions coil-gif/src/main/java/coil/decode/GifDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.graphics.Movie
import android.os.Build.VERSION.SDK_INT
import coil.bitmap.BitmapPool
import coil.drawable.MovieDrawable
import coil.request.animatedTransformation
import coil.request.repeatCount
import coil.size.Size
import okio.BufferedSource
Expand Down Expand Up @@ -56,6 +57,9 @@ class GifDecoder : Decoder {

drawable.setRepeatCount(options.parameters.repeatCount() ?: MovieDrawable.REPEAT_INFINITE)

// Set the animated transformation to be applied on each frame.
drawable.setAnimatedTransformation(options.parameters.animatedTransformation())

DecodeResult(
drawable = drawable,
isSampled = false
Expand All @@ -64,5 +68,6 @@ class GifDecoder : Decoder {

companion object {
const val REPEAT_COUNT_KEY = "coil#repeat_count"
const val ANIMATED_TRANSFORMATION_KEY = "coil#animated_transformation"
}
}
10 changes: 10 additions & 0 deletions coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.core.util.component1
import androidx.core.util.component2
import coil.bitmap.BitmapPool
import coil.drawable.ScaleDrawable
import coil.request.animatedTransformation
import coil.request.repeatCount
import coil.size.PixelSize
import coil.size.Size
Expand Down Expand Up @@ -59,6 +60,14 @@ class ImageDecoderDecoder : Decoder {
}

val baseDrawable = decoderSource.decodeDrawable { info, _ ->

// Setup post processor for transformation after decoding
options.parameters.animatedTransformation()?.let { animatedTransformation ->
setPostProcessor { canvas ->
animatedTransformation.transform(canvas).opacity
}
}

// It's safe to delete the temp file here.
tempFile?.delete()

Expand Down Expand Up @@ -121,5 +130,6 @@ class ImageDecoderDecoder : Decoder {

companion object {
const val REPEAT_COUNT_KEY = GifDecoder.REPEAT_COUNT_KEY
const val ANIMATED_TRANSFORMATION_KEY = GifDecoder.ANIMATED_TRANSFORMATION_KEY
}
}
20 changes: 20 additions & 0 deletions coil-gif/src/main/java/coil/drawable/MovieDrawable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import coil.bitmap.BitmapPool
import coil.decode.DecodeUtils
import coil.decode.ImageDecoderDecoder
import coil.size.Scale
import coil.transform.AnimatedTransformation

/**
* A [Drawable] that supports rendering [Movie]s (i.e. GIFs).
Expand Down Expand Up @@ -54,6 +55,8 @@ class MovieDrawable @JvmOverloads constructor(
private var repeatCount = REPEAT_INFINITE
private var loopIteration = 0

private var animatedTransformation: AnimatedTransformation? = null

init {
require(SDK_INT < 26 || config != Bitmap.Config.HARDWARE) { "Bitmap config must not be hardware." }
}
Expand Down Expand Up @@ -89,6 +92,9 @@ class MovieDrawable @JvmOverloads constructor(
movie.draw(this, 0f, 0f, paint)
}

// Apply transformation on frame
animatedTransformation?.transform(softwareCanvas)

// Draw onto the input canvas (may or may not be hardware).
canvas.withSave {
translate(hardwareDx, hardwareDy)
Expand Down Expand Up @@ -121,6 +127,20 @@ class MovieDrawable @JvmOverloads constructor(
/** Get the number of times the animation will repeat. */
fun getRepeatCount(): Int = repeatCount

/**
* Set the transformation to be applied on each frame.
*/
fun setAnimatedTransformation(animatedTransformation: AnimatedTransformation?) {
this.animatedTransformation = animatedTransformation
}

/**
* Get the applied transformation.
*/
fun getAnimatedTransformation(): AnimatedTransformation? {
return animatedTransformation
}

override fun setAlpha(alpha: Int) {
require(alpha in 0..255) { "Invalid alpha: $alpha" }
paint.alpha = alpha
Expand Down
19 changes: 19 additions & 0 deletions coil-gif/src/main/java/coil/request/Gifs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package coil.request

import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.Drawable
import coil.decode.GifDecoder.Companion.ANIMATED_TRANSFORMATION_KEY
import coil.decode.GifDecoder.Companion.REPEAT_COUNT_KEY
import coil.drawable.MovieDrawable
import coil.transform.AnimatedTransformation

/**
* Set the number of times to repeat the animation if the result is an animated [Drawable].
Expand All @@ -23,3 +25,20 @@ fun ImageRequest.Builder.repeatCount(repeatCount: Int): ImageRequest.Builder {

/** Get the number of times to repeat the animation if the result is an animated [Drawable]. */
fun Parameters.repeatCount(): Int? = value(REPEAT_COUNT_KEY) as Int?

/**
* Set the transformation for GIFs, animated WebPs, and animated HEIFs.
*
* @see [MovieDrawable.setAnimatedTransformation]
* @see [coil.decode.ImageDecoderDecoder.decode]
*/
fun ImageRequest.Builder.animatedTransformation(animatedTransformation: AnimatedTransformation): ImageRequest.Builder {
return setParameter(ANIMATED_TRANSFORMATION_KEY, animatedTransformation)
}

/**
* Get the [AnimatedTransformation] applied on GIFs, animated WebPs, and animated HEIFs.
*/
fun Parameters.animatedTransformation(): AnimatedTransformation? {
return value(ANIMATED_TRANSFORMATION_KEY) as AnimatedTransformation?
}
32 changes: 32 additions & 0 deletions coil-gif/src/main/java/coil/transform/AnimatedTransformation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package coil.transform

import android.graphics.Canvas
import android.graphics.PixelFormat as AndroidPixelFormat

/**
* An interface for applying transformation on GIFs, animated WebPs, and animated HEIFs.
*/
interface AnimatedTransformation {

/**
* Apply transformation on [canvas]
*
* Note: Do not allocate objects in this method as it will be invoked for each frame of the animation.
*
* @param canvas [Canvas] on which transformation to be applied.
*
* @return Opacity of the result after drawing.
* @see AndroidPixelFormat
*/
fun transform(canvas: Canvas): PixelFormat

/**
* Opacity of the result after drawing.
*/
enum class PixelFormat(val opacity: Int) {
UNKNOWN(AndroidPixelFormat.UNKNOWN),
TRANSLUCENT(AndroidPixelFormat.TRANSLUCENT),
OPAQUE(AndroidPixelFormat.OPAQUE),
TRANSPARENT(AndroidPixelFormat.TRANSPARENT)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.