Skip to content

Commit 440df83

Browse files
falhassenglide-copybara-robot
authored andcommitted
Creates callback api to notify when image becomes visible to user
PiperOrigin-RevId: 774423696
1 parent 10c7a11 commit 440df83

File tree

2 files changed

+101
-84
lines changed
  • integration/compose/src

2 files changed

+101
-84
lines changed

integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageTest.kt

Lines changed: 66 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import androidx.compose.ui.platform.LocalDensity
1616
import androidx.compose.ui.test.assert
1717
import androidx.compose.ui.test.onNodeWithContentDescription
1818
import androidx.compose.ui.test.onNodeWithText
19+
import androidx.compose.ui.test.onRoot
1920
import androidx.compose.ui.test.performClick
21+
import androidx.compose.ui.test.printToString
2022
import androidx.compose.ui.unit.dp
2123
import androidx.test.core.app.ApplicationProvider
2224
import com.bumptech.glide.Glide
@@ -37,6 +39,9 @@ import java.util.concurrent.atomic.AtomicInteger
3739
import java.util.concurrent.atomic.AtomicReference
3840
import org.junit.Rule
3941
import org.junit.Test
42+
import org.mockito.kotlin.any
43+
import org.mockito.kotlin.mock
44+
import org.mockito.kotlin.verify
4045

4146
class GlideImageTest {
4247
private val context: Context = ApplicationProvider.getApplicationContext()
@@ -64,7 +69,7 @@ class GlideImageTest {
6469
GlideImage(
6570
model = resourceId,
6671
contentDescription = description,
67-
modifier = Modifier.size(300.dp, 300.dp)
72+
modifier = Modifier.size(300.dp, 300.dp),
6873
)
6974
}
7075

@@ -95,7 +100,7 @@ class GlideImageTest {
95100
GlideImage(
96101
model = model.value,
97102
modifier = Modifier.size(100.dp),
98-
contentDescription = description
103+
contentDescription = description,
99104
)
100105
}
101106
}
@@ -119,7 +124,7 @@ class GlideImageTest {
119124
model = android.R.drawable.star_big_on,
120125
requestBuilderTransform = { it.fitCenter() },
121126
contentDescription = description,
122-
modifier = Modifier.size(viewDimension.dp, viewDimension.dp)
127+
modifier = Modifier.size(viewDimension.dp, viewDimension.dp),
123128
)
124129

125130
with(LocalDensity.current) {
@@ -161,68 +166,47 @@ class GlideImageTest {
161166
val description = "test"
162167
glideComposeRule.setContent {
163168
Box(modifier = Modifier.size(0.dp)) {
164-
GlideImage(
165-
model = android.R.drawable.star_big_on,
166-
contentDescription = description,
167-
)
169+
GlideImage(model = android.R.drawable.star_big_on, contentDescription = description)
168170
}
169171
}
170172
glideComposeRule.waitForIdle()
171-
glideComposeRule
172-
.onNodeWithContentDescription(description)
173-
.assertDisplays(null)
173+
glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null)
174174
}
175175

176-
177176
@Test
178177
fun glideImage_withNegativeSize_doesNotStartLoad() {
179178
val description = "test"
180179
glideComposeRule.setContent {
181180
Box(modifier = Modifier.size((-10).dp)) {
182-
GlideImage(
183-
model = android.R.drawable.star_big_on,
184-
contentDescription = description,
185-
)
181+
GlideImage(model = android.R.drawable.star_big_on, contentDescription = description)
186182
}
187183
}
188184
glideComposeRule.waitForIdle()
189-
glideComposeRule
190-
.onNodeWithContentDescription(description)
191-
.assertDisplays(null)
185+
glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null)
192186
}
193187

194188
@Test
195189
fun glideImage_withZeroWidth_validHeight_doesNotStartLoad() {
196190
val description = "test"
197191
glideComposeRule.setContent {
198192
Box(modifier = Modifier.size(0.dp, 10.dp)) {
199-
GlideImage(
200-
model = android.R.drawable.star_big_on,
201-
contentDescription = description,
202-
)
193+
GlideImage(model = android.R.drawable.star_big_on, contentDescription = description)
203194
}
204195
}
205196
glideComposeRule.waitForIdle()
206-
glideComposeRule
207-
.onNodeWithContentDescription(description)
208-
.assertDisplays(null)
197+
glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null)
209198
}
210199

211200
@Test
212201
fun glideImage_withValidWidth_zeroHeight_doesNotStartLoad() {
213202
val description = "test"
214203
glideComposeRule.setContent {
215204
Box(modifier = Modifier.size(10.dp, 0.dp)) {
216-
GlideImage(
217-
model = android.R.drawable.star_big_on,
218-
contentDescription = description,
219-
)
205+
GlideImage(model = android.R.drawable.star_big_on, contentDescription = description)
220206
}
221207
}
222208
glideComposeRule.waitForIdle()
223-
glideComposeRule
224-
.onNodeWithContentDescription(description)
225-
.assertDisplays(null)
209+
glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null)
226210
}
227211

228212
@Test
@@ -238,10 +222,7 @@ class GlideImageTest {
238222

239223
TextButton(onClick = ::swapSize) { Text(text = "Swap") }
240224
Box(modifier = Modifier.size(currentSize.value)) {
241-
GlideImage(
242-
model = resourceId,
243-
contentDescription = description,
244-
)
225+
GlideImage(model = resourceId, contentDescription = description)
245226
}
246227
}
247228
glideComposeRule.waitForIdle()
@@ -268,10 +249,7 @@ class GlideImageTest {
268249

269250
TextButton(onClick = ::swapSize) { Text(text = "Swap") }
270251
Box(modifier = Modifier.size(currentSize.value)) {
271-
GlideImage(
272-
model = resourceId,
273-
contentDescription = description,
274-
)
252+
GlideImage(model = resourceId, contentDescription = description)
275253
}
276254
}
277255
repeat(validSizeDps.size) {
@@ -291,28 +269,29 @@ class GlideImageTest {
291269
@Test
292270
fun glideImage_withSuccessfulResource_callsOnResourceReadyOnce() {
293271
val onResourceReadyCounter = AtomicInteger()
294-
val requestListener = object : RequestListener<Drawable> {
295-
override fun onLoadFailed(
296-
e: GlideException?,
297-
model: Any?,
298-
target: Target<Drawable>?,
299-
isFirstResource: Boolean,
300-
): Boolean {
301-
throw UnsupportedOperationException()
272+
val requestListener =
273+
object : RequestListener<Drawable> {
274+
override fun onLoadFailed(
275+
e: GlideException?,
276+
model: Any?,
277+
target: Target<Drawable>?,
278+
isFirstResource: Boolean,
279+
): Boolean {
280+
throw UnsupportedOperationException()
281+
}
282+
283+
override fun onResourceReady(
284+
resource: Drawable?,
285+
model: Any?,
286+
target: Target<Drawable>?,
287+
dataSource: DataSource?,
288+
isFirstResource: Boolean,
289+
): Boolean {
290+
onResourceReadyCounter.incrementAndGet()
291+
return false
292+
}
302293
}
303294

304-
override fun onResourceReady(
305-
resource: Drawable?,
306-
model: Any?,
307-
target: Target<Drawable>?,
308-
dataSource: DataSource?,
309-
isFirstResource: Boolean,
310-
): Boolean {
311-
onResourceReadyCounter.incrementAndGet()
312-
return false
313-
}
314-
}
315-
316295
glideComposeRule.setContent {
317296
GlideImage(model = android.R.drawable.star_big_on, contentDescription = "") {
318297
it.listener(requestListener)
@@ -323,4 +302,31 @@ class GlideImageTest {
323302

324303
assertThat(onResourceReadyCounter.get()).isEqualTo(1)
325304
}
305+
306+
@Test
307+
fun glideImage_loadImageFromFile_callsOnImageVisibleToUser() {
308+
val onImageVisibleToUser = mock<(Drawable) -> Unit>()
309+
310+
glideComposeRule.setContent {
311+
GlideImage(
312+
model = android.R.drawable.star_big_on,
313+
contentDescription = "star",
314+
onImageVisibleToUser = onImageVisibleToUser,
315+
)
316+
}
317+
glideComposeRule.waitUntilContentDescriptionWithinRootNode("star")
318+
319+
verify(onImageVisibleToUser).invoke(any())
320+
}
321+
322+
private fun GlideComposeRule.waitUntilContentDescriptionWithinRootNode(
323+
contentDescription: String
324+
) {
325+
waitUntil(
326+
conditionDescription = "waitUntilContentDescriptionWithinRootNode",
327+
timeoutMillis = 10000,
328+
) {
329+
onRoot(useUnmergedTree = false).printToString().contains(contentDescription)
330+
}
331+
}
326332
}

integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.annotation.DrawableRes
55
import androidx.compose.foundation.Image
66
import androidx.compose.foundation.layout.Box
77
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.LaunchedEffect
89
import androidx.compose.runtime.MutableState
910
import androidx.compose.runtime.remember
1011
import androidx.compose.runtime.rememberCoroutineScope
@@ -71,17 +72,17 @@ public typealias RequestBuilderTransform<T> = (RequestBuilder<T>) -> RequestBuil
7172
* [RequestBuilder.error].
7273
*
7374
* @param loading A [Placeholder] that will be displayed while the request is loading. Specifically
74-
* it's used if the request is cleared ([com.bumptech.glide.request.target.Target.onLoadCleared]) or
75-
* loading ([com.bumptech.glide.request.target.Target.onLoadStarted]. There's a subtle difference in
76-
* behavior depending on which type of [Placeholder] you use. The resource and `Drawable` variants
77-
* will be displayed if the request fails and no other failure handling is specified, but the
78-
* `Composable` will not.
75+
* it's used if the request is cleared ([com.bumptech.glide.request.target.Target.onLoadCleared])
76+
* or loading ([com.bumptech.glide.request.target.Target.onLoadStarted]. There's a subtle
77+
* difference in behavior depending on which type of [Placeholder] you use. The resource and
78+
* `Drawable` variants will be displayed if the request fails and no other failure handling is
79+
* specified, but the `Composable` will not.
7980
* @param failure A [Placeholder] that will be displayed if the request fails. Specifically it's
80-
* used when [com.bumptech.glide.request.target.Target.onLoadFailed] is called. If
81-
* [RequestBuilder.error] is called in [requestBuilderTransform] with a valid [RequestBuilder] (as
82-
* opposed to resource id or [Drawable]), this [Placeholder] will not be used unless the `error`
83-
* [RequestBuilder] also fails. This parameter does not override error [RequestBuilder]s, only error
84-
* resource ids and/or [Drawable]s.
81+
* used when [com.bumptech.glide.request.target.Target.onLoadFailed] is called. If
82+
* [RequestBuilder.error] is called in [requestBuilderTransform] with a valid [RequestBuilder] (as
83+
* opposed to resource id or [Drawable]), this [Placeholder] will not be used unless the `error`
84+
* [RequestBuilder] also fails. This parameter does not override error [RequestBuilder]s, only
85+
* error resource ids and/or [Drawable]s.
8586
*/
8687
// TODO(judds): the API here is not particularly composeesque, we should consider alternatives
8788
// to RequestBuilder (though thumbnail() may make that a challenge).
@@ -101,6 +102,7 @@ public fun GlideImage(
101102
// See http://shortn/_x79pjkMZIH for an internal discussion.
102103
loading: Placeholder? = null,
103104
failure: Placeholder? = null,
105+
onImageVisibleToUser: ((resource: Drawable) -> Unit)? = null,
104106
// TODO(judds): Consider defaulting to load the model here instead of always doing so below.
105107
requestBuilderTransform: RequestBuilderTransform<Drawable> = { it },
106108
) {
@@ -132,6 +134,7 @@ public fun GlideImage(
132134
colorFilter = colorFilter,
133135
placeholder = loading?.maybeComposable(),
134136
failure = failure?.maybeComposable(),
137+
onImageVisibleToUser = onImageVisibleToUser,
135138
)
136139
}
137140

@@ -143,7 +146,7 @@ private fun PreviewResourceOrDrawable(
143146
modifier: Modifier,
144147
) {
145148
val drawable =
146-
when(loading) {
149+
when (loading) {
147150
is Placeholder.OfDrawable -> loading.drawable
148151
is Placeholder.OfResourceId -> LocalContext.current.getDrawable(loading.resourceId)
149152
is Placeholder.OfComposable ->
@@ -205,7 +208,9 @@ public fun placeholder(composable: @Composable () -> Unit): Placeholder =
205208
@ExperimentalGlideComposeApi
206209
public sealed class Placeholder {
207210
internal class OfDrawable(internal val drawable: Drawable?) : Placeholder()
211+
208212
internal class OfResourceId(@DrawableRes internal val resourceId: Int) : Placeholder()
213+
209214
internal class OfComposable(internal val composable: @Composable () -> Unit) : Placeholder()
210215

211216
internal fun isResourceOrDrawable() =
@@ -223,7 +228,7 @@ public sealed class Placeholder {
223228

224229
internal fun <T> apply(
225230
resource: (Int) -> RequestBuilder<T>,
226-
drawable: (Drawable?) -> RequestBuilder<T>
231+
drawable: (Drawable?) -> RequestBuilder<T>,
227232
): RequestBuilder<T> =
228233
when (this) {
229234
is OfDrawable -> drawable(this.drawable)
@@ -238,18 +243,15 @@ private data class SizeAndModifier(val size: ResolvableGlideSize, val modifier:
238243

239244
@OptIn(InternalGlideApi::class)
240245
@Composable
241-
private fun rememberSizeAndModifier(
242-
overrideSize: Size?,
243-
modifier: Modifier,
244-
) =
246+
private fun rememberSizeAndModifier(overrideSize: Size?, modifier: Modifier) =
245247
remember(overrideSize, modifier) {
246248
if (overrideSize != null) {
247249
SizeAndModifier(ImmediateGlideSize(overrideSize), modifier)
248250
} else {
249251
val sizeObserver = SizeObserver()
250252
SizeAndModifier(
251253
AsyncGlideSize(sizeObserver::getSize),
252-
modifier.sizeObservingModifier(sizeObserver)
254+
modifier.sizeObservingModifier(sizeObserver),
253255
)
254256
}
255257
}
@@ -259,7 +261,7 @@ private fun rememberRequestBuilderWithDefaults(
259261
model: Any?,
260262
requestManager: RequestManager,
261263
requestBuilderTransform: RequestBuilderTransform<Drawable>,
262-
contentScale: ContentScale
264+
contentScale: ContentScale,
263265
) =
264266
remember(model, requestManager, requestBuilderTransform, contentScale) {
265267
requestBuilderTransform(requestManager.load(model).contentScaleTransform(contentScale))
@@ -300,15 +302,24 @@ private fun SizedGlideImage(
300302
colorFilter: ColorFilter?,
301303
placeholder: @Composable (() -> Unit)?,
302304
failure: @Composable (() -> Unit)?,
305+
onImageVisibleToUser: ((resource: Drawable) -> Unit)? = null,
303306
) {
304307
// Use a Box so we can infer the size if the request doesn't have an explicit size.
305308
@Composable fun @Composable () -> Unit.boxed() = Box(modifier = modifier) { this@boxed() }
306309

307-
val painter =
308-
rememberGlidePainter(
309-
requestBuilder = requestBuilder,
310-
size = size,
311-
)
310+
val painter = rememberGlidePainter(requestBuilder = requestBuilder, size = size)
311+
312+
if (onImageVisibleToUser != null) {
313+
val currentStatus = painter.status
314+
val currentDrawable = painter.currentDrawable.value
315+
316+
LaunchedEffect(currentStatus, currentDrawable) {
317+
if (currentStatus == Status.SUCCEEDED && currentDrawable != null) {
318+
onImageVisibleToUser(currentDrawable)
319+
}
320+
}
321+
}
322+
312323
if (placeholder != null && painter.status.showPlaceholder()) {
313324
placeholder.boxed()
314325
} else if (failure != null && painter.status == Status.FAILED) {
@@ -321,7 +332,7 @@ private fun SizedGlideImage(
321332
contentScale = contentScale,
322333
alpha = alpha,
323334
colorFilter = colorFilter,
324-
modifier = modifier.then(Modifier.semantics { displayedDrawable = painter.currentDrawable })
335+
modifier = modifier.then(Modifier.semantics { displayedDrawable = painter.currentDrawable }),
325336
)
326337
}
327338
}

0 commit comments

Comments
 (0)