diff --git a/coil-gif/api/coil-gif.api b/coil-gif/api/coil-gif.api index 23932f706d..37330ad771 100644 --- a/coil-gif/api/coil-gif.api +++ b/coil-gif/api/coil-gif.api @@ -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 ()V @@ -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 ()V @@ -30,6 +32,7 @@ public final class coil/drawable/MovieDrawable : android/graphics/drawable/Drawa public synthetic fun (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 @@ -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 @@ -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; +} + diff --git a/coil-gif/build.gradle.kts b/coil-gif/build.gradle.kts index 9c3c87b3db..73b4b80d66 100644 --- a/coil-gif/build.gradle.kts +++ b/coil-gif/build.gradle.kts @@ -1,5 +1,7 @@ import coil.Library +import coil.addAndroidTestDependencies import coil.setupLibraryModule +import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") @@ -15,4 +17,6 @@ dependencies { implementation(Library.ANDROIDX_CORE) implementation(Library.ANDROIDX_VECTOR_DRAWABLE_ANIMATED) + + addAndroidTestDependencies(KotlinCompilerVersion.VERSION) } diff --git a/coil-gif/src/androidTest/java/coil/transform/AnimatedTransformationTest.kt b/coil-gif/src/androidTest/java/coil/transform/AnimatedTransformationTest.kt new file mode 100644 index 0000000000..e233651afc --- /dev/null +++ b/coil-gif/src/androidTest/java/coil/transform/AnimatedTransformationTest.kt @@ -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) + } +} diff --git a/coil-gif/src/androidTest/java/coil/util/RoundedCornerTransformation.kt b/coil-gif/src/androidTest/java/coil/util/RoundedCornerTransformation.kt new file mode 100644 index 0000000000..9f9521748b --- /dev/null +++ b/coil-gif/src/androidTest/java/coil/util/RoundedCornerTransformation.kt @@ -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 + } +} diff --git a/coil-gif/src/main/java/coil/decode/GifDecoder.kt b/coil-gif/src/main/java/coil/decode/GifDecoder.kt index a16954fa23..482e477456 100644 --- a/coil-gif/src/main/java/coil/decode/GifDecoder.kt +++ b/coil-gif/src/main/java/coil/decode/GifDecoder.kt @@ -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 @@ -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 @@ -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" } } diff --git a/coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt b/coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt index cc9f05a532..9d2855c11f 100644 --- a/coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt +++ b/coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt @@ -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 @@ -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() @@ -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 } } diff --git a/coil-gif/src/main/java/coil/drawable/MovieDrawable.kt b/coil-gif/src/main/java/coil/drawable/MovieDrawable.kt index f13a89503d..490f84d96a 100644 --- a/coil-gif/src/main/java/coil/drawable/MovieDrawable.kt +++ b/coil-gif/src/main/java/coil/drawable/MovieDrawable.kt @@ -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). @@ -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." } } @@ -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) @@ -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 diff --git a/coil-gif/src/main/java/coil/request/Gifs.kt b/coil-gif/src/main/java/coil/request/Gifs.kt index e29b338152..f5d7148ef3 100644 --- a/coil-gif/src/main/java/coil/request/Gifs.kt +++ b/coil-gif/src/main/java/coil/request/Gifs.kt @@ -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]. @@ -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? +} diff --git a/coil-gif/src/main/java/coil/transform/AnimatedTransformation.kt b/coil-gif/src/main/java/coil/transform/AnimatedTransformation.kt new file mode 100644 index 0000000000..a1dc505b17 --- /dev/null +++ b/coil-gif/src/main/java/coil/transform/AnimatedTransformation.kt @@ -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) + } +} diff --git a/coil-test/src/main/assets/animated_gif_rounded.png b/coil-test/src/main/assets/animated_gif_rounded.png new file mode 100644 index 0000000000..be210f6f7f Binary files /dev/null and b/coil-test/src/main/assets/animated_gif_rounded.png differ diff --git a/coil-test/src/main/assets/animated_heif_rounded.png b/coil-test/src/main/assets/animated_heif_rounded.png new file mode 100644 index 0000000000..7512cfb6de Binary files /dev/null and b/coil-test/src/main/assets/animated_heif_rounded.png differ diff --git a/coil-test/src/main/assets/animated_webp_rounded.png b/coil-test/src/main/assets/animated_webp_rounded.png new file mode 100644 index 0000000000..360757f962 Binary files /dev/null and b/coil-test/src/main/assets/animated_webp_rounded.png differ