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

Callback support for animated drawables #676

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions coil-gif/api/coil-gif.api
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
public final class coil/decode/GifDecoder : coil/decode/Decoder {
public static final field ANIMATED_TRANSFORMATION_KEY Ljava/lang/String;
public static final field ANIMATION_END_CALLBACK_KEY Ljava/lang/String;
public static final field ANIMATION_START_CALLBACK_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 @@ -12,6 +14,8 @@ 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 ANIMATION_END_CALLBACK_KEY Ljava/lang/String;
public static final field ANIMATION_START_CALLBACK_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 Down Expand Up @@ -81,6 +85,10 @@ 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 animationEndCallback (Lcoil/request/Parameters;)Lkotlin/jvm/functions/Function0;
public static final fun animationStartCallback (Lcoil/request/Parameters;)Lkotlin/jvm/functions/Function0;
public static final fun onAnimationEnd (Lcoil/request/ImageRequest$Builder;Lkotlin/jvm/functions/Function0;)Lcoil/request/ImageRequest$Builder;
public static final fun onAnimationStart (Lcoil/request/ImageRequest$Builder;Lkotlin/jvm/functions/Function0;)Lcoil/request/ImageRequest$Builder;
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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package coil.callback

import android.content.ContentResolver
import android.content.Context
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.activityScenarioRule
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.onAnimationEnd
import coil.request.onAnimationStart
import coil.request.repeatCount
import coil.util.TestActivity
import coil.util.activity
import coil.util.runBlockingTest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertTrue

class AnimationCallbacksTest {

private lateinit var context: Context
private lateinit var imageLoader: ImageLoader

@get:Rule
val activityRule = activityScenarioRule<TestActivity>()

@Before
fun before() {
context = ApplicationProvider.getApplicationContext()
imageLoader = ImageLoader.Builder(context)
.crossfade(false)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.build()
activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
}

@After
fun after() {
imageLoader.shutdown()
}

@Test
fun callbacksTest() = runBlockingTest {
val imageView = activityRule.scenario.activity.imageView
var isStartCalled = MutableStateFlow(false)
var isEndCalled = MutableStateFlow(false)
val decoder = if (Build.VERSION.SDK_INT >= 28) {
ImageDecoderDecoder()
} else {
GifDecoder()
}

val imageRequest = ImageRequest.Builder(context)
.repeatCount(0)
.onAnimationStart {
isStartCalled.value = true
}
.onAnimationEnd {
isEndCalled.value = true
}
.target(imageView)
.decoder(decoder)
.data("${ContentResolver.SCHEME_FILE}:///android_asset/animated.gif")
.build()
imageLoader.enqueue(imageRequest)
assertTrue(isStartCalled.first { it })
assertTrue(isEndCalled.first { it })
}
}
19 changes: 19 additions & 0 deletions coil-gif/src/main/java/coil/decode/GifDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ package coil.decode

import android.graphics.Bitmap
import android.graphics.Movie
import android.graphics.drawable.Drawable
import android.os.Build.VERSION.SDK_INT
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import coil.bitmap.BitmapPool
import coil.drawable.MovieDrawable
import coil.request.animatedTransformation
import coil.request.animationEndCallback
import coil.request.animationStartCallback
import coil.request.repeatCount
import coil.size.Size
import okio.BufferedSource
Expand Down Expand Up @@ -54,6 +58,19 @@ class GifDecoder : Decoder {

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

// Set the start and end animation callbacks if any one is supplied through the request.
if (options.parameters.animationStartCallback() != null || options.parameters.animationEndCallback() != null) {
drawable.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationStart(drawable: Drawable?) {
options.parameters.animationStartCallback()?.invoke()
}

override fun onAnimationEnd(drawable: Drawable?) {
options.parameters.animationEndCallback()?.invoke()
}
})
}

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

Expand All @@ -66,5 +83,7 @@ class GifDecoder : Decoder {
companion object {
const val REPEAT_COUNT_KEY = "coil#repeat_count"
const val ANIMATED_TRANSFORMATION_KEY = "coil#animated_transformation"
const val ANIMATION_START_CALLBACK_KEY = "coil#animation_start_callback"
const val ANIMATION_END_CALLBACK_KEY = "coil#animation_end_callback"
}
}
156 changes: 90 additions & 66 deletions coil-gif/src/main/java/coil/decode/ImageDecoderDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package coil.decode

import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.drawable.Animatable2
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.Drawable
import android.os.Build.VERSION.SDK_INT
import androidx.annotation.RequiresApi
import androidx.core.graphics.decodeDrawable
Expand All @@ -13,10 +15,14 @@ import androidx.core.util.component2
import coil.bitmap.BitmapPool
import coil.drawable.ScaleDrawable
import coil.request.animatedTransformation
import coil.request.animationEndCallback
import coil.request.animationStartCallback
import coil.request.repeatCount
import coil.size.PixelSize
import coil.size.Size
import coil.util.asPostProcessor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.BufferedSource
import okio.buffer
import okio.sink
Expand All @@ -43,88 +49,106 @@ class ImageDecoderDecoder : Decoder {
source: BufferedSource,
size: Size,
options: Options
): DecodeResult = withInterruptibleSource(source) { interruptibleSource ->
var tempFile: File? = null

try {
var isSampled = false

val bufferedSource = interruptibleSource.buffer()
val decoderSource = if (SDK_INT >= 30) {
// Buffer the source into memory.
ImageDecoder.createSource(ByteBuffer.wrap(bufferedSource.use { it.readByteArray() }))
} else {
// Work around https://issuetracker.google.com/issues/139371066 by copying the source to a temp file.
tempFile = File.createTempFile("tmp", null, null)
bufferedSource.use { tempFile.sink().use(it::readAll) }
ImageDecoder.createSource(tempFile)
}

val baseDrawable = decoderSource.decodeDrawable { info, _ ->
// It's safe to delete the temp file here.
tempFile?.delete()
): DecodeResult {
var isSampled = false
val baseDrawable = withInterruptibleSource(source) { interruptibleSource ->
var tempFile: File? = null
try {
val bufferedSource = interruptibleSource.buffer()
val decoderSource = if (SDK_INT >= 30) {
// Buffer the source into memory.
ImageDecoder.createSource(ByteBuffer.wrap(bufferedSource.use { it.readByteArray() }))
} else {
// Work around https://issuetracker.google.com/issues/139371066 by copying the source to a temp file.
tempFile = File.createTempFile("tmp", null, null)
bufferedSource.use { tempFile.sink().use(it::readAll) }
ImageDecoder.createSource(tempFile)
}

if (size is PixelSize) {
val (srcWidth, srcHeight) = info.size
val multiplier = DecodeUtils.computeSizeMultiplier(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = size.width,
dstHeight = size.height,
scale = options.scale
)

// Set the target size if the image is larger than the requested dimensions
// or the request requires exact dimensions.
isSampled = multiplier < 1
if (isSampled || !options.allowInexactSize) {
val targetWidth = (multiplier * srcWidth).roundToInt()
val targetHeight = (multiplier * srcHeight).roundToInt()
setTargetSize(targetWidth, targetHeight)
return@withInterruptibleSource decoderSource.decodeDrawable { info, _ ->
// It's safe to delete the temp file here.
tempFile?.delete()

if (size is PixelSize) {
val (srcWidth, srcHeight) = info.size
val multiplier = DecodeUtils.computeSizeMultiplier(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = size.width,
dstHeight = size.height,
scale = options.scale
)

// Set the target size if the image is larger than the requested dimensions
// or the request requires exact dimensions.
isSampled = multiplier < 1
if (isSampled || !options.allowInexactSize) {
val targetWidth = (multiplier * srcWidth).roundToInt()
val targetHeight = (multiplier * srcHeight).roundToInt()
setTargetSize(targetWidth, targetHeight)
}
}
}

allocator = if (options.config == Bitmap.Config.HARDWARE) {
ImageDecoder.ALLOCATOR_HARDWARE
} else {
ImageDecoder.ALLOCATOR_SOFTWARE
}
allocator = if (options.config == Bitmap.Config.HARDWARE) {
ImageDecoder.ALLOCATOR_HARDWARE
} else {
ImageDecoder.ALLOCATOR_SOFTWARE
}

memorySizePolicy = if (options.allowRgb565) {
ImageDecoder.MEMORY_POLICY_LOW_RAM
} else {
ImageDecoder.MEMORY_POLICY_DEFAULT
}
memorySizePolicy = if (options.allowRgb565) {
ImageDecoder.MEMORY_POLICY_LOW_RAM
} else {
ImageDecoder.MEMORY_POLICY_DEFAULT
}

if (options.colorSpace != null) {
setTargetColorSpace(options.colorSpace)
}
if (options.colorSpace != null) {
setTargetColorSpace(options.colorSpace)
}

isUnpremultipliedRequired = !options.premultipliedAlpha
isUnpremultipliedRequired = !options.premultipliedAlpha

postProcessor = options.parameters.animatedTransformation()?.asPostProcessor()
postProcessor = options.parameters.animatedTransformation()?.asPostProcessor()
}
} finally {
tempFile?.delete()
}
}

val drawable = if (baseDrawable is AnimatedImageDrawable) {
baseDrawable.repeatCount = options.parameters.repeatCount() ?: AnimatedImageDrawable.REPEAT_INFINITE

// Wrap AnimatedImageDrawable in a ScaleDrawable so it always scales to fill its bounds.
ScaleDrawable(baseDrawable, options.scale)
} else {
baseDrawable
val drawable = if (baseDrawable is AnimatedImageDrawable) {
baseDrawable.repeatCount = options.parameters.repeatCount() ?: AnimatedImageDrawable.REPEAT_INFINITE

// Set the start and end animation callbacks if any one is supplied through the request.
if (options.parameters.animationStartCallback() != null ||
options.parameters.animationEndCallback() != null) {
withContext(Dispatchers.Main.immediate) {
baseDrawable.registerAnimationCallback(object : Animatable2.AnimationCallback() {
override fun onAnimationStart(drawable: Drawable?) {
options.parameters.animationStartCallback()?.invoke()
}

override fun onAnimationEnd(drawable: Drawable?) {
options.parameters.animationEndCallback()?.invoke()
}
})
}
}

DecodeResult(
drawable = drawable,
isSampled = isSampled
)
} finally {
tempFile?.delete()
// Wrap AnimatedImageDrawable in a ScaleDrawable so it always scales to fill its bounds.
ScaleDrawable(baseDrawable, options.scale)
} else {
baseDrawable
}

return DecodeResult(
drawable = drawable,
isSampled = isSampled
)
}

companion object {
const val REPEAT_COUNT_KEY = GifDecoder.REPEAT_COUNT_KEY
const val ANIMATED_TRANSFORMATION_KEY = GifDecoder.ANIMATED_TRANSFORMATION_KEY
const val ANIMATION_START_CALLBACK_KEY = GifDecoder.ANIMATION_START_CALLBACK_KEY
const val ANIMATION_END_CALLBACK_KEY = GifDecoder.ANIMATION_END_CALLBACK_KEY
}
}
24 changes: 23 additions & 1 deletion coil-gif/src/main/java/coil/request/Gifs.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
@file:Suppress("unused")
@file:Suppress("unused", "UNCHECKED_CAST")
@file:JvmName("Gifs")

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.ANIMATION_END_CALLBACK_KEY
import coil.decode.GifDecoder.Companion.ANIMATION_START_CALLBACK_KEY
import coil.decode.GifDecoder.Companion.REPEAT_COUNT_KEY
import coil.drawable.MovieDrawable
import coil.transform.AnimatedTransformation
Expand Down Expand Up @@ -35,3 +37,23 @@ fun ImageRequest.Builder.animatedTransformation(animatedTransformation: Animated
fun Parameters.animatedTransformation(): AnimatedTransformation? {
return value(ANIMATED_TRANSFORMATION_KEY) as AnimatedTransformation?
}

/** Set the callback to be invoked at the start of the animation if the result is an animated [Drawable]. */
fun ImageRequest.Builder.onAnimationStart(callback: (() -> Unit)?): ImageRequest.Builder {
return setParameter(ANIMATION_START_CALLBACK_KEY, callback)
}

/** Get the callback to be invoked at the start of the animation if the result is an animated [Drawable]. */
fun Parameters.animationStartCallback(): (() -> Unit)? {
return value(ANIMATION_START_CALLBACK_KEY) as (() -> Unit)?
}

/** Set the callback to be invoked at the end of the animation if the result is an animated [Drawable]. */
fun ImageRequest.Builder.onAnimationEnd(callback: (() -> Unit)?): ImageRequest.Builder {
return setParameter(ANIMATION_END_CALLBACK_KEY, callback)
}

/** Get the callback to be invoked at the end of the animation if the result is an animated [Drawable]. */
fun Parameters.animationEndCallback(): (() -> Unit)? {
return value(ANIMATION_END_CALLBACK_KEY) as (() -> Unit)?
}