From 64df60efd1eee1d68ab40585c0fe983959918924 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Mon, 12 Apr 2021 18:29:41 +0100 Subject: [PATCH] Merge ImageState into LoadPainter --- coil/api/coil.api | 17 +- .../com/google/accompanist/coil/CoilTest.kt | 36 +- .../java/com/google/accompanist/coil/Coil.kt | 183 ++++------- .../google/accompanist/coil/DeprecatedCoil.kt | 37 +-- glide/api/glide.api | 11 +- .../com/google/accompanist/glide/GlideTest.kt | 72 ++-- .../accompanist/glide/DeprecatedGlide.kt | 39 +-- .../com/google/accompanist/glide/Glide.kt | 247 ++++++-------- imageloading-core/api/imageloading-core.api | 17 +- .../accompanist/imageloading/Deprecated.kt | 42 +++ .../google/accompanist/imageloading/Image.kt | 308 +++++++++--------- .../accompanist/imageloading/ImageState.kt | 109 ------- .../imageloading/test/ImageRequest.kt | 8 +- .../sample/coil/CoilBasicSample.kt | 8 +- .../sample/glide/GlideBasicSample.kt | 20 +- 15 files changed, 494 insertions(+), 660 deletions(-) delete mode 100644 imageloading-core/src/main/java/com/google/accompanist/imageloading/ImageState.kt diff --git a/coil/api/coil.api b/coil/api/coil.api index 6a251a036..0494f5d45 100644 --- a/coil/api/coil.api +++ b/coil/api/coil.api @@ -3,18 +3,6 @@ public final class com/google/accompanist/coil/CoilImage { public static final fun CoilImage (Lcoil/request/ImageRequest;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLkotlin/jvm/functions/Function2;Lcoil/ImageLoader;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun CoilImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lcoil/ImageLoader;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun CoilImage (Ljava/lang/Object;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLkotlin/jvm/functions/Function2;Lcoil/ImageLoader;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V - public static final fun getLocalImageLoader ()Landroidx/compose/runtime/ProvidableCompositionLocal; - public static final fun rememberCoilImageState (Ljava/lang/Object;Lcoil/ImageLoader;Landroid/content/Context;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Lcom/google/accompanist/coil/CoilImageState; - public static final fun rememberCoilPainter (Ljava/lang/Object;Lcoil/ImageLoader;Landroid/content/Context;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIILandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/graphics/painter/Painter; -} - -public final class com/google/accompanist/coil/CoilImageState : com/google/accompanist/imageloading/ImageState { - public fun (Lcoil/ImageLoader;Landroid/content/Context;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lcoil/ImageLoader;Landroid/content/Context;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getData ()Ljava/lang/Object; - public final fun getRequestBuilder ()Lkotlin/jvm/functions/Function2; - public final fun setData (Ljava/lang/Object;)V - public final fun setRequestBuilder (Lkotlin/jvm/functions/Function2;)V } public final class com/google/accompanist/coil/CoilImageStateDefaults { @@ -23,3 +11,8 @@ public final class com/google/accompanist/coil/CoilImageStateDefaults { public final fun defaultImageLoader (Landroidx/compose/runtime/Composer;I)Lcoil/ImageLoader; } +public final class com/google/accompanist/coil/CoilKt { + public static final fun getLocalImageLoader ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun rememberCoilPainter (Ljava/lang/Object;Lcoil/ImageLoader;Landroid/content/Context;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIILandroidx/compose/runtime/Composer;II)Lcom/google/accompanist/imageloading/LoadPainter; +} + diff --git a/coil/src/androidTest/java/com/google/accompanist/coil/CoilTest.kt b/coil/src/androidTest/java/com/google/accompanist/coil/CoilTest.kt index 7b4878609..d0c554194 100644 --- a/coil/src/androidTest/java/com/google/accompanist/coil/CoilTest.kt +++ b/coil/src/androidTest/java/com/google/accompanist/coil/CoilTest.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.test.assertHeightIsAtLeast @@ -52,7 +53,6 @@ import coil.request.ImageResult import com.google.accompanist.coil.test.R import com.google.accompanist.imageloading.ImageLoadState import com.google.accompanist.imageloading.isFinalState -import com.google.accompanist.imageloading.rememberLoadPainter import com.google.accompanist.imageloading.test.ImageMockWebServer import com.google.accompanist.imageloading.test.assertPixels import com.google.accompanist.imageloading.test.resourceUri @@ -273,18 +273,18 @@ class CoilTest { var size by mutableStateOf(128.dp) composeTestRule.setContent { - val state = rememberCoilImageState(server.url("/red")) + val painter = rememberCoilPainter(server.url("/red")) Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(size) .testTag(CoilTestTags.Image), ) - LaunchedEffect(state) { - snapshotFlow { state.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .onCompletion { loadStates.cancel() } .collect { loadStates.send(it) } @@ -352,6 +352,32 @@ class CoilTest { .assertPixels(Color.Red) } + @Test + @SdkSuppress(minSdkVersion = 26) // captureToImage is SDK 26+ + fun previewPlaceholder() { + composeTestRule.setContent { + CompositionLocalProvider(LocalInspectionMode provides true) { + Image( + painter = rememberCoilPainter( + data = "blah", + previewPlaceholder = R.drawable.red_rectangle, + ), + contentDescription = null, + modifier = Modifier + .size(128.dp, 128.dp) + .testTag(CoilTestTags.Image), + ) + } + } + + composeTestRule.onNodeWithTag(CoilTestTags.Image) + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + .assertIsDisplayed() + .captureToImage() + .assertPixels(Color.Red) + } + @Test fun errorStillHasSize() { composeTestRule.setContent { diff --git a/coil/src/main/java/com/google/accompanist/coil/Coil.kt b/coil/src/main/java/com/google/accompanist/coil/Coil.kt index 32ea05888..dca7d9ba4 100644 --- a/coil/src/main/java/com/google/accompanist/coil/Coil.kt +++ b/coil/src/main/java/com/google/accompanist/coil/Coil.kt @@ -14,19 +14,14 @@ * limitations under the License. */ -@file:JvmName("CoilImage") -@file:JvmMultifileClass - package com.google.accompanist.coil import android.content.Context +import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter @@ -40,17 +35,18 @@ import coil.request.ImageResult import coil.size.Precision import com.google.accompanist.imageloading.DataSource import com.google.accompanist.imageloading.ImageLoadState -import com.google.accompanist.imageloading.ImageState +import com.google.accompanist.imageloading.LoadPainter import com.google.accompanist.imageloading.rememberLoadPainter +import kotlinx.coroutines.ExperimentalCoroutinesApi /** * Composition local containing the preferred [ImageLoader] to be used by - * [rememberCoilImageState]. + * [rememberCoilPainter]. */ val LocalImageLoader = staticCompositionLocalOf { null } /** - * Contains some default values used by [rememberCoilImageState]. + * Contains some default values used by [rememberCoilPainter]. */ object CoilImageStateDefaults { /** @@ -62,132 +58,73 @@ object CoilImageStateDefaults { } } -@Suppress("NOTHING_TO_INLINE") @Composable -inline fun rememberCoilPainter( +fun rememberCoilPainter( data: Any?, imageLoader: ImageLoader = CoilImageStateDefaults.defaultImageLoader(), context: Context = LocalContext.current, - noinline shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, - noinline requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, + shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, fadeIn: Boolean = false, fadeInDurationMs: Int = 1000, @DrawableRes previewPlaceholder: Int = 0, -): Painter = rememberLoadPainter( - state = rememberCoilImageState( - data = data, - imageLoader = imageLoader, - context = context, - shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, - requestBuilder = requestBuilder - ), - fadeIn = fadeIn, - fadeInDurationMs = fadeInDurationMs, - previewPlaceholder = previewPlaceholder -) - -/** - * Creates a [CoilImageState] that is remembered across compositions. - * - * Changes to the provided values for [imageLoader] and [context] will **not** result - * in the state being recreated or changed in any way if it has already been created. - * Changes to [data], [shouldRefetchOnSizeChange] & [requestBuilder] will result in - * the [CoilImageState] being updated. - * - * @param data the value for [CoilImageState.data] - * @param imageLoader the value for [CoilImageState.imageLoader] - * @param context the initial value for [CoilImageState.context] - * @param shouldRefetchOnSizeChange the value for [CoilImageState.shouldRefetchOnSizeChange] - * @param requestBuilder the value for [CoilImageState.requestBuilder] - */ -@Composable -fun rememberCoilImageState( - data: Any?, - imageLoader: ImageLoader = CoilImageStateDefaults.defaultImageLoader(), - context: Context = LocalContext.current, - shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, - requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, -): CoilImageState = remember(imageLoader, context) { - CoilImageState( - imageLoader = imageLoader, - context = context, +): LoadPainter { + val updatedRequestBuilder by rememberUpdatedState(requestBuilder) + val updatedImageLoader by rememberUpdatedState(imageLoader) + + return rememberLoadPainter( + request = checkData(data), + loader = { request, size -> + executeCoilRequest(request, size, context, updatedImageLoader, updatedRequestBuilder) + }, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + fadeIn = fadeIn, + fadeInDurationMs = fadeInDurationMs, + previewPlaceholder = previewPlaceholder, ) -}.apply { - this.data = data - this.requestBuilder = requestBuilder - this.shouldRefetchOnSizeChange = shouldRefetchOnSizeChange } -/** - * A state object that can be hoisted for [com.google.accompanist.imageloading.rememberLoadPainter] - * to load images using [coil.Coil]. - * - * In most cases, this will be created via [rememberCoilImageState]. - * - * @param imageLoader The [ImageLoader] to use when requesting the image. Defaults to - * [CoilImageStateDefaults.defaultImageLoader]. - * @param context The Android [Context] to use when creating [ImageRequest]s. - * @param shouldRefetchOnSizeChange the value for [CoilImageState.shouldRefetchOnSizeChange]. - */ -@Stable -class CoilImageState( - private val imageLoader: ImageLoader, - private val context: Context, - shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, -) : ImageState(shouldRefetchOnSizeChange) { - private var currentData by mutableStateOf(null) - - override val request: Any? - get() = currentData - - /** - * Holds an optional builder for every created [ImageRequest]. - */ - var requestBuilder by mutableStateOf<(ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)?>(null) - - /** - * The data to load. See [ImageRequest.Builder.data] for the types supported. - */ - var data: Any? - get() = currentData - set(value) { - currentData = checkData(value) +@OptIn(ExperimentalCoroutinesApi::class) +private suspend fun executeCoilRequest( + request: Any, + size: IntSize, + context: Context, + imageLoader: ImageLoader, + requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)?, +): ImageLoadState { + val baseRequest = when (request) { + // If we've been given an ImageRequest instance, use it... + is ImageRequest -> request.newBuilder() + // Otherwise we construct a request from the data + else -> { + ImageRequest.Builder(context) + .data(request) + // We force in-exact precision as AUTOMATIC only works when used from views. + // INEXACT is correct as we can scale the result appropriately. + .precision(Precision.INEXACT) } - - override suspend fun executeRequest(request: Any, size: IntSize): ImageLoadState { - val baseRequest = when (request) { - // If we've been given an ImageRequest instance, use it... - is ImageRequest -> request.newBuilder() - // Otherwise we construct a request from the data - else -> { - ImageRequest.Builder(context) - .data(request) - // We force in-exact precision as AUTOMATIC only works when used from views. - // INEXACT is correct as we can scale the result appropriately. - .precision(Precision.INEXACT) - } - }.apply { - // Apply the request builder - requestBuilder?.invoke(this, size) - }.build() - - val sizedRequest = when { - // If the request has a size resolver set we just execute the request as-is - baseRequest.defined.sizeResolver != null -> baseRequest - // If the size contains an unspecified sized dimension, we don't specify a size - // in the Coil request - size.width < 0 || size.height < 0 -> baseRequest - // If we have a non-zero size, we can modify the request to include the size - size.width > 0 && size.height > 0 -> { - baseRequest.newBuilder().size(size.width, size.height).build() - } - // Otherwise we have a zero size, so no point executing a request so return empty now - else -> return ImageLoadState.Empty + }.apply { + // Apply the request builder + requestBuilder?.invoke(this, size) + }.build() + + val sizedRequest = when { + // If the request has a size resolver set we just execute the request as-is + baseRequest.defined.sizeResolver != null -> baseRequest + // If the size contains an unspecified sized dimension, we don't specify a size + // in the Coil request + size.width < 0 || size.height < 0 -> baseRequest + // If we have a non-zero size, we can modify the request to include the size + size.width > 0 && size.height > 0 -> { + baseRequest.newBuilder() + .size(size.width, size.height) + .build() } - - return imageLoader.execute(sizedRequest).toResult(request) + // Otherwise we have a zero size, so no point executing a request + else -> return ImageLoadState.Empty } + + return imageLoader.execute(sizedRequest).toResult(request) } private fun ImageResult.toResult(request: Any): ImageLoadState = when (this) { @@ -216,7 +153,7 @@ private fun coil.decode.DataSource.toDataSource(): DataSource = when (this) { private fun checkData(data: Any?): Any? { when (data) { - is android.graphics.drawable.Drawable -> { + is Drawable -> { throw IllegalArgumentException( "Unsupported type: Drawable." + " If you wish to load a drawable, pass in the resource ID." diff --git a/coil/src/main/java/com/google/accompanist/coil/DeprecatedCoil.kt b/coil/src/main/java/com/google/accompanist/coil/DeprecatedCoil.kt index 5e235dd12..565c0d7c1 100644 --- a/coil/src/main/java/com/google/accompanist/coil/DeprecatedCoil.kt +++ b/coil/src/main/java/com/google/accompanist/coil/DeprecatedCoil.kt @@ -36,7 +36,6 @@ import coil.ImageLoader import coil.request.ImageRequest import com.google.accompanist.imageloading.ImageLoadState import com.google.accompanist.imageloading.ImageSuchDeprecated -import com.google.accompanist.imageloading.MaterialLoadingImage import com.google.accompanist.imageloading.isFinalState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter @@ -99,23 +98,24 @@ fun CoilImage( onRequestCompleted: (ImageLoadState) -> Unit = {}, content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit ) { - val request = rememberCoilImageState( + val painter = rememberCoilPainter( data = data, requestBuilder = requestBuilder, imageLoader = imageLoader, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + previewPlaceholder = previewPlaceholder, ) - LaunchedEffect(request) { - snapshotFlow { request.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .collect { onRequestCompleted(it) } } @Suppress("DEPRECATION") ImageSuchDeprecated( - state = request, - previewPlaceholder = previewPlaceholder, + loadPainter = painter, + contentDescription = null, modifier = modifier, content = content ) @@ -193,7 +193,7 @@ fun CoilImage( } /** - * Creates a composable that will attempt to load the given [data] using [Coil], and then + * Creates a composable that will attempt to load the given [data] using [coil.Coil], and then * display the result in an [Image]. * * This version of the function is more opinionated, providing: @@ -279,11 +279,13 @@ fun CoilImage( error: @Composable (BoxScope.(ImageLoadState.Error) -> Unit)? = null, loading: @Composable (BoxScope.() -> Unit)? = null, ) { - val request = rememberCoilImageState( + val request = rememberCoilPainter( data = data, requestBuilder = requestBuilder, imageLoader = imageLoader, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + fadeIn = fadeIn, + previewPlaceholder = previewPlaceholder, ) LaunchedEffect(request) { @@ -294,24 +296,17 @@ fun CoilImage( @Suppress("DEPRECATION") ImageSuchDeprecated( - state = request, - previewPlaceholder = previewPlaceholder, + loadPainter = request, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, modifier = modifier, ) { imageState -> when (imageState) { - is ImageLoadState.Success -> { - MaterialLoadingImage( - result = imageState, - contentDescription = contentDescription, - fadeInEnabled = fadeIn, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter - ) - } is ImageLoadState.Error -> if (error != null) error(imageState) ImageLoadState.Loading -> if (loading != null) loading() - ImageLoadState.Empty -> Unit + else -> Unit } } } diff --git a/glide/api/glide.api b/glide/api/glide.api index 6166a1363..2b127ae92 100644 --- a/glide/api/glide.api +++ b/glide/api/glide.api @@ -3,14 +3,6 @@ public final class com/google/accompanist/glide/GlideImage { public static final fun GlideImage (Ljava/lang/Object;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLkotlin/jvm/functions/Function2;Lcom/bumptech/glide/RequestManager;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } -public final class com/google/accompanist/glide/GlideImageState : com/google/accompanist/imageloading/ImageState { - public fun (Lcom/bumptech/glide/RequestManager;Lkotlin/jvm/functions/Function2;)V - public final fun getData ()Ljava/lang/Object; - public final fun getRequestBuilder ()Lkotlin/jvm/functions/Function2; - public final fun setData (Ljava/lang/Object;)V - public final fun setRequestBuilder (Lkotlin/jvm/functions/Function2;)V -} - public final class com/google/accompanist/glide/GlideImageStateDefaults { public static final field $stable I public static final field INSTANCE Lcom/google/accompanist/glide/GlideImageStateDefaults; @@ -19,7 +11,6 @@ public final class com/google/accompanist/glide/GlideImageStateDefaults { public final class com/google/accompanist/glide/GlideKt { public static final fun getLocalRequestManager ()Landroidx/compose/runtime/ProvidableCompositionLocal; - public static final fun rememberGlideImageState (Ljava/lang/Object;Lcom/bumptech/glide/RequestManager;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Lcom/google/accompanist/glide/GlideImageState; - public static final fun rememberGlidePainter (Ljava/lang/Object;Lcom/bumptech/glide/RequestManager;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIILandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/graphics/painter/Painter; + public static final fun rememberGlidePainter (Ljava/lang/Object;Lcom/bumptech/glide/RequestManager;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIILandroidx/compose/runtime/Composer;II)Lcom/google/accompanist/imageloading/LoadPainter; } diff --git a/glide/src/androidTest/java/com/google/accompanist/glide/GlideTest.kt b/glide/src/androidTest/java/com/google/accompanist/glide/GlideTest.kt index 6a49e5e6f..693d16395 100644 --- a/glide/src/androidTest/java/com/google/accompanist/glide/GlideTest.kt +++ b/glide/src/androidTest/java/com/google/accompanist/glide/GlideTest.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -48,7 +49,6 @@ import com.bumptech.glide.Glide import com.google.accompanist.glide.test.R import com.google.accompanist.imageloading.ImageLoadState import com.google.accompanist.imageloading.isFinalState -import com.google.accompanist.imageloading.rememberLoadPainter import com.google.accompanist.imageloading.test.ImageMockWebServer import com.google.accompanist.imageloading.test.LaunchedOnRequestComplete import com.google.accompanist.imageloading.test.assertPixels @@ -95,11 +95,11 @@ class GlideTest { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(server.url("/image").toString()) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(server.url("/image").toString()) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(128.dp, 128.dp) @@ -121,11 +121,11 @@ class GlideTest { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(R.drawable.red_rectangle) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(R.drawable.red_rectangle) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(128.dp, 128.dp) @@ -149,11 +149,11 @@ class GlideTest { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(resourceUri(R.drawable.red_rectangle)) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(resourceUri(R.drawable.red_rectangle)) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(128.dp, 128.dp) @@ -224,11 +224,11 @@ class GlideTest { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(data.toString()) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(data.toString()) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(128.dp, 128.dp) @@ -272,18 +272,18 @@ class GlideTest { var size by mutableStateOf(128.dp) composeTestRule.setContent { - val state = rememberGlideImageState(server.url("/red").toString()) + val painter = rememberGlidePainter(server.url("/red").toString()) Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(size) .testTag(GlideTestTags.Image), ) - LaunchedEffect(state) { - snapshotFlow { state.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .onCompletion { loadStates.cancel() } .collect { loadStates.send(it) } @@ -312,11 +312,11 @@ class GlideTest { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(server.url("/image").toString()) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(server.url("/image").toString()) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier.testTag(GlideTestTags.Image), ) @@ -359,16 +359,42 @@ class GlideTest { .assertPixels(Color.Red) } + @Test + @SdkSuppress(minSdkVersion = 26) // captureToImage is SDK 26+ + fun previewPlaceholder() { + composeTestRule.setContent { + CompositionLocalProvider(LocalInspectionMode provides true) { + Image( + painter = rememberGlidePainter( + data = "blah", + previewPlaceholder = R.drawable.red_rectangle, + ), + contentDescription = null, + modifier = Modifier + .size(128.dp, 128.dp) + .testTag(GlideTestTags.Image), + ) + } + } + + composeTestRule.onNodeWithTag(GlideTestTags.Image) + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + .assertIsDisplayed() + .captureToImage() + .assertPixels(Color.Red) + } + @Test fun errorStillHasSize() { var requestCompleted by mutableStateOf(false) composeTestRule.setContent { - val state = rememberGlideImageState(server.url("/noimage").toString()) - LaunchedOnRequestComplete(state) { requestCompleted = true } + val painter = rememberGlidePainter(server.url("/noimage").toString()) + LaunchedOnRequestComplete(painter) { requestCompleted = true } Image( - painter = rememberLoadPainter(state), + painter = painter, contentDescription = null, modifier = Modifier .size(128.dp, 128.dp) diff --git a/glide/src/main/java/com/google/accompanist/glide/DeprecatedGlide.kt b/glide/src/main/java/com/google/accompanist/glide/DeprecatedGlide.kt index 329f58f16..c7ea60306 100644 --- a/glide/src/main/java/com/google/accompanist/glide/DeprecatedGlide.kt +++ b/glide/src/main/java/com/google/accompanist/glide/DeprecatedGlide.kt @@ -40,7 +40,6 @@ import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestManager import com.google.accompanist.imageloading.ImageLoadState import com.google.accompanist.imageloading.ImageSuchDeprecated -import com.google.accompanist.imageloading.MaterialLoadingImage import com.google.accompanist.imageloading.isFinalState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter @@ -103,23 +102,24 @@ fun GlideImage( onRequestCompleted: (ImageLoadState) -> Unit = {}, content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit ) { - val request = rememberGlideImageState( + val painter = rememberGlidePainter( data = data, requestManager = requestManager, requestBuilder = requestBuilder, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + previewPlaceholder = previewPlaceholder, ) - LaunchedEffect(request) { - snapshotFlow { request.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .collect { onRequestCompleted(it) } } @Suppress("DEPRECATION") ImageSuchDeprecated( - state = request, - previewPlaceholder = previewPlaceholder, + loadPainter = painter, + contentDescription = null, modifier = modifier, content = content ) @@ -212,39 +212,34 @@ fun GlideImage( error: @Composable (BoxScope.(ImageLoadState.Error) -> Unit)? = null, loading: @Composable (BoxScope.() -> Unit)? = null, ) { - val request = rememberGlideImageState( + val painter = rememberGlidePainter( data = data, requestManager = requestManager, requestBuilder = requestBuilder, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + fadeIn = fadeIn, + previewPlaceholder = previewPlaceholder, ) - LaunchedEffect(request) { - snapshotFlow { request.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .collect { onRequestCompleted(it) } } @Suppress("DEPRECATION") ImageSuchDeprecated( - state = request, - previewPlaceholder = previewPlaceholder, + loadPainter = painter, modifier = modifier, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter ) { imageState -> when (imageState) { - is ImageLoadState.Success -> { - MaterialLoadingImage( - result = imageState, - contentDescription = contentDescription, - fadeInEnabled = fadeIn, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter - ) - } is ImageLoadState.Error -> if (error != null) error(imageState) ImageLoadState.Loading -> if (loading != null) loading() - ImageLoadState.Empty -> Unit + else -> Unit } } } diff --git a/glide/src/main/java/com/google/accompanist/glide/Glide.kt b/glide/src/main/java/com/google/accompanist/glide/Glide.kt index 41915a0f5..d8548dc7c 100644 --- a/glide/src/main/java/com/google/accompanist/glide/Glide.kt +++ b/glide/src/main/java/com/google/accompanist/glide/Glide.kt @@ -19,11 +19,8 @@ package com.google.accompanist.glide import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter @@ -38,19 +35,19 @@ import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.google.accompanist.imageloading.DataSource import com.google.accompanist.imageloading.ImageLoadState -import com.google.accompanist.imageloading.ImageState +import com.google.accompanist.imageloading.LoadPainter import com.google.accompanist.imageloading.rememberLoadPainter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine /** * Composition local containing the preferred [RequestManager] to use - * for [rememberGlideImageState]. + * for [rememberGlidePainter]. */ val LocalRequestManager = staticCompositionLocalOf { null } /** - * Contains some default values used for [GlideImageState]. + * Contains some default values used for [rememberGlidePainter]. */ object GlideImageStateDefaults { /** @@ -66,175 +63,117 @@ object GlideImageStateDefaults { } } -@Suppress("NOTHING_TO_INLINE") @Composable -inline fun rememberGlidePainter( +fun rememberGlidePainter( data: Any?, requestManager: RequestManager = GlideImageStateDefaults.defaultRequestManager(), - noinline shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, - noinline requestBuilder: (RequestBuilder.(size: IntSize) -> RequestBuilder)? = null, + shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, + requestBuilder: (RequestBuilder.(size: IntSize) -> RequestBuilder)? = null, fadeIn: Boolean = false, fadeInDurationMs: Int = 1000, @DrawableRes previewPlaceholder: Int = 0, -): Painter = rememberLoadPainter( - state = rememberGlideImageState( - data = data, - requestManager = requestManager, +): LoadPainter { + val updatedRequestBuilder by rememberUpdatedState(requestBuilder) + val updatedRequestManager by rememberUpdatedState(requestManager) + + return rememberLoadPainter( + request = checkData(data), + loader = { request, size -> + executeGlideRequest(request, size, updatedRequestManager, updatedRequestBuilder) + }, shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, - requestBuilder = requestBuilder - ), - fadeIn = fadeIn, - fadeInDurationMs = fadeInDurationMs, - previewPlaceholder = previewPlaceholder -) - -/** - * Creates a [GlideImageState] that is remembered across compositions. - * - * Changes to the provided values for [requestManager] will **not** result - * in the state being recreated or changed in any way if it has already been created. - * Changes to [data], [shouldRefetchOnSizeChange] & [requestBuilder] will result in - * the [GlideImageState] being updated. - * - * @param data the value for [GlideImageState.data] - * @param requestManager the initial value for [GlideImageState.requestManager] - * @param shouldRefetchOnSizeChange the value for [GlideImageState.shouldRefetchOnSizeChange] - * @param requestBuilder the value for [GlideImageState.requestBuilder] - */ -@Composable -fun rememberGlideImageState( - data: Any?, - requestManager: RequestManager = GlideImageStateDefaults.defaultRequestManager(), - shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean = { _, _ -> false }, - requestBuilder: (RequestBuilder.(size: IntSize) -> RequestBuilder)? = null, -): GlideImageState = remember(requestManager) { - GlideImageState(requestManager, shouldRefetchOnSizeChange) -}.apply { - this.data = data - this.requestBuilder = requestBuilder - this.shouldRefetchOnSizeChange = shouldRefetchOnSizeChange -} - -/** - * A state object that can be hoisted for [com.google.accompanist.imageloading.rememberLoadPainter] - * to load images using [Glide]. - * - * In most cases, this will be created via [rememberGlideImageState]. - * - * @param requestManager The [RequestManager] to use when requesting the image. - * @param shouldRefetchOnSizeChange Initial value for [GlideImageState.shouldRefetchOnSizeChange] - */ -@Stable -class GlideImageState( - private val requestManager: RequestManager, - shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean, -) : ImageState(shouldRefetchOnSizeChange) { - private var currentData by mutableStateOf(null) - - override val request: Any? - get() = currentData - - /** - * The data to load. See [RequestManager.load] for the types supported. - */ - var data: Any? - get() = currentData - set(value) { - currentData = checkData(value) - } - - /** - * Holds an optional builder for every created [RequestBuilder]. - */ - var requestBuilder by mutableStateOf<(RequestBuilder.(size: IntSize) -> RequestBuilder)?>( - null + fadeIn = fadeIn, + fadeInDurationMs = fadeInDurationMs, + previewPlaceholder = previewPlaceholder ) +} - @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun executeRequest( - request: Any, - size: IntSize - ): ImageLoadState = suspendCancellableCoroutine { cont -> - var failException: Throwable? = null - - val target = object : EmptyCustomTarget( - if (size.width > 0) size.width else Target.SIZE_ORIGINAL, - if (size.height > 0) size.height else Target.SIZE_ORIGINAL - ) { - override fun onLoadFailed(errorDrawable: Drawable?) { - if (cont.isCompleted) { - // If we've already completed, ignore this - return - } +@OptIn(ExperimentalCoroutinesApi::class) +private suspend fun executeGlideRequest( + request: Any, + size: IntSize, + requestManager: RequestManager, + requestBuilder: (RequestBuilder.(size: IntSize) -> RequestBuilder)?, +): ImageLoadState = suspendCancellableCoroutine { cont -> + var failException: Throwable? = null + + val target = object : EmptyCustomTarget( + if (size.width > 0) size.width else Target.SIZE_ORIGINAL, + if (size.height > 0) size.height else Target.SIZE_ORIGINAL + ) { + override fun onLoadFailed(errorDrawable: Drawable?) { + if (cont.isCompleted) { + // If we've already completed, ignore this + return + } - val result = ImageLoadState.Error( - result = errorDrawable, - request = request, - throwable = failException - ?: IllegalArgumentException("Error while loading $request") - ) + val result = ImageLoadState.Error( + result = errorDrawable, + request = request, + throwable = failException + ?: IllegalArgumentException("Error while loading $request") + ) - cont.resume(result) { - // Clear any resources from the target if cancelled - requestManager.clear(this) - } + cont.resume(result) { + // Clear any resources from the target if cancelled + requestManager.clear(this) } } + } - val listener = object : RequestListener { - override fun onResourceReady( - drawable: Drawable, - model: Any, - target: Target, - dataSource: com.bumptech.glide.load.DataSource, - isFirstResource: Boolean - ): Boolean { - if (cont.isCompleted) { - // If we've already completed, ignore this - return true - } - - val result = ImageLoadState.Success( - result = drawable, - request = request, - source = dataSource.toDataSource() - ) - - cont.resume(result) { - // Clear any resources from the target if cancelled - requestManager.clear(target) - } - - // Return true so that the target doesn't receive the drawable + val listener = object : RequestListener { + override fun onResourceReady( + drawable: Drawable, + model: Any, + target: Target, + dataSource: com.bumptech.glide.load.DataSource, + isFirstResource: Boolean + ): Boolean { + if (cont.isCompleted) { + // If we've already completed, ignore this return true } - override fun onLoadFailed( - e: GlideException?, - model: Any, - target: Target, - isFirstResource: Boolean - ): Boolean { - // Glide only passes the exception to the listener, so we store it - // for the target to use - failException = e - // Return false, allowing the target to receive it's onLoadFailed. - // This is needed so we can use any errorDrawable - return false + val result = ImageLoadState.Success( + result = drawable, + request = request, + source = dataSource.toDataSource() + ) + + cont.resume(result) { + // Clear any resources from the target if cancelled + requestManager.clear(target) } - } - // Start the image request into the target - requestManager.load(request) - .apply { requestBuilder?.invoke(this, size) } - .addListener(listener) - .into(target) + // Return true so that the target doesn't receive the drawable + return true + } - // If we're cancelled, clear the request from Glide - cont.invokeOnCancellation { - requestManager.clear(target) + override fun onLoadFailed( + e: GlideException?, + model: Any, + target: Target, + isFirstResource: Boolean + ): Boolean { + // Glide only passes the exception to the listener, so we store it + // for the target to use + failException = e + // Return false, allowing the target to receive it's onLoadFailed. + // This is needed so we can use any errorDrawable + return false } } + + // Start the image request into the target + requestManager.load(request) + .apply { requestBuilder?.invoke(this, size) } + .addListener(listener) + .into(target) + + // If we're cancelled, clear the request from Glide + cont.invokeOnCancellation { + requestManager.clear(target) + } } private fun com.bumptech.glide.load.DataSource.toDataSource(): DataSource = when (this) { diff --git a/imageloading-core/api/imageloading-core.api b/imageloading-core/api/imageloading-core.api index 7dddf9013..30f96b27d 100644 --- a/imageloading-core/api/imageloading-core.api +++ b/imageloading-core/api/imageloading-core.api @@ -7,6 +7,7 @@ public final class com/google/accompanist/imageloading/DataSource : java/lang/En } public final class com/google/accompanist/imageloading/DeprecatedKt { + public static final fun ImageSuchDeprecated (Lcom/google/accompanist/imageloading/LoadPainter;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;FLandroidx/compose/ui/graphics/ColorFilter;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun getDefaultRefetchOnSizeChangeLambda ()Lkotlin/jvm/functions/Function2; } @@ -16,8 +17,7 @@ public final class com/google/accompanist/imageloading/DrawablePainterKt { } public final class com/google/accompanist/imageloading/ImageKt { - public static final fun ImageSuchDeprecated (Lcom/google/accompanist/imageloading/ImageState;Landroidx/compose/ui/Modifier;ILkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun rememberLoadPainter (Lcom/google/accompanist/imageloading/ImageState;ZIILandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/graphics/painter/Painter; + public static final fun rememberLoadPainter (Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;ZIILandroidx/compose/runtime/Composer;II)Lcom/google/accompanist/imageloading/LoadPainter; } public abstract class com/google/accompanist/imageloading/ImageLoadState { @@ -71,12 +71,17 @@ public final class com/google/accompanist/imageloading/ImageLoadStateKt { public static final fun isFinalState (Lcom/google/accompanist/imageloading/ImageLoadState;)Z } -public abstract class com/google/accompanist/imageloading/ImageState { - public fun (Lkotlin/jvm/functions/Function2;)V - protected abstract fun executeRequest-JVtK1S4 (Ljava/lang/Object;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +public final class com/google/accompanist/imageloading/LoadPainter : androidx/compose/ui/graphics/painter/Painter, androidx/compose/runtime/RememberObserver { + public static final field $stable I + public fun (Lkotlin/jvm/functions/Function3;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function2;)V + public fun getIntrinsicSize-NH-jbRc ()J public final fun getLoadState ()Lcom/google/accompanist/imageloading/ImageLoadState; - protected abstract fun getRequest ()Ljava/lang/Object; + public final fun getRequest ()Ljava/lang/Object; public final fun getShouldRefetchOnSizeChange ()Lkotlin/jvm/functions/Function2; + public fun onAbandoned ()V + public fun onForgotten ()V + public fun onRemembered ()V + public final fun setRequest (Ljava/lang/Object;)V public final fun setShouldRefetchOnSizeChange (Lkotlin/jvm/functions/Function2;)V } diff --git a/imageloading-core/src/main/java/com/google/accompanist/imageloading/Deprecated.kt b/imageloading-core/src/main/java/com/google/accompanist/imageloading/Deprecated.kt index e453a68b3..2c6606362 100644 --- a/imageloading-core/src/main/java/com/google/accompanist/imageloading/Deprecated.kt +++ b/imageloading-core/src/main/java/com/google/accompanist/imageloading/Deprecated.kt @@ -18,6 +18,14 @@ package com.google.accompanist.imageloading +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.IntSize /** @@ -25,3 +33,37 @@ import androidx.compose.ui.unit.IntSize */ @Deprecated("Create your own lambda instead", ReplaceWith("{ _, _ -> false }")) val DefaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } + +/** + * @hide + */ +@Deprecated("Only used to help migration. DO NOT USE.") +@Composable +fun ImageSuchDeprecated( + loadPainter: LoadPainter, + contentDescription: String?, + modifier: Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = 1f, + colorFilter: ColorFilter? = null, + content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit +) { + Box( + propagateMinConstraints = true, + modifier = modifier, + ) { + Image( + painter = loadPainter, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + ) + if (loadPainter.loadState !is ImageLoadState.Success) { + content(loadPainter.loadState) + } + } +} diff --git a/imageloading-core/src/main/java/com/google/accompanist/imageloading/Image.kt b/imageloading-core/src/main/java/com/google/accompanist/imageloading/Image.kt index 04a5a0e43..0f9dce473 100644 --- a/imageloading-core/src/main/java/com/google/accompanist/imageloading/Image.kt +++ b/imageloading-core/src/main/java/com/google/accompanist/imageloading/Image.kt @@ -16,22 +16,16 @@ package com.google.accompanist.imageloading +import android.annotation.SuppressLint import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.ColorFilter @@ -40,10 +34,10 @@ import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -65,63 +59,93 @@ import kotlin.math.roundToInt * ran in preview mode. */ @Composable -fun rememberLoadPainter( - state: ImageState, +fun rememberLoadPainter( + request: R?, + loader: suspend (R, size: IntSize) -> ImageLoadState, + shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean, fadeIn: Boolean = false, fadeInDurationMs: Int = DefaultTransitionDuration, @DrawableRes previewPlaceholder: Int = 0, -): Painter { - if (LocalInspectionMode.current && previewPlaceholder != 0) { - // If we're in inspection mode (preview) and we have a preview placeholder, just draw - // that using an Image and return - return painterResource(previewPlaceholder) - } - +): LoadPainter { val coroutineScope = rememberCoroutineScope() - val imageLoader = remember(state, coroutineScope) { - ImageLoader(state, coroutineScope) - } - - // This runs our fade in animation - val fadeInColorFilter = fadeInAsState( - imageState = state, - enabled = { result -> - // We run the fade in animation if the result is loaded from disk/network. This allows - // us to approximate only running the animation on 'first load' - fadeIn && result is ImageLoadState.Success && result.source != DataSource.MEMORY - }, - durationMs = fadeInDurationMs - ) - - // Our result painter, created from the ImageState with some composition lifecycle - // callbacks - val resultPainter = state.painterAsState() - // Our painter. We use a DelegatingPainter which delegates the actual drawn/laid-out painter // to our result painter state. That block will be called during layout and drawing, // which means that any changes to `state.loadState` will automatically re-trigger layout/draw. - return remember(state) { + return remember(coroutineScope) { LoadPainter( - painter = { resultPainter.value }, - transitionColorFilter = { fadeInColorFilter.value }, - imageLoader = imageLoader, + loader = loader, + coroutineScope = coroutineScope, + shouldRefetchOnSizeChange = shouldRefetchOnSizeChange + ) + }.apply { + this.shouldRefetchOnSizeChange = shouldRefetchOnSizeChange + this.request = request + }.also { loadPainter -> + // This runs our fade in animation + loadPainter.fadeInAsState( + enabled = { result -> + // We run the fade in animation if the result is loaded from disk/network. This allows + // us to approximate only running the animation on 'first load' + fadeIn && result is ImageLoadState.Success && result.source != DataSource.MEMORY + }, + durationMs = fadeInDurationMs, ) + + // Our result painter, created from the ImageState with some composition lifecycle + // callbacks + updatePainter(loadPainter, previewPlaceholder) } } -private class LoadPainter( - private val painter: () -> Painter, - private val transitionColorFilter: () -> ColorFilter?, - private val imageLoader: ImageLoader, -) : Painter() { +/** + * TODO + * + * @param R + * @property loader + * @property coroutineScope + * @constructor + * TODO + * + * @param shouldRefetchOnSizeChange + */ +class LoadPainter( + private val loader: suspend (request: R, size: IntSize) -> ImageLoadState, + private val coroutineScope: CoroutineScope, + shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean, +) : Painter(), RememberObserver { private val paint by lazy(LazyThreadSafetyMode.NONE) { Paint() } - private var alpha: Float = 1f - private var colorFilter: ColorFilter? = null + internal var painter by mutableStateOf(EmptyPainter) + internal var transitionColorFilter by mutableStateOf(null) + + /** + * TODO + */ + var shouldRefetchOnSizeChange by mutableStateOf(shouldRefetchOnSizeChange) + + /** + * TODO + */ + var loadState: ImageLoadState by mutableStateOf(ImageLoadState.Empty) + private set + + /** + * TODO + */ + var request by mutableStateOf(null) + + private var alpha: Float by mutableStateOf(1f) + private var colorFilter: ColorFilter? by mutableStateOf(null) + + // Our size to use when performing the image load request + private var requestSize by mutableStateOf(null) + + // Current request job + private var job: Job? = null override val intrinsicSize: Size - get() = painter().intrinsicSize + get() = painter.intrinsicSize override fun applyAlpha(alpha: Float): Boolean { this.alpha = alpha @@ -135,13 +159,12 @@ private class LoadPainter( override fun DrawScope.onDraw() { // Update the request size, based on the provided canvas size - imageLoader.requestSize = IntSize( + requestSize = IntSize( width = if (size.width >= 0.5f) size.width.roundToInt() else -1, height = if (size.height >= 0.5f) size.width.roundToInt() else -1, ) - val transitionColorFilter = transitionColorFilter() - + val transitionColorFilter = transitionColorFilter if (colorFilter != null && transitionColorFilter != null) { // If we have a transition color filter, and a specified color filter we need to // draw the content in a layer for both to apply. @@ -149,79 +172,18 @@ private class LoadPainter( drawIntoCanvas { canvas -> paint.colorFilter = transitionColorFilter canvas.saveLayer(bounds = size.toRect(), paint = paint) - with(painter()) { + with(painter) { draw(size, alpha, colorFilter) } canvas.restore() } } else { // Otherwise we just draw the content directly, using the filter - with(painter()) { + with(painter) { draw(size, alpha, colorFilter ?: transitionColorFilter) } } } -} - -/** - * Allows us observe the current result [Painter] as state. This function allows us to - * minimize the amount of composition needed, such that only this function needs to be restarted - * when the `loadState` changes. - */ -@Composable -private fun ImageState<*>.painterAsState(): State { - val painter = loadState.drawable - ?.let { rememberDrawablePainter(it) } - ?: EmptyPainter - return rememberUpdatedState(painter) -} - -@Composable -private fun fadeInAsState( - imageState: ImageState<*>, - enabled: (ImageLoadState) -> Boolean, - durationMs: Int, -): State { - val colorFilter = remember(imageState.internalRequest) { mutableStateOf(null) } - - val loadState = imageState.loadState - if (enabled(loadState)) { - val colorMatrix = remember { ColorMatrix() } - val fadeInTransition = updateFadeInTransition(loadState, durationMs) - - colorFilter.value = if (!fadeInTransition.isFinished) { - colorMatrix.apply { - updateAlpha(fadeInTransition.alpha) - updateBrightness(fadeInTransition.brightness) - updateSaturation(fadeInTransition.saturation) - }.let { ColorFilter.colorMatrix(it) } - } else { - // If the fade-in isn't running, reset the color matrix - null - } - } else { - // If the fade in is not enabled, we don't use a fade in transition - colorFilter.value = null - } - - return colorFilter -} - -@Stable -private class ImageLoader( - val state: ImageState, - val coroutineScope: CoroutineScope, -) : RememberObserver { - // Our size to use when performing the image load request - var requestSize by mutableStateOf(null) - - // Current request job - private var job: Job? = null - - // Our layout modifier, which allows us to receive the incoming constraints to update - // requestSize. Using a modifier allows us to avoid using BoxWithConstraints and the cost of - // subcomposition. For most usages subcomposition is fine, but Image's tend to be used - // in large quantities which multiplies the cost. override fun onAbandoned() { // We've been abandoned from composition, so cancel our request handling coroutine @@ -244,65 +206,101 @@ private class ImageLoader( // will run and execute the image load (with any on-going request cancelled). job = coroutineScope.launch { combine( - state.internalRequestFlow, + snapshotFlow { request }, snapshotFlow { requestSize }.filterNotNull(), transform = { request, size -> request to size } ).collectLatest { (request, size) -> - state.execute(request, size) + execute(request, size) } } } + + /** + * The function which executes the requests, and update [loadState] as appropriate with the + * result. + */ + private suspend fun execute(request: R?, size: IntSize) { + if (request == null) { + // If we don't have a request, set our state to Empty and return + loadState = ImageLoadState.Empty + return + } + + if (loadState != ImageLoadState.Empty && + request == loadState.request && + !shouldRefetchOnSizeChange(loadState, size) + ) { + // If we're not empty, the request is the same and shouldRefetchOnSizeChange() + // returns false, return now to skip this request + return + } + + // Otherwise we're about to start a request, so set us to 'Loading' + loadState = ImageLoadState.Loading + + loadState = try { + loader(request, size) + } catch (ce: CancellationException) { + // We specifically don't do anything for the request coroutine being + // cancelled: https://github.com/google/accompanist/issues/217 + throw ce + } catch (e: Error) { + // Re-throw all Errors + throw e + } catch (e: IllegalStateException) { + // Re-throw all IllegalStateExceptions + throw e + } catch (e: IllegalArgumentException) { + // Re-throw all IllegalArgumentExceptions + throw e + } catch (t: Throwable) { + // Anything else, we wrap in a Error state instance + ImageLoadState.Error(result = null, throwable = t, request = request) + } + } } /** - * @hide + * Allows us observe the current result [Painter] as state. This function allows us to + * minimize the amount of composition needed, such that only this function needs to be restarted + * when the `loadState` changes. */ -@Deprecated("Only used to help migration. DO NOT USE.") +@SuppressLint("ComposableNaming") @Composable -fun ImageSuchDeprecated( - state: ImageState, - modifier: Modifier, +private fun updatePainter( + loadPainter: LoadPainter, @DrawableRes previewPlaceholder: Int = 0, - content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit ) { - if (LocalInspectionMode.current && previewPlaceholder != 0) { + loadPainter.painter = if (LocalInspectionMode.current && previewPlaceholder != 0) { // If we're in inspection mode (preview) and we have a preview placeholder, just draw // that using an Image and return - Image( - painter = painterResource(previewPlaceholder), - contentDescription = null, - modifier = modifier, - ) - return - } - - val coroutineScope = rememberCoroutineScope() - - val imageLoader = remember(state, coroutineScope) { - ImageLoader(state, coroutineScope) + painterResource(previewPlaceholder) + } else { + loadPainter.loadState.drawable?.let { rememberDrawablePainter(it) } ?: EmptyPainter } +} - Box( - propagateMinConstraints = true, - modifier = modifier - // Layout modifier to receive the incoming constraints, such that we can use them - // to update our request size - .layout { measurable, constraints -> - // Update our request size. The observing flow below checks shouldRefetchOnSizeChange - imageLoader.requestSize = IntSize( - width = if (constraints.hasBoundedWidth) constraints.maxWidth else -1, - height = if (constraints.hasBoundedHeight) constraints.maxHeight else -1 - ) +@Composable +private fun LoadPainter.fadeInAsState( + enabled: (ImageLoadState) -> Boolean, + durationMs: Int, +) { + val state = loadState + transitionColorFilter = if (enabled(state)) { + val colorMatrix = remember { ColorMatrix() } + val fadeInTransition = updateFadeInTransition(state, durationMs) - // No-op measure + layout - val placeable = measurable.measure(constraints) - layout(width = placeable.width, height = placeable.height) { - placeable.place(0, 0) - } - } - ) { - content(state.loadState) - } + if (!fadeInTransition.isFinished) { + colorMatrix.apply { + updateAlpha(fadeInTransition.alpha) + updateBrightness(fadeInTransition.brightness) + updateSaturation(fadeInTransition.saturation) + }.let { ColorFilter.colorMatrix(it) } + } else { + // If the fade-in isn't running, reset the color matrix + null + } + } else null // If the fade in is not enabled, we don't use a fade in transition } private object EmptyPainter : Painter() { diff --git a/imageloading-core/src/main/java/com/google/accompanist/imageloading/ImageState.kt b/imageloading-core/src/main/java/com/google/accompanist/imageloading/ImageState.kt deleted file mode 100644 index 45c1c5848..000000000 --- a/imageloading-core/src/main/java/com/google/accompanist/imageloading/ImageState.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2021 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 com.google.accompanist.imageloading - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.unit.IntSize -import kotlinx.coroutines.flow.Flow -import kotlin.coroutines.cancellation.CancellationException - -/** - * A state base class that can be hoisted to control image loads for [rememberLoadPainter]. - * - * @param shouldRefetchOnSizeChange Initial value for [shouldRefetchOnSizeChange]. - */ -@Stable -abstract class ImageState( - shouldRefetchOnSizeChange: (currentState: ImageLoadState, size: IntSize) -> Boolean, -) { - /** - * The current request object. - * - * Extending classes should return the current request object, which should be backed by - * a [androidx.compose.runtime.State] instance. - */ - protected abstract val request: R? - - /** - * The current [ImageLoadState]. - */ - var loadState by mutableStateOf(ImageLoadState.Empty) - private set - - /** - * Lambda which will be invoked when the size changes, allowing - * optional re-fetching of the image. - */ - var shouldRefetchOnSizeChange by mutableStateOf(shouldRefetchOnSizeChange) - - /** - * The function which executes the requests, and update [loadState] as appropriate with the - * result. - */ - internal suspend fun execute(request: R?, size: IntSize) { - if (request == null) { - // If we don't have a request, set our state to Empty and return - loadState = ImageLoadState.Empty - return - } - - if (loadState != ImageLoadState.Empty && - request == loadState.request && - !shouldRefetchOnSizeChange(loadState, size) - ) { - // If we're not empty, the request is the same and shouldRefetchOnSizeChange() - // returns false, return now to skip this request - return - } - - // Otherwise we're about to start a request, so set us to 'Loading' - loadState = ImageLoadState.Loading - - loadState = try { - executeRequest(request, size) - } catch (ce: CancellationException) { - // We specifically don't do anything for the request coroutine being - // cancelled: https://github.com/google/accompanist/issues/217 - throw ce - } catch (e: Error) { - // Re-throw all Errors - throw e - } catch (e: IllegalStateException) { - // Re-throw all IllegalStateExceptions - throw e - } catch (e: IllegalArgumentException) { - // Re-throw all IllegalArgumentExceptions - throw e - } catch (t: Throwable) { - // Anything else, we wrap in a Error state instance - ImageLoadState.Error(result = null, throwable = t, request = request) - } - } - - /** - * Extending classes should implement this function to execute the [request] with the given - * [size] constraints. - */ - protected abstract suspend fun executeRequest(request: R, size: IntSize): ImageLoadState - - internal val internalRequestFlow: Flow get() = snapshotFlow { request } - internal val internalRequest get() = request -} diff --git a/imageloading-testutils/src/main/java/com/google/accompanist/imageloading/test/ImageRequest.kt b/imageloading-testutils/src/main/java/com/google/accompanist/imageloading/test/ImageRequest.kt index b40893bb4..6120ff905 100644 --- a/imageloading-testutils/src/main/java/com/google/accompanist/imageloading/test/ImageRequest.kt +++ b/imageloading-testutils/src/main/java/com/google/accompanist/imageloading/test/ImageRequest.kt @@ -19,7 +19,7 @@ package com.google.accompanist.imageloading.test import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow -import com.google.accompanist.imageloading.ImageState +import com.google.accompanist.imageloading.LoadPainter import com.google.accompanist.imageloading.isFinalState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter @@ -31,11 +31,11 @@ import kotlinx.coroutines.flow.filter */ @Composable inline fun LaunchedOnRequestComplete( - state: ImageState<*>, + painter: LoadPainter<*>, crossinline block: () -> Unit ) { - LaunchedEffect(state) { - snapshotFlow { state.loadState } + LaunchedEffect(painter) { + snapshotFlow { painter.loadState } .filter { it.isFinalState() } .collect { block() } } diff --git a/sample/src/main/java/com/google/accompanist/sample/coil/CoilBasicSample.kt b/sample/src/main/java/com/google/accompanist/sample/coil/CoilBasicSample.kt index 1b0d27677..ab30e2074 100644 --- a/sample/src/main/java/com/google/accompanist/sample/coil/CoilBasicSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/coil/CoilBasicSample.kt @@ -45,10 +45,8 @@ import coil.ImageLoader import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.transform.CircleCropTransformation -import com.google.accompanist.coil.rememberCoilImageState import com.google.accompanist.coil.rememberCoilPainter import com.google.accompanist.imageloading.ImageLoadState -import com.google.accompanist.imageloading.rememberLoadPainter import com.google.accompanist.sample.AccompanistSampleTheme import com.google.accompanist.sample.R import com.google.accompanist.sample.rememberRandomSampleImageUrl @@ -112,15 +110,15 @@ private fun Sample() { item { // Loading content Box { - val coilState = rememberCoilImageState(rememberRandomSampleImageUrl()) + val coilPainter = rememberCoilPainter(rememberRandomSampleImageUrl()) Image( - painter = rememberLoadPainter(state = coilState), + painter = coilPainter, contentDescription = null, modifier = Modifier.size(128.dp), ) - Crossfade(coilState.loadState) { state -> + Crossfade(coilPainter.loadState) { state -> if (state == ImageLoadState.Loading) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } diff --git a/sample/src/main/java/com/google/accompanist/sample/glide/GlideBasicSample.kt b/sample/src/main/java/com/google/accompanist/sample/glide/GlideBasicSample.kt index aeff47510..e87c87cce 100644 --- a/sample/src/main/java/com/google/accompanist/sample/glide/GlideBasicSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/glide/GlideBasicSample.kt @@ -38,10 +38,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.accompanist.glide.rememberGlideImageState import com.google.accompanist.glide.rememberGlidePainter import com.google.accompanist.imageloading.ImageLoadState -import com.google.accompanist.imageloading.rememberLoadPainter import com.google.accompanist.sample.AccompanistSampleTheme import com.google.accompanist.sample.R import com.google.accompanist.sample.rememberRandomSampleImageUrl @@ -91,16 +89,16 @@ private fun Sample() { item { // Loading content Box { - val coilState = rememberGlideImageState(rememberRandomSampleImageUrl()) + val glidePainter = rememberGlidePainter(rememberRandomSampleImageUrl()) Image( - painter = rememberLoadPainter(state = coilState), + painter = glidePainter, contentDescription = null, modifier = Modifier.size(128.dp), ) Crossfade( - targetState = coilState.loadState, + targetState = glidePainter.loadState, modifier = Modifier .align(Alignment.Center) .padding(16.dp) @@ -127,15 +125,15 @@ private fun Sample() { item { // Fade in and loading content Box { - val coilState = rememberGlideImageState(rememberRandomSampleImageUrl()) + val glidePainter = rememberGlidePainter(rememberRandomSampleImageUrl()) Image( - painter = rememberLoadPainter(state = coilState, fadeIn = true), + painter = glidePainter, contentDescription = null, modifier = Modifier.size(128.dp), ) - Crossfade(coilState.loadState) { state -> + Crossfade(glidePainter.loadState) { state -> if (state == ImageLoadState.Loading) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } @@ -146,14 +144,14 @@ private fun Sample() { item { // Implicit size Box { - val glideState = rememberGlideImageState(rememberRandomSampleImageUrl()) + val glidePainter = rememberGlidePainter(rememberRandomSampleImageUrl()) Image( - painter = rememberLoadPainter(state = glideState), + painter = glidePainter, contentDescription = null, ) - Crossfade(glideState.loadState) { state -> + Crossfade(glidePainter.loadState) { state -> if (state == ImageLoadState.Loading) { CircularProgressIndicator(Modifier.align(Alignment.Center)) }