diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java index 6e4c6892be..8a8585c064 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java @@ -31,8 +31,8 @@ import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; import com.bumptech.glide.test.ResourceIds.raw; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import com.bumptech.glide.testutil.ConcurrencyHelper; import com.bumptech.glide.testutil.TearDownGlide; import com.google.common.truth.Truth; @@ -308,7 +308,7 @@ public void clearDiskCache_doesNotPreventFutureLoads() { // Tests #2428. @Test public void onlyRetrieveFromCache_withPreviousRequestLoadingFromSource_doesNotBlock() { - final WaitModel waitModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel waitModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); FutureTarget loadFromSourceFuture = GlideApp.with(context).load(waitModel).submit(); diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java index 21c8c2e8ee..83e6c4effb 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java @@ -25,8 +25,8 @@ import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import com.bumptech.glide.testutil.ConcurrencyHelper; import com.bumptech.glide.testutil.TearDownGlide; import java.io.File; @@ -114,7 +114,7 @@ public void load_whenLoadSucceeds_butEncoderFails_doesNotCallOnLoadFailed() { @Test public void clearRequest_withError_afterPrimaryFails_clearsErrorRequest() { - WaitModel errorModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + WaitModel errorModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); FutureTarget target = Glide.with(context) diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java index 340982d8a1..269d174733 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java @@ -18,8 +18,8 @@ import com.bumptech.glide.request.target.Target; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import com.bumptech.glide.testutil.ConcurrencyHelper; import com.bumptech.glide.testutil.TearDownGlide; import org.junit.Before; @@ -111,7 +111,7 @@ public void run() { @Test public void onStop_withSingleRequestInProgress_nullsOutDrawableInView() { - final WaitModel model = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel model = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.runOnMainThread( new Runnable() { @Override @@ -132,7 +132,7 @@ public void run() { @Test public void onStop_withRequestWithThumbnailBothInProgress_nullsOutDrawableInView() { - final WaitModel model = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel model = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.runOnMainThread( new Runnable() { @Override @@ -158,7 +158,7 @@ public void run() { /** Tests #2555. */ @Test public void clear_withRequestWithOnlyFullInProgress_nullsOutDrawableInView() { - final WaitModel mainModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel mainModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(mainModel) @@ -198,7 +198,7 @@ public void run() { @Test public void clear_withRequestWithOnlyFullInProgress_doesNotNullOutDrawableInView() { - final WaitModel mainModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel mainModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(mainModel) @@ -238,7 +238,7 @@ public void run() { @Test public void onStop_withRequestWithOnlyThumbnailInProgress_doesNotNullOutDrawableInView() { - final WaitModel thumbModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel thumbModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(ResourceIds.raw.canonical) diff --git a/integration/compose/api/compose.api b/integration/compose/api/compose.api index 6977f8d3b9..b687bef692 100644 --- a/integration/compose/api/compose.api +++ b/integration/compose/api/compose.api @@ -2,7 +2,14 @@ public abstract interface annotation class com/bumptech/glide/integration/compos } public final class com/bumptech/glide/integration/compose/GlideImageKt { - public static final fun GlideImage (Ljava/lang/Object;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;FLandroidx/compose/ui/graphics/ColorFilter;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun GlideImage (Ljava/lang/Object;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;FLandroidx/compose/ui/graphics/ColorFilter;Lcom/bumptech/glide/integration/compose/Placeholder;Lcom/bumptech/glide/integration/compose/Placeholder;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun placeholder (I)Lcom/bumptech/glide/integration/compose/Placeholder; + public static final fun placeholder (Landroid/graphics/drawable/Drawable;)Lcom/bumptech/glide/integration/compose/Placeholder; + public static final fun placeholder (Lkotlin/jvm/functions/Function2;)Lcom/bumptech/glide/integration/compose/Placeholder; +} + +public abstract class com/bumptech/glide/integration/compose/Placeholder { + public static final field $stable I } public final class com/bumptech/glide/integration/compose/PreloadKt { diff --git a/integration/compose/build.gradle b/integration/compose/build.gradle index 633bb13f22..d3d5587941 100644 --- a/integration/compose/build.gradle +++ b/integration/compose/build.gradle @@ -61,6 +61,7 @@ dependencies { androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$ANDROID_X_TEST_ESPRESSO_VERSION" androidTestImplementation "androidx.test.ext:junit:$ANDROID_X_TEST_JUNIT_VERSION" androidTestImplementation "androidx.compose.material:material:$ANDROID_X_COMPOSE_VERSION" + androidTestImplementation project(':testutil') } apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt index 3571cfeaee..f50d2a1edc 100644 --- a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt @@ -3,19 +3,15 @@ package com.bumptech.glide.integration.compose import android.content.Context -import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -24,29 +20,28 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import androidx.test.core.app.ApplicationProvider import com.bumptech.glide.Glide +import com.bumptech.glide.integration.compose.test.bitmapSize +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawableSize import com.bumptech.glide.integration.ktx.InternalGlideApi import com.bumptech.glide.integration.ktx.Size import com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit +import com.bumptech.glide.testutil.TearDownGlide import java.util.concurrent.atomic.AtomicReference -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test class GlideComposeTest { private val context: Context = ApplicationProvider.getApplicationContext() - @get:Rule val composeRule = createComposeRule() + @get:Rule(order = 1) val composeRule = createComposeRule() + @get:Rule(order = 2) val tearDownGlide = TearDownGlide() @Before fun setUp() { GlideIdlingResourceInit.initGlide(composeRule) } - @After - fun tearDown() { - Glide.tearDown() - } - @Test fun glideImage_noModifierSize_resourceDrawable_displaysDrawable() { val description = "test" @@ -109,10 +104,9 @@ class GlideComposeTest { composeRule.onNodeWithText("Swap").performClick() composeRule.waitForIdle() - val fullsizeBitmap = (secondDrawable as BitmapDrawable).bitmap composeRule .onNodeWithContentDescription(description) - .assert(expectDisplayedDrawable(fullsizeBitmap) { (it as BitmapDrawable).bitmap }) + .assert(expectDisplayedDrawable(secondDrawable)) } @Test @@ -148,8 +142,6 @@ class GlideComposeTest { val thumbnailDrawable = context.getDrawable(android.R.drawable.star_big_off) val fullsizeDrawable = context.getDrawable(android.R.drawable.star_big_on) - val fullsizeBitmap = (fullsizeDrawable as BitmapDrawable).bitmap - composeRule.setContent { GlideImage( model = fullsizeDrawable, @@ -162,34 +154,6 @@ class GlideComposeTest { composeRule .onNodeWithContentDescription(description) - .assert(expectDisplayedDrawable(fullsizeBitmap) { (it as BitmapDrawable).bitmap }) + .assert(expectDisplayedDrawable(fullsizeDrawable)) } - - private fun Int.bitmapSize() = context.resources.getDrawable(this, context.theme).size() } - -private fun Drawable.size() = (this as BitmapDrawable).bitmap.let { Size(it.width, it.height) } - -private fun expectDisplayedDrawableSize(widthPixels: Int, heightPixels: Int): SemanticsMatcher = - expectDisplayedDrawable(Size(widthPixels, heightPixels)) { it?.size() } - -private fun expectDisplayedDrawableSize(expectedSize: Size): SemanticsMatcher = - expectDisplayedDrawable(expectedSize) { it?.size() } - -private fun expectDisplayedDrawable( - expectedValue: ValueT, - transform: (Drawable?) -> ValueT -): SemanticsMatcher = expectStateValue(DisplayedDrawableKey, expectedValue) { transform(it) } - -private fun expectStateValue( - key: SemanticsPropertyKey>, - expectedValue: TransformedValueT, - transform: (ValueT?) -> TransformedValueT? -): SemanticsMatcher = - SemanticsMatcher("${key.name} = '$expectedValue'") { - val value = transform(it.config.getOrElseNullable(key) { null }?.value) - if (value != expectedValue) { - throw AssertionError("Expected: $expectedValue, but was: $value") - } - true - } diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt new file mode 100644 index 0000000000..509eba8fe4 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt @@ -0,0 +1,208 @@ +@file:OptIn(ExperimentalGlideComposeApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedResource +import com.bumptech.glide.integration.compose.test.expectNoDrawable +import com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit +import com.bumptech.glide.testutil.TearDownGlide +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Avoids [com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit] because we want to make + * assertions about loads that have not yet completed. + */ +class GlideImageErrorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule(order = 1) val composeRule = createComposeRule() + @get:Rule(order = 2) val tearDownGlide = TearDownGlide() + + @Before + public fun before() { + GlideIdlingResourceInit.initGlide(composeRule) + } + + @Test + fun requestBuilderTransform_withErrorResourceId_displaysError() { + val description = "test" + val errorResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage(model = null, contentDescription = description) { + it.error(errorResourceId) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(errorResourceId)) + } + + @Test + fun requestBuilderTransform_withErrorDrawable_displaysError() { + val description = "test" + val errorDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage(model = null, contentDescription = description) { + it.error(errorDrawable) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(errorDrawable)) + } + + @Test + fun failureParameter_withErrorResourceId_displaysError() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failureParameter_withDrawable_displaysDrawable() { + val description = "test" + val failureDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureDrawable), + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(failureDrawable)) + } + + @Test + fun failureParameter_withNullDrawable_displaysNothing() { + val description = "test" + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(null as Drawable?) + ) + } + + composeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun failureParameter_withComposable_displaysComposable() { + val failureResourceId = android.R.drawable.star_big_off + val description = "test" + composeRule.setContent { + GlideImage( + model = null, + contentDescription = "none", + failure = placeholder { + // Nesting GlideImage is not really a good idea, but it's convenient for this test because + // we can use our helpers to assert on its contents. + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + } + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failure_setViaFailureParameterWithResourceId_andRequestBuilderTransform_prefersFailureParameter() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) { + it.error(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failure_setViaFailureParameterWithDrawable_andRequestBuilderTransform_prefersFailureParameter() { + val description = "test" + val failureDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureDrawable), + ) { + it.error(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(failureDrawable)) + } + + @Test + fun failure_setViaFailureParameterWithNullDrawable_andRequestBuilderTransformWithNonNullDrawable_showsNoPlaceholder() { + val description = "test" + composeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(null as Drawable?), + ) { + it.error(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectNoDrawable()) + } + + @Test + fun failure_setViaFailureParameterWithComposable_andRequestBuilderTransform_showsComposable() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = null, + contentDescription = "other", + failure = placeholder { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + }, + ) { + it.error(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } +} \ No newline at end of file diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt new file mode 100644 index 0000000000..4a7782d0b5 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt @@ -0,0 +1,214 @@ +@file:OptIn(ExperimentalGlideComposeApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedResource +import com.bumptech.glide.integration.compose.test.expectNoDrawable +import com.bumptech.glide.testutil.TearDownGlide +import com.bumptech.glide.testutil.WaitModelLoaderRule +import org.junit.Rule +import org.junit.Test + +/** + * Avoids [com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit] because we want to make + * assertions about loads that have not yet completed. + */ +class GlideImagePlaceholderTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule(order = 1) val composeRule = createComposeRule() + @get:Rule(order = 2) val waitModelLoaderRule = WaitModelLoaderRule() + @get:Rule(order = 3) val tearDownGlide = TearDownGlide() + + @Test + fun requestBuilderTransform_withPlaceholderResourceId_displaysPlaceholder() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage(model = waitModel, contentDescription = description) { + it.placeholder(placeholderResourceId) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun requestBuilderTransform_withPlaceholderDrawable_displaysPlaceholder() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage(model = waitModel, contentDescription = description) { + it.placeholder(placeholderDrawable) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loadingParameter_withResourceId_displaysResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loadingParameter_withDrawable_displaysResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderDrawable), + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loadingParameter_withNullDrawable_displaysNothing() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(null as Drawable?) + ) + } + + composeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun loadingParameter_withComposable_displaysComposable() { + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + val description = "test" + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = "none", + loading = placeholder { + // Nesting GlideImage is not really a good idea, but it's convenient for this test because + // we can use our helpers to assert on its contents. + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + } + ) + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loading_setViaLoadingParameterWithResourceId_andRequestBuilderTransform_prefersLoadingParameter() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loading_setViaLoadingParameterWithDrawable_andRequestBuilderTransform_prefersLoadingParameter() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderDrawable), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loading_setViaLoadingParameterWithNullDrawable_andRequestBuilderTransform_showsNoResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(null as Drawable?), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectNoDrawable()) + } + + + @Test + fun loading_setViaLoadingParameterWithComposable_andRequestBuilderTransform_showsComposable() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = "other", + loading = placeholder { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + }, + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } +} \ No newline at end of file diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt new file mode 100644 index 0000000000..d25962ede2 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt @@ -0,0 +1,54 @@ +@file:OptIn(InternalGlideApi::class) + +package com.bumptech.glide.integration.compose.test + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.test.SemanticsMatcher +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.DisplayedDrawableKey +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size + +private val context = ApplicationProvider.getApplicationContext() + +fun Int.bitmapSize() = context.resources.getDrawable(this, context.theme).size() + +fun Drawable.size() = (this as BitmapDrawable).bitmap.let { Size(it.width, it.height) } + +fun expectDisplayedResource(resourceId: Int) = + expectDisplayedDrawable(context.getDrawable(resourceId)) + +fun Drawable?.bitmapOrThrow(): Bitmap? = if (this == null) null else (this as BitmapDrawable).bitmap + +fun expectDisplayedDrawableSize(expectedSize: Size): SemanticsMatcher = + expectDisplayedDrawable(expectedSize) { it?.size() } + +fun expectDisplayedDrawable( + expectedValue: Drawable? +): SemanticsMatcher = + expectDisplayedDrawable(expectedValue.bitmapOrThrow()) { it.bitmapOrThrow() } + +fun expectNoDrawable(): SemanticsMatcher = expectDisplayedDrawable(null) + +private fun expectDisplayedDrawable( + expectedValue: ValueT, + transform: (Drawable?) -> ValueT +): SemanticsMatcher = expectStateValue(DisplayedDrawableKey, expectedValue) { transform(it) } + +private fun expectStateValue( + key: SemanticsPropertyKey>, + expectedValue: TransformedValueT, + transform: (ValueT?) -> TransformedValueT? +): SemanticsMatcher = + SemanticsMatcher("${key.name} = '$expectedValue'") { + val value = transform(it.config.getOrElseNullable(key) { null }?.value) + if (value != expectedValue) { + throw AssertionError("Expected: $expectedValue, but was: $value") + } + true + } diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt index 026d81a8da..5aa47792fb 100644 --- a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt @@ -2,6 +2,7 @@ package com.bumptech.glide.integration.compose import android.graphics.drawable.Drawable import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -20,10 +21,12 @@ import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestManager import com.bumptech.glide.integration.ktx.AsyncGlideSize +import com.bumptech.glide.integration.ktx.ExperimentGlideFlows import com.bumptech.glide.integration.ktx.ImmediateGlideSize import com.bumptech.glide.integration.ktx.InternalGlideApi import com.bumptech.glide.integration.ktx.ResolvableGlideSize import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.integration.ktx.Status /** Mutates and returns the given [RequestBuilder] to apply relevant options. */ public typealias RequestBuilderTransform = (RequestBuilder) -> RequestBuilder @@ -58,6 +61,24 @@ public typealias RequestBuilderTransform = (RequestBuilder) -> RequestBuil * Note - this method is likely to change while we work on improving the API. Transitions are one * significant unexplored area. It's also possible we'll try and remove the [RequestBuilder] from * the direct API and instead allow all options to be set directly in the method. + * + * [requestBuilderTransform] is overridden by any overlapping parameter defined in this method if + * that parameter is non-null. For example, [loading] and [failure], if non-null will be used in + * place of any placeholder set by [requestBuilderTransform] using [RequestBuilder.placeholder] or + * [RequestBuilder.error]. + * + * @param loading A [Placeholder] that will be displayed while the request is loading. Specifically + * it's used if the request is cleared ([com.bumptech.glide.request.target.Target.onLoadCleared]) or + * loading ([com.bumptech.glide.request.target.Target.onLoadStarted]. There's a subtle difference + * in behavior depending on which type of [Placeholder] you use. The resource and `Drawable` + * variants will be displayed if the request fails and no other failure handling is specified, but + * the `Composable` will not. + * @param failure A [Placeholder] that will be displayed if the request fails. Specifically it's + * used when [com.bumptech.glide.request.target.Target.onLoadFailed] is called. If + * [RequestBuilder.error] is called in [requestBuilderTransform] with a valid [RequestBuilder] (as + * opposed to resource id or [Drawable]), this [Placeholder] will not be used unless the `error` + * [RequestBuilder] also fails. This parameter does not override error [RequestBuilder]s, only + * error resource ids and/or [Drawable]s. */ // TODO(judds): the API here is not particularly composeesque, we should consider alternatives // to RequestBuilder (though thumbnail() may make that a challenge). @@ -73,12 +94,21 @@ public fun GlideImage( contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, + loading: Placeholder? = null, + failure: Placeholder? = null, // TODO(judds): Consider defaulting to load the model here instead of always doing so below. requestBuilderTransform: RequestBuilderTransform = { it }, ) { val requestManager: RequestManager = LocalContext.current.let { remember(it) { Glide.with(it) } } val requestBuilder = - rememberRequestBuilderWithDefaults(model, requestManager, requestBuilderTransform, contentScale) + rememberRequestBuilderWithDefaults( + model, requestManager, requestBuilderTransform, contentScale + ).let { + loading?.apply(it::placeholder, it::placeholder) ?: it + }.let { + failure?.apply(it::error, it::error) ?: it + } + val overrideSize: Size? = requestBuilder.overrideSize() val (size, finalModifier) = rememberSizeAndModifier(overrideSize, modifier) @@ -91,9 +121,60 @@ public fun GlideImage( contentScale = contentScale, alpha = alpha, colorFilter = colorFilter, + placeholder = loading?.maybeComposable(), + failure = failure?.maybeComposable(), ) } +/** + * Ideally [drawable] is non-null, but because [android.content.Context.getDrawable] can return + * null, we allow it here. `placeholder(null)` has the same override behavior as if a non-null + * `Drawable` were provided. + */ +@ExperimentalGlideComposeApi +public fun placeholder(drawable: Drawable?): Placeholder = Placeholder.OfDrawable(drawable) +@ExperimentalGlideComposeApi +public fun placeholder(resourceId: Int): Placeholder = Placeholder.OfResourceId(resourceId) +@ExperimentalGlideComposeApi +public fun placeholder(composable: @Composable () -> Unit): Placeholder = + Placeholder.OfComposable(composable) + +/** + * Content to display during a particular state of a Glide Request, for example while the request is + * loading or if the request fails. + * + * `of(Drawable)` and `of(resourceId)` trigger fewer recompositions than + * `of(@Composable () -> Unit)` so you should only use the Composable variant if you require + * something more complex than a simple color or a static image. + * + * `of(@Composable () -> Unit)` will display the [Composable] inside a [Box] whose modifier + * is the one provided to [GlideImage]. Doing so allows Glide to infer the requested size if one + * is not explicitly specified on the request itself. + */ +@ExperimentalGlideComposeApi +public sealed class Placeholder { + internal class OfDrawable(internal val drawable: Drawable?) : Placeholder() + internal class OfResourceId(internal val resourceId: Int) : Placeholder() + internal class OfComposable(internal val composable: @Composable () -> Unit) : Placeholder() + + internal fun maybeComposable(): (@Composable () -> Unit)? = + when(this) { + is OfComposable -> this.composable + else -> null + } + + internal fun apply( + resource: (Int) -> RequestBuilder, + drawable: (Drawable?) -> RequestBuilder + ): RequestBuilder = + when(this) { + is OfDrawable -> drawable(this.drawable) + is OfResourceId -> resource(this.resourceId) + // Clear out any previously set placeholder. + else -> drawable(null) + } +} + @OptIn(InternalGlideApi::class) private data class SizeAndModifier(val size: ResolvableGlideSize, val modifier: Modifier) @@ -148,7 +229,7 @@ private fun RequestBuilder.contentScaleTransform( // TODO(judds): Think about how to handle the various fills } -@OptIn(InternalGlideApi::class) +@OptIn(InternalGlideApi::class, ExperimentGlideFlows::class) @Composable private fun SizedGlideImage( requestBuilder: RequestBuilder, @@ -159,23 +240,45 @@ private fun SizedGlideImage( contentScale: ContentScale, alpha: Float, colorFilter: ColorFilter?, + placeholder: @Composable (() -> Unit)?, + failure: @Composable (() -> Unit)?, ) { + // Use a Box so we can infer the size if the request doesn't have an explicit size. + @Composable fun @Composable () -> Unit.boxed() = + Box(modifier = modifier) { + this@boxed() + } + val painter = rememberGlidePainter( requestBuilder = requestBuilder, size = size, ) - Image( - painter = painter, - contentDescription = contentDescription, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - modifier = modifier.then(Modifier.semantics { displayedDrawable = painter.currentDrawable }), - ) + if (placeholder != null && painter.status.showPlaceholder()) { + placeholder.boxed() + } else if (failure != null && painter.status == Status.FAILED) { + failure.boxed() + } else { + Image( + painter = painter, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + modifier = modifier.then(Modifier.semantics { displayedDrawable = painter.currentDrawable }) + ) + } } +@OptIn(ExperimentGlideFlows::class) +private fun Status.showPlaceholder(): Boolean = + when (this) { + Status.RUNNING -> true + Status.CLEARED -> true + else -> false + } + @OptIn(InternalGlideApi::class) @Composable private fun rememberGlidePainter( diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt index 1a75e8e4d6..d3796486a0 100644 --- a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt @@ -3,6 +3,7 @@ package com.bumptech.glide.integration.compose import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.Stable @@ -24,6 +25,7 @@ import com.bumptech.glide.integration.ktx.InternalGlideApi import com.bumptech.glide.integration.ktx.Placeholder import com.bumptech.glide.integration.ktx.ResolvableGlideSize import com.bumptech.glide.integration.ktx.Resource +import com.bumptech.glide.integration.ktx.Status import com.bumptech.glide.integration.ktx.flowResolvable import com.google.accompanist.drawablepainter.DrawablePainter import kotlinx.coroutines.CoroutineScope @@ -44,6 +46,8 @@ constructor( private val size: ResolvableGlideSize, scope: CoroutineScope, ) : Painter(), RememberObserver { + @OptIn(ExperimentGlideFlows::class) + internal var status: Status by mutableStateOf(Status.CLEARED) internal val currentDrawable: MutableState = mutableStateOf(null) private var alpha: Float by mutableStateOf(DefaultAlpha) private var colorFilter: ColorFilter? by mutableStateOf(null) @@ -81,6 +85,7 @@ constructor( is Placeholder -> it.placeholder } ) + status = it.status } } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java similarity index 83% rename from instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java rename to testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java index 42531bcec0..f4fad6f34c 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java +++ b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java @@ -1,4 +1,4 @@ -package com.bumptech.glide.test; +package com.bumptech.glide.testutil; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -11,8 +11,7 @@ import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoaderFactory; import com.bumptech.glide.load.model.MultiModelLoaderFactory; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; -import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import java.io.InputStream; import java.util.concurrent.CountDownLatch; @@ -22,6 +21,37 @@ */ public final class WaitModelLoader implements ModelLoader, Data> { + public static final class WaitModel { + private final CountDownLatch latch = new CountDownLatch(1); + private final T wrapped; + + WaitModel(T wrapped) { + this.wrapped = wrapped; + } + + public void countDown() { + if (latch.getCount() != 1) { + throw new IllegalStateException(); + } + latch.countDown(); + } + } + + /** + * Use {@link WaitModelLoaderRule#waitOn(Object)} instead + */ + @Deprecated + public static synchronized WaitModel waitOn(T model) { + @SuppressWarnings("unchecked") + ModelLoaderFactory, InputStream> streamFactory = + new Factory<>((Class) model.getClass(), InputStream.class); + Glide.get(ApplicationProvider.getApplicationContext()) + .getRegistry() + .replace(WaitModel.class, InputStream.class, streamFactory); + + return new WaitModel<>(model); + } + private final ModelLoader wrapped; private WaitModelLoader(ModelLoader wrapped) { @@ -46,23 +76,7 @@ public boolean handles(@NonNull WaitModel waitModel) { return wrapped.handles(waitModel.wrapped); } - public static final class WaitModel { - private final CountDownLatch latch = new CountDownLatch(1); - private final T wrapped; - - WaitModel(T wrapped) { - this.wrapped = wrapped; - } - - public void countDown() { - if (latch.getCount() != 1) { - throw new IllegalStateException(); - } - latch.countDown(); - } - } - - public static final class Factory + private static final class Factory implements ModelLoaderFactory, Data> { private final Class modelClass; @@ -73,17 +87,6 @@ public static final class Factory this.dataClass = dataClass; } - public static synchronized WaitModel waitOn(T model) { - @SuppressWarnings("unchecked") - ModelLoaderFactory, InputStream> streamFactory = - new Factory<>((Class) model.getClass(), InputStream.class); - Glide.get(ApplicationProvider.getApplicationContext()) - .getRegistry() - .replace(WaitModel.class, InputStream.class, streamFactory); - - return new WaitModel<>(model); - } - @NonNull @Override public ModelLoader, Data> build(MultiModelLoaderFactory multiFactory) { diff --git a/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java new file mode 100644 index 0000000000..8fd7922652 --- /dev/null +++ b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java @@ -0,0 +1,27 @@ +package com.bumptech.glide.testutil; + +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; +import java.util.ArrayList; +import java.util.List; +import org.junit.rules.ExternalResource; + +/** + * Makes sure that all {@link WaitModel}s created by it are unblocked before the test ends. + */ +public final class WaitModelLoaderRule extends ExternalResource { + private final List> waitModels = new ArrayList<>(); + + public WaitModel waitOn(T model) { + WaitModel waitModel = WaitModelLoader.waitOn(model); + waitModels.add(waitModel); + return waitModel; + } + + @Override + protected void after() { + super.after(); + for (WaitModel waitModel : waitModels) { + waitModel.countDown(); + } + } +}