diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48e015800..d1caa2d6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,8 @@ jobs: runs-on: macOS-latest needs: build strategy: + # Allow tests to continue on other devices if they fail on one device. + fail-fast: false matrix: api-level: [23, 26, 29] env: diff --git a/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt b/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt index b5fa951d1..7ac7dc282 100644 --- a/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt +++ b/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt @@ -21,7 +21,7 @@ object Versions { } object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:4.2.0-alpha11" + const val androidGradlePlugin = "com.android.tools.build:gradle:4.2.0-alpha12" const val gradleMavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.13.0" @@ -86,10 +86,13 @@ object Libs { const val appcompat = "androidx.appcompat:appcompat:1.3.0-alpha02" } - const val coil = "io.coil-kt:coil:1.0.0-rc3" + object Coil { + private const val version = "1.0.0-rc3" + const val coil = "io.coil-kt:coil:$version" + const val gif = "io.coil-kt:coil-gif:$version" + } const val truth = "com.google.truth:truth:1.0.1" - const val mockk = "io.mockk:mockk-android:1.10.0" const val mockWebServer = "com.squareup.okhttp3:mockwebserver:3.12.2" } diff --git a/coil/README.md b/coil/README.md index 764b92bf4..6ceec2e33 100644 --- a/coil/README.md +++ b/coil/README.md @@ -23,7 +23,7 @@ There is also a version of this function which accepts a Coil [`ImageRequest`](h ```kotlin CoilImage( - request = GetRequest.Builder(ContextAmbient.current) + request = ImageRequest.Builder(ContextAmbient.current) .data("https://loremflickr.com/300/300") .transformations(CircleCropTransformation()) .build() @@ -74,20 +74,24 @@ CoilImage( data = "https://random.image", ) { imageState -> when (imageState) { - is CoilImageState.Success -> { + is ImageLoadState.Success -> { MaterialLoadingImage( result = imageState, fadeInEnabled = true, fadeInDurationMs = 600, ) } - is CoilImageState.Error -> /* TODO */ - CoilImageState.Loading -> /* TODO */ - CoilImageState.Empty -> /* TODO */ + is ImageLoadState.Error -> /* TODO */ + ImageLoadState.Loading -> /* TODO */ + ImageLoadState.Empty -> /* TODO */ } } ``` +## GIFs + +Accompanist Coil supports GIFs through Coil's own GIF support. Follow the [setup instructions](https://coil-kt.github.io/coil/gifs/) and it should just work. + ## Download ```groovy @@ -100,10 +104,6 @@ dependencies { } ``` -## Limitations - -* Compose currently only supports static bitmap images, which means that we need to convert the resulting images to a `Bitmap`. This means that using things like Coil's [GIF support](https://coil-kt.github.io/coil/gifs/) will result in only the first frame being rendered, instead of animating. - Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. ### What's the goal of the library? diff --git a/coil/api/coil.api b/coil/api/coil.api index d4fee7111..7549a9e3a 100644 --- a/coil/api/coil.api +++ b/coil/api/coil.api @@ -1,65 +1,12 @@ public final class dev/chrisbanes/accompanist/coil/CoilImage { - public static final fun CoilImage (Lcoil/request/ImageRequest;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun CoilImage (Lcoil/request/ImageRequest;Landroidx/compose/ui/Modifier;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun CoilImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun CoilImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun CoilImage (Lcoil/request/ImageRequest;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLkotlin/jvm/functions/Function2;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun CoilImage (Lcoil/request/ImageRequest;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun CoilImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLkotlin/jvm/functions/Function2;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun CoilImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun CoilImageWithCrossfade (Lcoil/request/ImageRequest;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;ILcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public static final fun CoilImageWithCrossfade (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;ILcoil/ImageLoader;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public static fun ErrorResult$annotations ()V - public static final fun MaterialLoadingImage (Landroidx/compose/ui/graphics/ImageAsset;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/animation/core/AnimationClockObservable;ZILandroidx/compose/runtime/Composer;II)V - public static final fun MaterialLoadingImage (Ldev/chrisbanes/accompanist/coil/CoilImageState$Success;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/animation/core/AnimationClockObservable;ZZILandroidx/compose/runtime/Composer;II)V public static fun RequestResult$annotations ()V public static fun SuccessResult$annotations ()V } -public abstract class dev/chrisbanes/accompanist/coil/CoilImageState { -} - -public final class dev/chrisbanes/accompanist/coil/CoilImageState$Empty : dev/chrisbanes/accompanist/coil/CoilImageState { - public static final field INSTANCE Ldev/chrisbanes/accompanist/coil/CoilImageState$Empty; -} - -public final class dev/chrisbanes/accompanist/coil/CoilImageState$Error : dev/chrisbanes/accompanist/coil/CoilImageState { - public fun (Landroidx/compose/ui/graphics/ImageAsset;Ljava/lang/Throwable;)V - public synthetic fun (Lcoil/request/ErrorResult;JLkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Landroidx/compose/ui/graphics/ImageAsset; - public final fun component2 ()Ljava/lang/Throwable; - public final fun copy (Landroidx/compose/ui/graphics/ImageAsset;Ljava/lang/Throwable;)Ldev/chrisbanes/accompanist/coil/CoilImageState$Error; - public static synthetic fun copy$default (Ldev/chrisbanes/accompanist/coil/CoilImageState$Error;Landroidx/compose/ui/graphics/ImageAsset;Ljava/lang/Throwable;ILjava/lang/Object;)Ldev/chrisbanes/accompanist/coil/CoilImageState$Error; - public fun equals (Ljava/lang/Object;)Z - public final fun getImage ()Landroidx/compose/ui/graphics/ImageAsset; - public final fun getThrowable ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class dev/chrisbanes/accompanist/coil/CoilImageState$Loading : dev/chrisbanes/accompanist/coil/CoilImageState { - public static final field INSTANCE Ldev/chrisbanes/accompanist/coil/CoilImageState$Loading; -} - -public final class dev/chrisbanes/accompanist/coil/CoilImageState$Success : dev/chrisbanes/accompanist/coil/CoilImageState { - public fun (Landroidx/compose/ui/graphics/ImageAsset;Lcoil/decode/DataSource;)V - public synthetic fun (Lcoil/request/SuccessResult;JLkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Landroidx/compose/ui/graphics/ImageAsset; - public final fun component2 ()Lcoil/decode/DataSource; - public final fun copy (Landroidx/compose/ui/graphics/ImageAsset;Lcoil/decode/DataSource;)Ldev/chrisbanes/accompanist/coil/CoilImageState$Success; - public static synthetic fun copy$default (Ldev/chrisbanes/accompanist/coil/CoilImageState$Success;Landroidx/compose/ui/graphics/ImageAsset;Lcoil/decode/DataSource;ILjava/lang/Object;)Ldev/chrisbanes/accompanist/coil/CoilImageState$Success; - public fun equals (Ljava/lang/Object;)Z - public final fun getImage ()Landroidx/compose/ui/graphics/ImageAsset; - public final fun getSource ()Lcoil/decode/DataSource; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class dev/chrisbanes/accompanist/coil/ImageLoadingColorMatrix : android/graphics/ColorMatrix { - public fun ()V - public fun (FFF)V - public synthetic fun (FFFILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getAlphaFraction ()F - public final fun getBrightnessFraction ()F - public final fun getSaturationFraction ()F - public final fun setAlphaFraction (F)V - public final fun setBrightnessFraction (F)V - public final fun setSaturationFraction (F)V -} - diff --git a/coil/build.gradle b/coil/build.gradle index b2767973b..334ddda88 100644 --- a/coil/build.gradle +++ b/coil/build.gradle @@ -53,12 +53,6 @@ android { } packagingOptions { - def e = excludes - // AGP 4.1.0-alpha06 added an exclude which breaks Kotlin libraries. Remove the exclude - // until we upgrade to an AGP version with a fix - e.remove("/META-INF/*.kotlin_module") - excludes = e - // Some of the Coil META-INF files conflict with coroutines-test. Exclude them to enable // our test APK to build (has no effect on our AARs) excludes += "/META-INF/AL2.0" @@ -79,7 +73,9 @@ afterEvaluate { } dependencies { - api Libs.coil + api project(':imageloading-core') + + api Libs.Coil.coil implementation Libs.AndroidX.coreKtx implementation Libs.AndroidX.Compose.runtime @@ -90,7 +86,6 @@ dependencies { androidTestImplementation Libs.junit androidTestImplementation Libs.truth - androidTestImplementation Libs.mockk androidTestImplementation Libs.mockWebServer diff --git a/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt b/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt index f5233c861..6dc21d2f6 100644 --- a/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt +++ b/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt @@ -45,12 +45,13 @@ import androidx.ui.test.onNodeWithText import coil.EventListener import coil.ImageLoader import coil.annotation.ExperimentalCoilApi +import coil.decode.Options +import coil.fetch.Fetcher import coil.request.CachePolicy import coil.request.ImageRequest import com.google.common.truth.Truth.assertThat import dev.chrisbanes.accompanist.coil.test.R -import io.mockk.mockk -import io.mockk.verify +import dev.chrisbanes.accompanist.imageloading.ImageLoadState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -94,8 +95,8 @@ class CoilTest { } @Test - fun onRequestCompleted() { - val results = ArrayList() + fun onRequestCompleted_fromImageRequest() { + val results = ArrayList() val latch = CountDownLatch(1) composeTestRule.setContent { @@ -115,7 +116,33 @@ class CoilTest { composeTestRule.runOnIdle { // And assert that we got a single successful result assertThat(results).hasSize(1) - assertThat(results[0]).isInstanceOf(CoilImageState.Success::class.java) + assertThat(results[0]).isInstanceOf(ImageLoadState.Success::class.java) + } + } + + @Test + fun onRequestCompleted_fromBuilder() { + val results = ArrayList() + val latch = CountDownLatch(1) + + composeTestRule.setContent { + CoilImage( + data = resourceUri(R.raw.sample), + requestBuilder = { + listener { _, _ -> latch.countDown() } + }, + modifier = Modifier.preferredSize(128.dp, 128.dp), + onRequestCompleted = { results += it } + ) + } + + // Wait for the Coil request listener to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.runOnIdle { + // And assert that we got a single successful result + assertThat(results).hasSize(1) + assertThat(results[0]).isInstanceOf(ImageLoadState.Success::class.java) } } @@ -170,8 +197,15 @@ class CoilTest { val context = InstrumentationRegistry.getInstrumentation().targetContext val latch = CountDownLatch(1) - // Build a custom ImageLoader with a mocked EventListener - val eventListener = mockk(relaxed = true) + // Build a custom ImageLoader with a fake EventListener + val eventListener = object : EventListener { + var startCalled = 0 + private set + + override fun fetchStart(request: ImageRequest, fetcher: Fetcher<*>, options: Options) { + startCalled++ + } + } val imageLoader = ImageLoader.Builder(context) .eventListener(eventListener) .build() @@ -189,8 +223,7 @@ class CoilTest { latch.await(5, TimeUnit.SECONDS) // Verify that our eventListener was invoked - verify(atLeast = 1) { eventListener.fetchStart(any(), any(), any()) } - verify(atLeast = 1) { eventListener.fetchEnd(any(), any(), any(), any()) } + assertThat(eventListener.startCalled).isAtLeast(1) } @OptIn(ExperimentalCoroutinesApi::class) @@ -319,7 +352,7 @@ class CoilTest { @Test fun content_error() { val latch = CountDownLatch(1) - val states = ArrayList() + val states = ArrayList() composeTestRule.setContent { CoilImage( @@ -339,16 +372,16 @@ class CoilTest { composeTestRule.runOnIdle { assertThat(states).hasSize(3) - assertThat(states[0]).isEqualTo(CoilImageState.Empty) - assertThat(states[1]).isEqualTo(CoilImageState.Loading) - assertThat(states[2]).isInstanceOf(CoilImageState.Error::class.java) + assertThat(states[0]).isEqualTo(ImageLoadState.Empty) + assertThat(states[1]).isEqualTo(ImageLoadState.Loading) + assertThat(states[2]).isInstanceOf(ImageLoadState.Error::class.java) } } @Test fun content_success() { val latch = CountDownLatch(1) - val states = ArrayList() + val states = ArrayList() composeTestRule.setContent { CoilImage( @@ -368,9 +401,9 @@ class CoilTest { composeTestRule.runOnIdle { assertThat(states).hasSize(3) - assertThat(states[0]).isEqualTo(CoilImageState.Empty) - assertThat(states[1]).isEqualTo(CoilImageState.Loading) - assertThat(states[2]).isInstanceOf(CoilImageState.Success::class.java) + assertThat(states[0]).isEqualTo(ImageLoadState.Empty) + assertThat(states[1]).isEqualTo(ImageLoadState.Loading) + assertThat(states[2]).isInstanceOf(ImageLoadState.Success::class.java) } } diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt index 69f0ad751..05cb9d071 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt +++ b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt @@ -19,34 +19,27 @@ package dev.chrisbanes.accompanist.coil -import android.graphics.drawable.Drawable import androidx.compose.foundation.Image import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.launchInComposition -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.stateFor import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.WithConstraints import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ImageAsset -import androidx.compose.ui.graphics.asImageAsset import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ContextAmbient import androidx.compose.ui.unit.IntSize -import androidx.core.graphics.drawable.toBitmap import coil.Coil import coil.ImageLoader -import coil.decode.DataSource import coil.imageLoader import coil.request.ImageRequest import coil.request.ImageResult +import dev.chrisbanes.accompanist.imageloading.DataSource +import dev.chrisbanes.accompanist.imageloading.ImageLoad +import dev.chrisbanes.accompanist.imageloading.ImageLoadState +import dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage +import dev.chrisbanes.accompanist.imageloading.toPainter /** * Creates a composable that will attempt to load the given [data] using [Coil], and provides @@ -57,16 +50,17 @@ import coil.request.ImageResult * data = "https://www.image.url", * ) { imageState -> * when (imageState) { - * is CoilImageState.Success -> // TODO - * is CoilImageState.Error -> // TODO - * CoilImageState.Loading -> // TODO - * CoilImageState.Empty -> // TODO + * is ImageLoadState.Success -> // TODO + * is ImageLoadState.Error -> // TODO + * ImageLoadState.Loading -> // TODO + * ImageLoadState.Empty -> // TODO * } * } * ``` * * @param data The data to load. See [ImageRequest.Builder.data] for the types allowed. * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param requestBuilder Optional builder for the [ImageRequest]. * @param imageLoader The [ImageLoader] to use when requesting the image. Defaults to [Coil]'s * default image loader. * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing @@ -78,14 +72,16 @@ import coil.request.ImageResult fun CoilImage( data: Any, modifier: Modifier = Modifier, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - content: @Composable (imageState: CoilImageState) -> Unit + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + content: @Composable (imageLoadState: ImageLoadState) -> Unit ) { CoilImage( request = data.toImageRequest(), modifier = modifier, + requestBuilder = requestBuilder, imageLoader = imageLoader, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, onRequestCompleted = onRequestCompleted, @@ -102,10 +98,10 @@ fun CoilImage( * request = ImageRequest.Builder(context).data(...).build(), * ) { imageState -> * when (imageState) { - * is CoilImageState.Success -> // TODO - * is CoilImageState.Error -> // TODO - * CoilImageState.Loading -> // TODO - * CoilImageState.Empty -> // TODO + * is ImageLoadState.Success -> // TODO + * is ImageLoadState.Error -> // TODO + * ImageLoadState.Loading -> // TODO + * ImageLoadState.Empty -> // TODO * } * } * ``` @@ -113,6 +109,7 @@ fun CoilImage( * @param request The request to execute. If the request does not have a [ImageRequest.sizeResolver] * set, one will be set on the request using the layout constraints. * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param requestBuilder Optional builder for the [ImageRequest]. * @param imageLoader The [ImageLoader] to use when requesting the image. Defaults to [Coil]'s * default image loader. * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing @@ -124,63 +121,41 @@ fun CoilImage( fun CoilImage( request: ImageRequest, modifier: Modifier = Modifier, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - content: @Composable (imageState: CoilImageState) -> Unit + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + content: @Composable (imageLoadState: ImageLoadState) -> Unit ) { - var state by stateFor(request) { CoilImageState.Empty } - - // This may look a little weird, but allows the launchInComposition callback to always - // invoke the last provided [onRequestCompleted]. - // - // If a composition happens *after* launchInComposition has launched, the given - // [onRequestCompleted] might have changed. If the actor lambda below directly referenced - // [onRequestCompleted] it would have captured access to the initial onRequestCompleted - // value, not the latest. - // - // This `callback` state enables the actor lambda to only capture the remembered state - // reference, which we can update on each composition. - val callback = remember { mutableStateOf(onRequestCompleted, referentialEqualityPolicy()) } - callback.value = onRequestCompleted - - val requestActor = remember(imageLoader, request) { - CoilRequestActor(imageLoader, request) - } - - launchInComposition(requestActor) { - // Launch the Actor - requestActor.run { _, newState -> - // Update the result state - state = newState - - if (newState is CoilImageState.Success || newState is CoilImageState.Error) { - callback.value(newState) + ImageLoad( + request = request, + executeRequest = { imageLoader.execute(it).toResult() }, + transformRequestForSize = { r, size -> + val sizedRequest = when { + // If the request has a size resolver set we just execute the request as-is + r.defined.sizeResolver != null -> r + // If the size contains an unspecified sized dimension, we don't specify a size + // in the Coil request + size.width < 0 || size.height < 0 -> r + // If we have a non-zero size, we can modify the request to include the size + size != IntSize.Zero -> r.newBuilder().size(size.width, size.height).build() + // Otherwise we have a zero size, so no point executing a request + else -> null } - } - } - - WithConstraints(modifier) { - // We remember the last size in a MutableRef (below) rather than a MutableState. - // This is because we don't need value changes to trigger a re-composition, we are only - // using it to store the last value. - val lastRequestedSize = remember(requestActor) { MutableRef(null) } - - val requestSize = IntSize( - width = if (constraints.hasBoundedWidth) constraints.maxWidth else UNSPECIFIED, - height = if (constraints.hasBoundedHeight) constraints.maxHeight else UNSPECIFIED - ) - - val lastSize = lastRequestedSize.value - if (lastSize == null || - (lastSize != requestSize && shouldRefetchOnSizeChange(state, requestSize)) - ) { - requestActor.send(requestSize) - lastRequestedSize.value = requestSize - } - content(state) - } + if (sizedRequest != null && requestBuilder != null) { + // If we have a transformed request and builder, let it run + requestBuilder(sizedRequest.newBuilder(), size).build() + } else { + // Otherwise we just return the sizedRequest + sizedRequest + } + }, + shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + onRequestCompleted = onRequestCompleted, + modifier = modifier, + content = content + ) } /** @@ -218,6 +193,7 @@ fun CoilImage( * @param loading Content to be displayed when the request is in progress. * @param fadeIn Whether to run a fade-in animation when images are successfully loaded. * Default: `false`. + * @param requestBuilder Optional builder for the [ImageRequest]. * @param imageLoader The [ImageLoader] to use when requesting the image. Defaults to [Coil]'s * default image loader. * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing @@ -232,10 +208,11 @@ fun CoilImage( contentScale: ContentScale = ContentScale.Fit, colorFilter: ColorFilter? = null, fadeIn: Boolean = false, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - error: @Composable ((CoilImageState.Error) -> Unit)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null, ) { CoilImage( @@ -245,6 +222,7 @@ fun CoilImage( contentScale = contentScale, colorFilter = colorFilter, fadeIn = fadeIn, + requestBuilder = requestBuilder, imageLoader = imageLoader, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, onRequestCompleted = onRequestCompleted, @@ -288,6 +266,7 @@ fun CoilImage( * @param error Content to be displayed when the request failed. * @param loading Content to be displayed when the request is in progress. * @param fadeIn Whether to run a fade-in animation when images are successfully loaded. Default: `false`. + * @param requestBuilder Optional builder for the [ImageRequest]. * @param imageLoader The [ImageLoader] to use when requesting the image. Defaults to [Coil]'s * default image loader. * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing @@ -302,21 +281,23 @@ fun CoilImage( contentScale: ContentScale = ContentScale.Fit, colorFilter: ColorFilter? = null, fadeIn: Boolean = false, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - error: @Composable ((CoilImageState.Error) -> Unit)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null, ) { CoilImage( request = request, modifier = modifier, + requestBuilder = requestBuilder, imageLoader = imageLoader, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, onRequestCompleted = onRequestCompleted, ) { imageState -> when (imageState) { - is CoilImageState.Success -> { + is ImageLoadState.Success -> { MaterialLoadingImage( result = imageState, fadeInEnabled = fadeIn, @@ -325,143 +306,60 @@ fun CoilImage( colorFilter = colorFilter ) } - is CoilImageState.Error -> if (error != null) error(imageState) - CoilImageState.Loading -> if (loading != null) loading() - CoilImageState.Empty -> Unit + is ImageLoadState.Error -> if (error != null) error(imageState) + ImageLoadState.Loading -> if (loading != null) loading() + ImageLoadState.Empty -> Unit } } } -/** - * Value for a [IntSize] dimension, where the dimension is not specified or is unknown. - */ -private const val UNSPECIFIED = -1 - -@Stable -private data class MutableRef(var value: T) - -private fun CoilRequestActor( - imageLoader: ImageLoader, - request: ImageRequest -) = RequestActor { size, onResult -> - // First, send the loading state - onResult(CoilImageState.Loading) - - val transformedRequest = when { - // If the request has a size resolver set we just execute the request as-is - request.defined.sizeResolver != null -> request - // If the size contains an unspecified dimension, we don't specify a size - // in the Coil request - size.width == UNSPECIFIED || size.height == UNSPECIFIED -> request - // If we have a non-zero size, we can modify the request to include the size - size != IntSize.Zero -> request.newBuilder().size(size.width, size.height).build() - // Otherwise we have a zero size, so no point executing a request - else -> null - } - - if (transformedRequest != null) { - // Now execute the request in Coil... - imageLoader - .execute(transformedRequest) - .toResult(size) - .also { imageState -> - // Tell RenderThread to pre-upload this bitmap. Saves the GPU upload cost on the - // first draw. See https://github.com/square/picasso/issues/1620 for a explanation - // from @ChrisCraik - when (imageState) { - is CoilImageState.Success -> imageState.image.prepareToDraw() - is CoilImageState.Error -> imageState.image?.prepareToDraw() - } - } - .also { state -> - // Send the result - onResult(state) - } - } else { - // If we don't have a request to execute, send empty - onResult(CoilImageState.Empty) - } -} - @Deprecated( - message = "Use RequestState", - replaceWith = ReplaceWith("RequestState", "dev.chrisbanes.accompanist.coil.LoadState") + message = "Use ImageLoadState", + replaceWith = ReplaceWith("ImageLoadState", "dev.chrisbanes.accompanist.imageloading.ImageLoadState") ) @Suppress("unused") -typealias RequestResult = CoilImageState +typealias RequestResult = ImageLoadState @Deprecated( - message = "Use CoilImageState.Success", + message = "Use ImageLoadState.Success", replaceWith = ReplaceWith( - "LoadState.Success", - "dev.chrisbanes.accompanist.coil.CoilImageState.Success" + "ImageLoadState.Success", + "dev.chrisbanes.accompanist.imageloading.ImageLoadState.Success" ) ) @Suppress("unused") -typealias SuccessResult = CoilImageState.Success +typealias SuccessResult = ImageLoadState.Success @Deprecated( - message = "Use CoilImageState.Error", + message = "Use ImageLoadState.Error", replaceWith = ReplaceWith( - "LoadState.Error", - "dev.chrisbanes.accompanist.coil.CoilImageState.Error" + "ImageLoadState.Error", + "dev.chrisbanes.accompanist.imageloading.ImageLoadState.Error" ) ) @Suppress("unused") -typealias ErrorResult = CoilImageState.Error - -/** - * Represents the state of a [CoilImage] - */ -sealed class CoilImageState { - /** - * Indicates that a request is not in progress. - */ - object Empty : CoilImageState() +typealias ErrorResult = ImageLoadState.Error - /** - * Indicates that the request is currently in progress. - */ - object Loading : CoilImageState() - - /** - * Indicates that the request completed successfully. - * - * @param image The result image. - * @param source The data source that the image was loaded from. - */ - data class Success( - val image: ImageAsset, - val source: DataSource - ) : CoilImageState() { - internal constructor(result: coil.request.SuccessResult, fallbackSize: IntSize) : this( - image = result.drawable.toImageAsset(fallbackSize), - source = result.metadata.dataSource +private fun ImageResult.toResult(): ImageLoadState = when (this) { + is coil.request.SuccessResult -> { + ImageLoadState.Success( + painter = drawable.toPainter(), + source = metadata.dataSource.toDataSource() ) } - - /** - * Indicates that an error occurred while executing the request. - * - * @param image The error image. - * @param throwable The error that failed the request. - */ - data class Error( - val image: ImageAsset?, - val throwable: Throwable - ) : CoilImageState() { - internal constructor(result: coil.request.ErrorResult, fallbackSize: IntSize) : this( - image = result.drawable?.toImageAsset(fallbackSize), - throwable = result.throwable + is coil.request.ErrorResult -> { + ImageLoadState.Error( + painter = drawable?.toPainter(), + throwable = throwable ) } } -private fun ImageResult.toResult( - fallbackSize: IntSize = IntSize.Zero -): CoilImageState = when (this) { - is coil.request.SuccessResult -> CoilImageState.Success(this, fallbackSize) - is coil.request.ErrorResult -> CoilImageState.Error(this, fallbackSize) +private fun coil.decode.DataSource.toDataSource(): DataSource = when (this) { + coil.decode.DataSource.NETWORK -> DataSource.NETWORK + coil.decode.DataSource.MEMORY -> DataSource.MEMORY + coil.decode.DataSource.MEMORY_CACHE -> DataSource.MEMORY + coil.decode.DataSource.DISK -> DataSource.DISK } @Composable @@ -475,13 +373,6 @@ internal fun Any.toImageRequest(): ImageRequest { } } -internal val emptySuccessLambda: (CoilImageState) -> Unit = {} +internal val emptySuccessLambda: (ImageLoadState) -> Unit = {} -internal val defaultRefetchOnSizeChangeLambda: (CoilImageState, IntSize) -> Boolean = { _, _ -> false } - -internal fun Drawable.toImageAsset(fallbackSize: IntSize): ImageAsset { - return toBitmap( - width = if (intrinsicWidth > 0) intrinsicWidth else fallbackSize.width, - height = if (intrinsicHeight > 0) intrinsicHeight else fallbackSize.height - ).asImageAsset() -} +internal val defaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt index 8339731d9..a432832ae 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt +++ b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt @@ -19,41 +19,17 @@ package dev.chrisbanes.accompanist.coil -import android.graphics.ColorMatrixColorFilter -import androidx.compose.animation.asDisposableClock -import androidx.compose.animation.core.AnimationClockObservable -import androidx.compose.animation.core.FloatPropKey -import androidx.compose.animation.core.createAnimation -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.neverEqualPolicy -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ImageAsset -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.drawCanvas -import androidx.compose.ui.graphics.painter.ImagePainter -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.AnimationClockAmbient import androidx.compose.ui.platform.ContextAmbient -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.toSize -import androidx.core.util.Pools import coil.ImageLoader -import coil.decode.DataSource import coil.imageLoader import coil.request.ImageRequest +import dev.chrisbanes.accompanist.imageloading.ImageLoadState +import dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage private const val DefaultTransitionDuration = 1000 @@ -77,7 +53,7 @@ private const val DefaultTransitionDuration = 1000 ) }""", "dev.chrisbanes.accompanist.coil.CoilImage", - "dev.chrisbanes.accompanist.coil.MaterialLoadingImage" + "dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage" ) ) @Composable @@ -88,9 +64,9 @@ fun CoilImageWithCrossfade( contentScale: ContentScale = ContentScale.Fit, crossfadeDuration: Int = DefaultTransitionDuration, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - error: @Composable ((CoilImageState.Error) -> Unit)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null ) { @Suppress("DEPRECATION") @@ -128,7 +104,7 @@ fun CoilImageWithCrossfade( ) }""", "dev.chrisbanes.accompanist.coil.CoilImage", - "dev.chrisbanes.accompanist.coil.MaterialLoadingImage" + "dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage" ) ) @Composable @@ -139,9 +115,9 @@ fun CoilImageWithCrossfade( contentScale: ContentScale = ContentScale.Fit, crossfadeDuration: Int = DefaultTransitionDuration, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: CoilImageState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (CoilImageState) -> Unit = emptySuccessLambda, - error: @Composable ((CoilImageState.Error) -> Unit)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null ) { CoilImage( @@ -152,7 +128,7 @@ fun CoilImageWithCrossfade( onRequestCompleted = onRequestCompleted, ) { imageState -> when (imageState) { - is CoilImageState.Success -> { + is ImageLoadState.Success -> { MaterialLoadingImage( result = imageState, fadeInEnabled = true, @@ -161,215 +137,9 @@ fun CoilImageWithCrossfade( contentScale = contentScale, ) } - is CoilImageState.Error -> if (error != null) error(imageState) - CoilImageState.Loading -> if (loading != null) loading() - CoilImageState.Empty -> Unit + is ImageLoadState.Error -> if (error != null) error(imageState) + ImageLoadState.Loading -> if (loading != null) loading() + ImageLoadState.Empty -> Unit } } } - -/** - * A wrapper around [Image] which implements the - * [Material Image Loading](https://material.io/archive/guidelines/patterns/loading-images.html) - * pattern. - * - * @param asset The [ImageAsset] to draw. - * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. - * background) - * @param alignment Optional alignment parameter used to place the [ImageAsset] in the given - * bounds defined by the width and height. - * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used - * if the bounds are a different size from the intrinsic size of the [ImageAsset]. - * @param colorFilter Optional ColorFilter to apply for the [ImageAsset] when it is rendered - * onscreen - * @param clock The [AnimationClockObservable] to use for running animations. - * @param fadeInEnabled Whether the fade-in animation should be used or not. - * @param fadeInDurationMs The duration of the fade-in animation in milliseconds. - */ -@Composable -fun MaterialLoadingImage( - asset: ImageAsset, - modifier: Modifier = Modifier, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - colorFilter: ColorFilter? = null, - clock: AnimationClockObservable = AnimationClockAmbient.current.asDisposableClock(), - fadeInEnabled: Boolean = true, - fadeInDurationMs: Int = DefaultTransitionDuration -) { - // Default painter for the image - val imagePainter = remember(asset) { ImagePainter(asset) } - - val painter = if (fadeInEnabled) { - val observablePainter = remember(asset) { - ObservableFadeInImagePainter(asset, fadeInDurationMs, clock).also { it.start() } - } - when { - // If the animation is running, return it as the painter - !observablePainter.isFinished -> observablePainter - // If the animation has finished, revert back to the default painter - else -> imagePainter - } - } else { - // If the fade is disabled, just use the standard ImagePainter - imagePainter - } - - Image( - painter = painter, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter, - modifier = modifier, - ) -} - -/** - * A wrapper around [Image] which implements the - * [Material Image Loading](https://material.io/archive/guidelines/patterns/loading-images.html) - * pattern. - * - * @param result The [CoilImageState.Success] instance provided by [CoilImage]. - * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. - * background) - * @param alignment Optional alignment parameter used to place the [ImageAsset] in the given - * bounds defined by the width and height. - * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used - * if the bounds are a different size from the intrinsic size of the [ImageAsset]. - * @param colorFilter Optional ColorFilter to apply for the [ImageAsset] when it is rendered - * onscreen - * @param clock The [AnimationClockObservable] to use for running animations. - * @param skipFadeWhenLoadedFromMemory Whether the fade animation should be skipped when the result - * has been loaded from memory. - * @param fadeInEnabled Whether the fade-in animation should be used or not. - * @param fadeInDurationMs The duration of the fade-in animation in milliseconds. - */ -@Composable -fun MaterialLoadingImage( - result: CoilImageState.Success, - modifier: Modifier = Modifier, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - colorFilter: ColorFilter? = null, - clock: AnimationClockObservable = AnimationClockAmbient.current.asDisposableClock(), - skipFadeWhenLoadedFromMemory: Boolean = true, - fadeInEnabled: Boolean = true, - fadeInDurationMs: Int = DefaultTransitionDuration -) { - MaterialLoadingImage( - asset = result.image, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter, - modifier = modifier, - fadeInEnabled = fadeInEnabled && !(skipFadeWhenLoadedFromMemory && result.isFromMemory()), - fadeInDurationMs = fadeInDurationMs, - clock = clock, - ) -} - -private class ObservableFadeInImagePainter( - private val image: ImageAsset, - duration: Int, - clock: AnimationClockObservable, - private val srcOffset: IntOffset = IntOffset.Zero, - private val srcSize: IntSize = IntSize(image.width, image.height) -) : Painter() { - var isFinished by mutableStateOf(false) - private set - - // Initial matrix is completely transparent. We use the NeverEqual equivalence check since this - // is a mutable entity. - private var matrix by mutableStateOf( - value = ImageLoadingColorMatrix(0f, 0f, 0f), - policy = neverEqualPolicy() - ) - - private val animation = CrossfadeTransition.definition(duration).createAnimation(clock) - - init { - animation.onUpdate = { - // Update the matrix state value with the new animated properties. This works since - // we're using the NeverEqual equivalence check - matrix = matrix.apply { - saturationFraction = animation[CrossfadeTransition.Saturation] - alphaFraction = animation[CrossfadeTransition.Alpha] - brightnessFraction = animation[CrossfadeTransition.Brightness] - } - } - - animation.onStateChangeFinished = { state -> - if (state == CrossfadeTransition.State.Loaded) { - isFinished = true - } - } - } - - override fun DrawScope.onDraw() { - val paint = paintPool.acquire() ?: Paint() - - try { - paint.asFrameworkPaint().colorFilter = ColorMatrixColorFilter(matrix) - - drawCanvas { canvas, _ -> - canvas.drawImageRect(image, srcOffset, srcSize, IntOffset.Zero, srcSize, paint) - } - } finally { - // Reset the Paint instance and release it back to the pool - paint.asFrameworkPaint().reset() - paintPool.release(paint) - } - } - - /** - * Return the dimension of the underlying [ImageAsset] as its intrinsic width and height - */ - override val intrinsicSize: Size get() = srcSize.toSize() - - fun start() { - // Start the animation by transitioning to the Loaded state - animation.toState(CrossfadeTransition.State.Loaded) - } -} - -/** - * A pool which allows us to cache and re-use [Paint] instances, which are relatively expensive - * to create. - */ -private val paintPool = Pools.SimplePool(2) - -private object CrossfadeTransition { - enum class State { - Loaded, Empty - } - - val Alpha = FloatPropKey() - val Brightness = FloatPropKey() - val Saturation = FloatPropKey() - - fun definition(durationMs: Int) = transitionDefinition { - state(State.Empty) { - this[Alpha] = 0f - this[Brightness] = 0.8f - this[Saturation] = 0f - } - state(State.Loaded) { - this[Alpha] = 1f - this[Brightness] = 1f - this[Saturation] = 1f - } - - transition { - // Alpha animates over the first 50% - Alpha using tween(durationMillis = durationMs / 2) - // Brightness animates over the first 75% - Brightness using tween(durationMillis = durationMs * 3 / 4) - // Saturation animates over whole duration - Saturation using tween(durationMillis = durationMs) - } - } -} - -private fun CoilImageState.Success.isFromMemory(): Boolean { - return source == DataSource.MEMORY || source == DataSource.MEMORY_CACHE -} diff --git a/imageloading-core/README.md b/imageloading-core/README.md new file mode 100644 index 000000000..47db33dcb --- /dev/null +++ b/imageloading-core/README.md @@ -0,0 +1,18 @@ +# Image Loading Core + +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/dev.chrisbanes.accompanist/accompanist-imageloading-core/badge.svg)](https://search.maven.org/search?q=g:dev.chrisbanes.accompanist) + +This library powers a number of common functions and utilities which available in the [Coil](../coil) library. You shouldn't need to depend on this +directly. + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "dev.chrisbanes.accompanist:accompanist-imageloading-core:" +} +``` diff --git a/imageloading-core/api/imageloading-core.api b/imageloading-core/api/imageloading-core.api new file mode 100644 index 000000000..eeb09c851 --- /dev/null +++ b/imageloading-core/api/imageloading-core.api @@ -0,0 +1,76 @@ +public final class dev/chrisbanes/accompanist/imageloading/AndroidDrawablePainter : androidx/compose/ui/graphics/painter/Painter { + public fun (Landroid/graphics/drawable/Drawable;)V + public fun getIntrinsicSize-NH-jbRc ()J +} + +public final class dev/chrisbanes/accompanist/imageloading/AndroidDrawablePainterKt { + public static final fun toPainter (Landroid/graphics/drawable/Drawable;)Landroidx/compose/ui/graphics/painter/Painter; +} + +public final class dev/chrisbanes/accompanist/imageloading/DataSource : java/lang/Enum { + public static final field DISK Ldev/chrisbanes/accompanist/imageloading/DataSource; + public static final field MEMORY Ldev/chrisbanes/accompanist/imageloading/DataSource; + public static final field NETWORK Ldev/chrisbanes/accompanist/imageloading/DataSource; + public static final fun valueOf (Ljava/lang/String;)Ldev/chrisbanes/accompanist/imageloading/DataSource; + public static final fun values ()[Ldev/chrisbanes/accompanist/imageloading/DataSource; +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoad { + public static final fun ImageLoad (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public abstract class dev/chrisbanes/accompanist/imageloading/ImageLoadState { +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoadState$Empty : dev/chrisbanes/accompanist/imageloading/ImageLoadState { + public static final field INSTANCE Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Empty; +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoadState$Error : dev/chrisbanes/accompanist/imageloading/ImageLoadState { + public fun (Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/Throwable;)V + public final fun component1 ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun component2 ()Ljava/lang/Throwable; + public final fun copy (Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/Throwable;)Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Error; + public static synthetic fun copy$default (Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Error;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/Throwable;ILjava/lang/Object;)Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getPainter ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun getThrowable ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoadState$Loading : dev/chrisbanes/accompanist/imageloading/ImageLoadState { + public static final field INSTANCE Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Loading; +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoadState$Success : dev/chrisbanes/accompanist/imageloading/ImageLoadState { + public fun (Landroidx/compose/ui/graphics/painter/Painter;Ldev/chrisbanes/accompanist/imageloading/DataSource;)V + public final fun component1 ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun component2 ()Ldev/chrisbanes/accompanist/imageloading/DataSource; + public final fun copy (Landroidx/compose/ui/graphics/painter/Painter;Ldev/chrisbanes/accompanist/imageloading/DataSource;)Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Success; + public static synthetic fun copy$default (Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Success;Landroidx/compose/ui/graphics/painter/Painter;Ldev/chrisbanes/accompanist/imageloading/DataSource;ILjava/lang/Object;)Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getPainter ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun getSource ()Ldev/chrisbanes/accompanist/imageloading/DataSource; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/chrisbanes/accompanist/imageloading/ImageLoadingColorMatrix : android/graphics/ColorMatrix { + public fun ()V + public fun (FFF)V + public synthetic fun (FFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAlphaFraction ()F + public final fun getBrightnessFraction ()F + public final fun getSaturationFraction ()F + public final fun setAlphaFraction (F)V + public final fun setBrightnessFraction (F)V + public final fun setSaturationFraction (F)V +} + +public final class dev/chrisbanes/accompanist/imageloading/MaterialLoadingImage { + public static final fun MaterialLoadingImage (Landroidx/compose/ui/graphics/ImageAsset;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/animation/core/AnimationClockObservable;ZILandroidx/compose/runtime/Composer;II)V + public static final fun MaterialLoadingImage (Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/animation/core/AnimationClockObservable;ZILandroidx/compose/runtime/Composer;II)V + public static final fun MaterialLoadingImage (Ldev/chrisbanes/accompanist/imageloading/ImageLoadState$Success;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/animation/core/AnimationClockObservable;ZZILandroidx/compose/runtime/Composer;II)V +} + diff --git a/imageloading-core/build.gradle b/imageloading-core/build.gradle new file mode 100644 index 000000000..9f1935bd7 --- /dev/null +++ b/imageloading-core/build.gradle @@ -0,0 +1,71 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import dev.chrisbanes.accompanist.buildsrc.Libs + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +android { + compileSdkVersion 30 + + defaultConfig { + minSdkVersion 21 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + buildConfig false + compose true + } + + composeOptions { + kotlinCompilerVersion Libs.Kotlin.version + kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } +} + +afterEvaluate { + tasks.withType(org.jetbrains.dokka.gradle.DokkaTask).configureEach { + outputDirectory.set(rootProject.file('docs/api')) + } +} + +dependencies { + implementation Libs.AndroidX.coreKtx + implementation Libs.AndroidX.Compose.runtime + implementation Libs.AndroidX.Compose.foundation + + implementation Libs.Kotlin.stdlib + implementation Libs.Coroutines.android +} + +apply plugin: "com.vanniktech.maven.publish" diff --git a/imageloading-core/gradle.properties b/imageloading-core/gradle.properties new file mode 100644 index 000000000..97d87e6ab --- /dev/null +++ b/imageloading-core/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=accompanist-imageloading-core +POM_NAME=Accompanist Image Loading Core library +POM_PACKAGING=aar \ No newline at end of file diff --git a/imageloading-core/images/crossfade.gif b/imageloading-core/images/crossfade.gif new file mode 100644 index 000000000..d05afe4a8 Binary files /dev/null and b/imageloading-core/images/crossfade.gif differ diff --git a/imageloading-core/src/main/AndroidManifest.xml b/imageloading-core/src/main/AndroidManifest.xml new file mode 100644 index 000000000..888b072af --- /dev/null +++ b/imageloading-core/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/AndroidDrawablePainter.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/AndroidDrawablePainter.kt new file mode 100644 index 000000000..1149c30b4 --- /dev/null +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/AndroidDrawablePainter.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.imageloading + +import android.graphics.PorterDuff +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.view.View +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageAsset +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.ImagePainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.graphics.drawable.DrawableCompat +import kotlin.math.roundToInt + +/** + * A [Painter] which draws an Android [Drawable]. Supports [Animatable] drawables. + */ +class AndroidDrawablePainter( + private val drawable: Drawable +) : Painter() { + private val drawableSize = Size( + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicWidth.toFloat() + ) + + private var invalidateTick by mutableStateOf(0) + private var startedAnimatable = drawable is Animatable && drawable.isRunning + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + init { + drawable.callback = object : Drawable.Callback { + override fun invalidateDrawable(d: Drawable) { + // Update the tick so that we get re-drawn + invalidateTick++ + } + + override fun scheduleDrawable(d: Drawable, what: Runnable, time: Long) { + handler.postAtTime(what, time) + } + + override fun unscheduleDrawable(d: Drawable, what: Runnable) { + handler.removeCallbacks(what) + } + } + } + + override fun applyAlpha(alpha: Float): Boolean { + drawable.alpha = (alpha * 255).roundToInt().coerceIn(0, 255) + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + if (colorFilter != null) { + drawable.setTint(colorFilter.color.toArgb()) + drawable.setTintMode(colorFilter.blendMode.toPorterDuffMode()) + } else { + drawable.setTintList(null) + drawable.setTintMode(null) + } + return true + } + + override fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean { + return DrawableCompat.setLayoutDirection( + drawable, + when (layoutDirection) { + LayoutDirection.Ltr -> View.LAYOUT_DIRECTION_LTR + LayoutDirection.Rtl -> View.LAYOUT_DIRECTION_RTL + } + ) + } + + override val intrinsicSize: Size get() = drawableSize + + override fun DrawScope.onDraw() { + if (!startedAnimatable && drawable is Animatable && !drawable.isRunning) { + // If the drawable is Animatable, start it on the first draw + drawable.start() + startedAnimatable = true + } + + drawCanvas { canvas, size -> + // Reading this ensures that we invalidate when invalidateDrawable() is called + invalidateTick + + drawable.setBounds(0, 0, size.width.toInt(), size.height.toInt()) + drawable.draw(canvas.nativeCanvas) + } + } +} + +/** + * Copied from AndroidBlendMode.kt in ui-graphics + */ +private fun BlendMode.toPorterDuffMode(): PorterDuff.Mode = when (this) { + BlendMode.Clear -> PorterDuff.Mode.CLEAR + BlendMode.Src -> PorterDuff.Mode.SRC + BlendMode.Dst -> PorterDuff.Mode.DST + BlendMode.SrcOver -> PorterDuff.Mode.SRC_OVER + BlendMode.DstOver -> PorterDuff.Mode.DST_OVER + BlendMode.SrcIn -> PorterDuff.Mode.SRC_IN + BlendMode.DstIn -> PorterDuff.Mode.DST_IN + BlendMode.SrcOut -> PorterDuff.Mode.SRC_OUT + BlendMode.DstOut -> PorterDuff.Mode.DST_OUT + BlendMode.SrcAtop -> PorterDuff.Mode.SRC_ATOP + BlendMode.DstAtop -> PorterDuff.Mode.DST_ATOP + BlendMode.Xor -> PorterDuff.Mode.XOR + BlendMode.Plus -> PorterDuff.Mode.ADD + BlendMode.Screen -> PorterDuff.Mode.SCREEN + BlendMode.Overlay -> PorterDuff.Mode.OVERLAY + BlendMode.Darken -> PorterDuff.Mode.DARKEN + BlendMode.Lighten -> PorterDuff.Mode.LIGHTEN + BlendMode.Modulate -> { + // b/73224934 Android PorterDuff Multiply maps to Skia Modulate + PorterDuff.Mode.MULTIPLY + } + // Always return SRC_OVER as the default if there is no valid alternative + else -> PorterDuff.Mode.SRC_OVER +} + +/** + * Allows wrapping of a [Drawable] into a [Painter], attempting to un-wrap the drawable contents + * and use Compose primitives where possible. + */ +fun Drawable.toPainter(): Painter = when (this) { + is BitmapDrawable -> ImagePainter(bitmap.asImageAsset()) + is ColorDrawable -> ColorPainter(Color(color)) + else -> AndroidDrawablePainter(this) +} diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/DataSource.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/DataSource.kt new file mode 100644 index 000000000..a962d4f09 --- /dev/null +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/DataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.imageloading + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import java.io.File +import java.nio.ByteBuffer + +enum class DataSource { + /** + * Represents an in-memory data source or cache (e.g. [Bitmap], [ByteBuffer]). + */ + MEMORY, + + /** + * Represents a disk-based data source (e.g. [DrawableRes], [File]). + */ + DISK, + + /** + * Represents a network-based data source. + */ + NETWORK +} diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt new file mode 100644 index 000000000..e1c394362 --- /dev/null +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("ImageLoad") +@file:JvmMultifileClass + +package dev.chrisbanes.accompanist.imageloading + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.launchInComposition +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.referentialEqualityPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.stateFor +import androidx.compose.ui.Modifier +import androidx.compose.ui.WithConstraints +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow + +/** + * A generic image loading composable, which provides hooks for image loading libraries to use. + * Apps shouldn't generally use this function, instead preferring one of the extension libraries + * which build upon this, such as the Coil library. + * + * The [executeRequest] parameters allows providing of a lambda to execute the 'image load'. + * The [T] type and [request] parameter should be whatever primitive the library uses to + * model a request. + * + * @param request The request to execute. + * @param executeRequest Suspending lambda to execute an image loading request. + * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param transformRequestForSize Optionally transform [request] for the given [IntSize]. + * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing + * optional re-fetching of the image. Return true to re-fetch the image. + * @param onRequestCompleted Listener which will be called when the loading request has finished. + * @param content Content to be displayed for the given state. + */ +@Composable +fun ImageLoad( + request: T, + executeRequest: suspend (T) -> ImageLoadState, + modifier: Modifier = Modifier, + transformRequestForSize: (T, IntSize) -> T? = { r, _ -> r }, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + content: @Composable (imageLoadState: ImageLoadState) -> Unit +) { + var state by stateFor(request) { ImageLoadState.Empty } + + // This may look a little weird, but allows the launchInComposition callback to always + // invoke the last provided [onRequestCompleted]. + // + // If a composition happens *after* launchInComposition has launched, the given + // [onRequestCompleted] might have changed. If the actor lambda below directly referenced + // [onRequestCompleted] it would have captured access to the initial onRequestCompleted + // value, not the latest. + // + // This `callback` state enables the actor lambda to only capture the remembered state + // reference, which we can update on each composition. + val callback = remember { mutableStateOf(onRequestCompleted, referentialEqualityPolicy()) } + callback.value = onRequestCompleted + + val requestActor = remember(request) { + ImageLoadRequestActor(executeRequest) + } + + launchInComposition(requestActor) { + // Launch the Actor + requestActor.run { _, newState -> + // Update the result state + state = newState + + if (newState is ImageLoadState.Success || newState is ImageLoadState.Error) { + callback.value(newState) + } + } + } + + WithConstraints(modifier) { + // We remember the last size in a MutableRef (below) rather than a MutableState. + // This is because we don't need value changes to trigger a re-composition, we are only + // using it to store the last value. + val lastRequestedSize = remember(requestActor) { MutableRef(null) } + + val requestSize = IntSize( + width = if (constraints.hasBoundedWidth) constraints.maxWidth else -1, + height = if (constraints.hasBoundedHeight) constraints.maxHeight else -1 + ) + + val lastSize = lastRequestedSize.value + if (lastSize == null || + (lastSize != requestSize && shouldRefetchOnSizeChange(state, requestSize)) + ) { + val transformedRequest = transformRequestForSize(request, requestSize) + if (transformedRequest != null) { + requestActor.send(transformedRequest) + lastRequestedSize.value = requestSize + } else { + // If the transform request is null, set our state to empty + state = ImageLoadState.Empty + } + } + + content(state) + } +} + +/** + * A simple mutable reference holder. Used as a replacement for [MutableState] when you don't need + * the recomposition triggers. + */ +@Stable +private data class MutableRef(var value: T) + +private fun ImageLoadRequestActor( + execute: suspend (T) -> ImageLoadState +) = RequestActor { request -> + flow { + // First, send the loading state + emit(ImageLoadState.Loading) + // Now execute the request in Coil... + emit(execute(request)) + }.catch { throwable -> + emit(ImageLoadState.Error(painter = null, throwable = throwable)) + } +} + +internal val emptySuccessLambda: (ImageLoadState) -> Unit = {} + +internal val defaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadState.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadState.kt new file mode 100644 index 000000000..7d59eb4dd --- /dev/null +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadState.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.imageloading + +import androidx.compose.ui.graphics.painter.Painter + +/** + * Represents the state of a [ImageLoad] + */ +sealed class ImageLoadState { + /** + * Indicates that a request is not in progress. + */ + object Empty : ImageLoadState() + + /** + * Indicates that the request is currently in progress. + */ + object Loading : ImageLoadState() + + /** + * Indicates that the request completed successfully. + * + * @param painter The result image. + * @param source The data source that the image was loaded from. + */ + data class Success( + val painter: Painter, + val source: DataSource + ) : ImageLoadState() + + /** + * Indicates that an error occurred while executing the request. + * + * @param painter The error image. + * @param throwable The error that failed the request. + */ + data class Error( + val painter: Painter?, + val throwable: Throwable + ) : ImageLoadState() +} diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/ImageLoadingColorMatrix.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadingColorMatrix.kt similarity index 98% rename from coil/src/main/java/dev/chrisbanes/accompanist/coil/ImageLoadingColorMatrix.kt rename to imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadingColorMatrix.kt index 97b3f472f..4c747ed53 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/ImageLoadingColorMatrix.kt +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoadingColorMatrix.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package dev.chrisbanes.accompanist.coil +package dev.chrisbanes.accompanist.imageloading import android.graphics.ColorMatrix diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/MaterialLoadingImage.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/MaterialLoadingImage.kt new file mode 100644 index 000000000..e0b5c00f7 --- /dev/null +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/MaterialLoadingImage.kt @@ -0,0 +1,292 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("MaterialLoadingImage") + +package dev.chrisbanes.accompanist.imageloading + +import android.graphics.ColorMatrixColorFilter +import androidx.compose.animation.asDisposableClock +import androidx.compose.animation.core.AnimationClockObservable +import androidx.compose.animation.core.FloatPropKey +import androidx.compose.animation.core.createAnimation +import androidx.compose.animation.core.transitionDefinition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageAsset +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawCanvas +import androidx.compose.ui.graphics.painter.ImagePainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.AnimationClockAmbient +import androidx.core.util.Pools + +private const val DefaultTransitionDuration = 1000 + +/** + * A wrapper around [Image] which implements the + * [Material Image Loading](https://material.io/archive/guidelines/patterns/loading-images.html) + * pattern. + * + * @param asset The [ImageAsset] to draw. + * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. + * background) + * @param alignment Optional alignment parameter used to place the [ImageAsset] in the given + * bounds defined by the width and height. + * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used + * if the bounds are a different size from the intrinsic size of the [ImageAsset]. + * @param colorFilter Optional ColorFilter to apply for the [ImageAsset] when it is rendered + * onscreen + * @param clock The [AnimationClockObservable] to use for running animations. + * @param fadeInEnabled Whether the fade-in animation should be used or not. + * @param fadeInDurationMs The duration of the fade-in animation in milliseconds. + */ +@Composable +fun MaterialLoadingImage( + asset: ImageAsset, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + clock: AnimationClockObservable = AnimationClockAmbient.current.asDisposableClock(), + fadeInEnabled: Boolean = true, + fadeInDurationMs: Int = DefaultTransitionDuration +) { + MaterialLoadingImage( + painter = ImagePainter(asset), + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + clock = clock, + fadeInEnabled = fadeInEnabled, + fadeInDurationMs = fadeInDurationMs + ) +} + +/** + * A wrapper around [Image] which implements the + * [Material Image Loading](https://material.io/archive/guidelines/patterns/loading-images.html) + * pattern. + * + * @param painter The [Painter] to draw. + * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. + * background) + * @param alignment Optional alignment parameter used to place the [painter] in the given + * bounds defined by the width and height. + * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used + * if the bounds are a different size from the intrinsic size of the [ImageAsset]. + * @param colorFilter Optional ColorFilter to apply for the [ImageAsset] when it is rendered + * onscreen + * @param clock The [AnimationClockObservable] to use for running animations. + * @param fadeInEnabled Whether the fade-in animation should be used or not. + * @param fadeInDurationMs The duration of the fade-in animation in milliseconds. + */ +@Composable +fun MaterialLoadingImage( + painter: Painter, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + clock: AnimationClockObservable = AnimationClockAmbient.current.asDisposableClock(), + fadeInEnabled: Boolean = true, + fadeInDurationMs: Int = DefaultTransitionDuration +) { + Image( + painter = if (fadeInEnabled) { + val animatedPainer = remember(painter) { + MaterialLoadingPainterWrapper(painter, fadeInDurationMs, clock).also { it.start() } + } + // If the animation painter is running, return use it, else use to the painter + if (!animatedPainer.isFinished) animatedPainer else painter + } else { + // If the fade is disabled, just use the standard ImagePainter + painter + }, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + modifier = modifier, + ) +} + +/** + * A wrapper around [Image] which implements the + * [Material Image Loading](https://material.io/archive/guidelines/patterns/loading-images.html) + * pattern. + * + * @param result A [ImageLoadState.Success] instance. + * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. + * background) + * @param alignment Optional alignment parameter used to place the [ImageAsset] in the given + * bounds defined by the width and height. + * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used + * if the bounds are a different size from the intrinsic size of the [ImageAsset]. + * @param colorFilter Optional ColorFilter to apply for the [ImageAsset] when it is rendered + * onscreen + * @param clock The [AnimationClockObservable] to use for running animations. + * @param skipFadeWhenLoadedFromMemory Whether the fade animation should be skipped when the result + * has been loaded from memory. + * @param fadeInEnabled Whether the fade-in animation should be used or not. + * @param fadeInDurationMs The duration of the fade-in animation in milliseconds. + */ +@Composable +fun MaterialLoadingImage( + result: ImageLoadState.Success, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + clock: AnimationClockObservable = AnimationClockAmbient.current.asDisposableClock(), + skipFadeWhenLoadedFromMemory: Boolean = true, + fadeInEnabled: Boolean = true, + fadeInDurationMs: Int = DefaultTransitionDuration +) { + MaterialLoadingImage( + painter = result.painter, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + modifier = modifier, + fadeInEnabled = fadeInEnabled && !(skipFadeWhenLoadedFromMemory && result.isFromMemory()), + fadeInDurationMs = fadeInDurationMs, + clock = clock, + ) +} + +private class MaterialLoadingPainterWrapper( + private val painter: Painter, + duration: Int, + clock: AnimationClockObservable +) : Painter() { + var isFinished by mutableStateOf(false) + private set + + // Initial matrix is completely transparent. We use the NeverEqual equivalence check since this + // is a mutable entity. + private var matrix by mutableStateOf( + value = ImageLoadingColorMatrix(0f, 0f, 0f), + policy = neverEqualPolicy() + ) + + private val animation = CrossfadeTransition.definition(duration).createAnimation(clock) + + init { + animation.onUpdate = { + // Update the matrix state value with the new animated properties. This works since + // we're using the NeverEqual equivalence check + matrix = matrix.apply { + saturationFraction = animation[CrossfadeTransition.Saturation] + alphaFraction = animation[CrossfadeTransition.Alpha] + brightnessFraction = animation[CrossfadeTransition.Brightness] + } + } + + animation.onStateChangeFinished = { state -> + if (state == CrossfadeTransition.State.Loaded) { + isFinished = true + } + } + } + + override fun DrawScope.onDraw() { + val paint = paintPool.acquire() ?: Paint() + + try { + paint.asFrameworkPaint().colorFilter = ColorMatrixColorFilter(matrix) + + drawCanvas { canvas, size -> + canvas.saveLayer(size.toRect(), paint) + + with(painter) { + // Need to explicitly set alpha. + // See https://issuetracker.google.com/169379346 + draw(size, alpha = 1f) + } + + canvas.restore() + } + } finally { + // Reset the Paint instance and release it back to the pool + paint.asFrameworkPaint().reset() + paintPool.release(paint) + } + } + + /** + * Return the dimension of the underlying [ImageAsset] as its intrinsic width and height + */ + override val intrinsicSize: Size get() = painter.intrinsicSize + + fun start() { + // Start the animation by transitioning to the Loaded state + animation.toState(CrossfadeTransition.State.Loaded) + } +} + +/** + * A pool which allows us to cache and re-use [Paint] instances, which are relatively expensive + * to create. + */ +private val paintPool = Pools.SimplePool(2) + +private object CrossfadeTransition { + enum class State { + Loaded, Empty + } + + val Alpha = FloatPropKey() + val Brightness = FloatPropKey() + val Saturation = FloatPropKey() + + fun definition(durationMs: Int) = transitionDefinition { + state(State.Empty) { + this[Alpha] = 0f + this[Brightness] = 0.8f + this[Saturation] = 0f + } + state(State.Loaded) { + this[Alpha] = 1f + this[Brightness] = 1f + this[Saturation] = 1f + } + + transition { + // Alpha animates over the first 50% + Alpha using tween(durationMillis = durationMs / 2) + // Brightness animates over the first 75% + Brightness using tween(durationMillis = durationMs * 3 / 4) + // Saturation animates over whole duration + Saturation using tween(durationMillis = durationMs) + } + } +} + +private fun ImageLoadState.Success.isFromMemory(): Boolean = source == DataSource.MEMORY diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/RequestActor.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/RequestActor.kt similarity index 82% rename from coil/src/main/java/dev/chrisbanes/accompanist/coil/RequestActor.kt rename to imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/RequestActor.kt index 234151609..88ff50977 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/RequestActor.kt +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/RequestActor.kt @@ -14,11 +14,15 @@ * limitations under the License. */ -package dev.chrisbanes.accompanist.coil +package dev.chrisbanes.accompanist.imageloading +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow /** @@ -54,7 +58,7 @@ import kotlinx.coroutines.flow.receiveAsFlow * ``` */ internal class RequestActor( - private val execute: suspend (Param, onResult: (Result) -> Unit) -> Unit + private val execute: (Param) -> Flow ) { private val channel = Channel(Channel.CONFLATED) @@ -66,11 +70,15 @@ internal class RequestActor( * * @param onResult A lambda which will be called with each input and the processed result. */ + @OptIn(ExperimentalCoroutinesApi::class) suspend fun run(onResult: (Param, Result) -> Unit) { channel.receiveAsFlow() .distinctUntilChanged() - .collect { input -> - execute(input) { result -> onResult(input, result) } + .flatMapLatest { param -> + execute(param).map { param to it } + } + .collect { (param, result) -> + onResult(param, result) } } diff --git a/sample/build.gradle b/sample/build.gradle index 2a368af67..0a1ed10e3 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -52,6 +52,8 @@ android { dependencies { implementation project(':coil') + implementation Libs.Coil.gif + implementation Libs.AndroidX.Compose.runtime implementation Libs.AndroidX.Compose.material implementation Libs.AndroidX.Compose.foundation diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index fbe8933c7..95f008403 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ -