From 8bef56e2588a371363079a9e33053a9f1f663849 Mon Sep 17 00:00:00 2001 From: Sam Judd Date: Tue, 20 Sep 2022 17:41:32 -0700 Subject: [PATCH] Add a Compose API for Glide. This is relatively early stage at this point. The API is likely to change in breaking ways. Ideally the migrations are not large, but I'd expect a few iterations. There's already a TODO to improve the Prealoader API and make the GlideImage API less reliant on GlideBuilder. Early feedback and usage would be welcome, with the caveat that it'll mean some migration work down the road. PiperOrigin-RevId: 475696354 --- build.gradle | 2 +- gradle.properties | 3 + integration/compose/api/compose.api | 11 + integration/compose/build.gradle | 63 ++++++ integration/compose/gradle.properties | 9 + integration/compose/rules.bzl | 51 +++++ .../integration/compose/GlideComposeTest.kt | 148 +++++++++++++ .../executor/GlideIdlingResourceInit.kt | 39 ++++ .../compose/src/main/AndroidManifest.xml | 5 + .../compose/ExperimentalGlideComposeApi.kt | 11 + .../glide/integration/compose/GlideImage.kt | 202 ++++++++++++++++++ .../glide/integration/compose/GlidePainter.kt | 115 ++++++++++ .../glide/integration/compose/Preload.kt | 136 ++++++++++++ .../glide/integration/compose/Sizes.kt | 39 ++++ .../bumptech/glide/integration/ktx/Flows.kt | 25 ++- .../glide/integration/ktx/FlowsTest.kt | 6 +- .../java/com/bumptech/glide/util/Util.java | 2 +- settings.gradle | 1 + 18 files changed, 857 insertions(+), 11 deletions(-) create mode 100644 integration/compose/api/compose.api create mode 100644 integration/compose/build.gradle create mode 100644 integration/compose/gradle.properties create mode 100644 integration/compose/rules.bzl create mode 100644 integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt create mode 100644 integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt create mode 100644 integration/compose/src/main/AndroidManifest.xml create mode 100644 integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt create mode 100644 integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt create mode 100644 integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt create mode 100644 integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt create mode 100644 integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt diff --git a/build.gradle b/build.gradle index eb16bafeb4..ca02d253e9 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,6 @@ subprojects { project -> url "https://oss.sonatype.org/content/repositories/snapshots" } gradlePluginPortal() - } afterEvaluate { @@ -154,6 +153,7 @@ subprojects { project -> abortOnError false } + // We don't need a BuildConfig constants class. buildFeatures { buildConfig = false } diff --git a/gradle.properties b/gradle.properties index bfb273ed36..4c8c2e7b8e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,12 +48,14 @@ ANDROID_X_ANNOTATION_VERSION=1.3.0 ANDROID_X_APPCOMPAT_VERSION=1.3.1 ANDROID_X_BENCHMARK_VERSION=1.1.0 ANDROID_X_CARDVIEW_VERSION=1.0.0 +ANDROID_X_COMPOSE_VERSION=1.2.1 ANDROID_X_CONCURRENT_FUTURES_VERSION=1.1.0 ANDROID_X_CORE_VERSION=1.6.0 ANDROID_X_EXIF_INTERFACE_VERSION=1.3.3 ANDROID_X_FRAGMENT_VERSION=1.3.6 ANDROID_X_RECYCLERVIEW_VERSION=1.2.1 ANDROID_X_TEST_CORE_VERSION=1.4.0 +ANDROID_X_TEST_ESPRESSO_VERSION=3.4.0 ANDROID_X_TEST_JUNIT_VERSION=1.1.3 ANDROID_X_TEST_RULES_VERSION=1.4.0 ANDROID_X_TEST_RUNNER_VERSION=1.4.0 @@ -71,6 +73,7 @@ JETBRAINS_KOTLIN_VERSION=1.7.0 JETBRAINS_KOTLIN_TEST_VERSION=1.7.0 ## Other dependency versions +ACCOMPANIEST_VERSION=0.25.1 ANDROID_GRADLE_VERSION=7.2.1 AUTO_SERVICE_VERSION=1.0-rc3 KOTLIN_COMPILE_TESTING_VERSION=1.4.9 diff --git a/integration/compose/api/compose.api b/integration/compose/api/compose.api new file mode 100644 index 0000000000..6977f8d3b9 --- /dev/null +++ b/integration/compose/api/compose.api @@ -0,0 +1,11 @@ +public abstract interface annotation class com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi : java/lang/annotation/Annotation { +} + +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 final class com/bumptech/glide/integration/compose/PreloadKt { + public static final fun GlideLazyListPreloader-u6VnWhU (Landroidx/compose/foundation/lazy/LazyListState;Ljava/util/List;JILjava/lang/Integer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/integration/compose/build.gradle b/integration/compose/build.gradle new file mode 100644 index 0000000000..8cceed2eb8 --- /dev/null +++ b/integration/compose/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +android { + compileSdk 32 + + defaultConfig { + minSdk 21 + targetSdk 32 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + } + + buildTypes { + release { + minifyEnabled false + } + } + + composeOptions { + kotlinCompilerExtensionVersion '1.2.0' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } +} + +// Enable strict mode, but exclude tests. +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + if (!it.name.contains("Test")) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } +} + +dependencies { + implementation project(':library') + implementation project(':integration:ktx') + implementation(project(':integration:recyclerview')) { + transitive = false + } + implementation "androidx.compose.foundation:foundation:$ANDROID_X_COMPOSE_VERSION" + implementation "androidx.compose.ui:ui:$ANDROID_X_COMPOSE_VERSION" + implementation "com.google.accompanist:accompanist-drawablepainter:$ACCOMPANIEST_VERSION" + implementation "androidx.core:core-ktx:$ANDROID_X_CORE_KTX_VERSION" + debugImplementation "androidx.compose.ui:ui-test-manifest:$ANDROID_X_COMPOSE_VERSION" + androidTestImplementation "junit:junit:$JUNIT_VERSION" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$ANDROID_X_COMPOSE_VERSION" + androidTestImplementation "androidx.test.espresso:espresso-core:$ANDROID_X_TEST_ESPRESSO_VERSION" + androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$ANDROID_X_TEST_ESPRESSO_VERSION" + androidTestImplementation "androidx.test.ext:junit:$ANDROID_X_TEST_JUNIT_VERSION" +} diff --git a/integration/compose/gradle.properties b/integration/compose/gradle.properties new file mode 100644 index 0000000000..4fb26886f6 --- /dev/null +++ b/integration/compose/gradle.properties @@ -0,0 +1,9 @@ +POM_NAME=Glide Compose Integration +POM_ARTIFACT_ID=compose +POM_PACKAGING=aar +POM_DESCRIPTION=An integration library to integrate with Jetpack Compose + +VERSION_MAJOR=1 +VERSION_MINOR=0 +VERSION_PATCH=0 +VERSION_NAME=1.0.0-alpha.0-SNAPSHOT diff --git a/integration/compose/rules.bzl b/integration/compose/rules.bzl new file mode 100644 index 0000000000..d36b18dc99 --- /dev/null +++ b/integration/compose/rules.bzl @@ -0,0 +1,51 @@ +""" +Workaround for the lack of kt_android_library_test_rule (b/243549140). +""" + +load("//tools/build_defs/kotlin:rules.bzl", "kt_android_library") +load("//tools/build_defs/android:rules.bzl", "android_library_test") + +def kt_android_library_test(name, size, srcs, custom_package, manifest, manifest_values, deps, target_devices, test_class): + """A simple equivalent of android_library_test that works with Kotlin. + + This is not well generalized. A better solution is b/243549140, which would + mean adding a real kt_android_library_test to Android's test_macros: + http://google3/tools/build_defs/android/dev/test_macros.bzl;l=17;rcl=470614953 + + While this is only used in one place and we could theoretically move a bunch + of constants out of the test rule into this one, it seems better not to do + so. Leaving the constant values in the calling BUILD file should make a + migration to a real kt_android_library_test rule easier in the future. + + Args: + name: The test name + size: The test size, probably large + srcs: The test library source set + custom_package: The test library and android_library_test package + manifest: The android_library_test manifest + manifest_values: The android_library_test manifest values + deps: the test library and android_library_test dependencies + target_devices: the target devices passed to android_library_test + test_class: the test class for the android_library_test + """ + library_attrs = {} + library_attrs["srcs"] = srcs + library_attrs["deps"] = deps + library_attrs["testonly"] = 1 + library_attrs["custom_package"] = custom_package + + libname = name + "_lib" + + test_attrs = {} + test_attrs["deps"] = [":" + libname] + test_attrs["size"] = size + test_attrs["manifest"] = manifest + test_attrs["multidex"] = "legacy" + + test_attrs["target_devices"] = target_devices + test_attrs["manifest"] = manifest + test_attrs["manifest_values"] = manifest_values + test_attrs["test_class"] = test_class + + kt_android_library(libname, **library_attrs) + android_library_test(name, **test_attrs) 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 new file mode 100644 index 0000000000..e75dd7da73 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideComposeTest.kt @@ -0,0 +1,148 @@ +@file:OptIn(ExperimentalGlideComposeApi::class, InternalGlideApi::class) + +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.size +import androidx.compose.runtime.MutableState +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 +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit +import java.util.concurrent.atomic.AtomicReference +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() + + @Before + fun setUp() { + GlideIdlingResourceInit.initGlide(composeRule) + } + + @Test + fun glideImage_noModifierSize_resourceDrawable_displaysDrawable() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + composeRule.setContent { GlideImage(model = resourceId, contentDescription = description) } + + composeRule.waitForIdle() + + val expectedSize = resourceId.bitmapSize() + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(expectedSize)) + } + + @Test + fun glideImage_withSizeLargerThanImage_noTransformSet_doesNotUpscaleImage() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + composeRule.setContent { + GlideImage( + model = resourceId, + contentDescription = description, + modifier = Modifier.size(300.dp, 300.dp) + ) + } + + composeRule.waitForIdle() + + val expectedSize = resourceId.bitmapSize() + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(expectedSize)) + } + + @Test + fun glideImage_withSizeLargerThanImage_upscaleTransformSet_upscalesImage() { + val viewDimension = 300 + val description = "test" + val sizeRef = AtomicReference() + composeRule.setContent { + GlideImage( + model = android.R.drawable.star_big_on, + requestBuilderTransform = { it.fitCenter() }, + contentDescription = description, + modifier = Modifier.size(viewDimension.dp, viewDimension.dp) + ) + + with(LocalDensity.current) { + val pixels = viewDimension.dp.roundToPx() + sizeRef.set(Size(pixels, pixels)) + } + } + + composeRule.waitForIdle() + + val pixels = sizeRef.get() + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(pixels)) + } + + @Test + fun glideImage_withThumbnail_prefersFullSizeImage() { + val description = "test" + 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, + requestBuilderTransform = { it.thumbnail(Glide.with(context).load(thumbnailDrawable)) }, + contentDescription = description, + ) + } + + composeRule.waitForIdle() + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(fullsizeBitmap) { (it as BitmapDrawable).bitmap }) + } + + 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/load/engine/executor/GlideIdlingResourceInit.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt new file mode 100644 index 0000000000..1c7a56437c --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt @@ -0,0 +1,39 @@ +package com.bumptech.glide.load.engine.executor + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.idling.concurrent.IdlingThreadPoolExecutor +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +object GlideIdlingResourceInit { + + fun initGlide(composeRule: ComposeTestRule) { + val executor = + IdlingThreadPoolExecutor( + "glide_test_thread", + /* corePoolSize= */ 1, + /* maximumPoolSize= */ 1, + /* keepAliveTime= */ 1, + TimeUnit.SECONDS, + LinkedBlockingQueue() + ) { Thread(it) } + composeRule.registerIdlingResource( + object : IdlingResource { + override val isIdleNow: Boolean + get() = executor.isIdleNow + } + ) + val glideExecutor = GlideExecutor(executor) + Glide.init( + ApplicationProvider.getApplicationContext(), + GlideBuilder() + .setSourceExecutor(glideExecutor) + .setAnimationExecutor(glideExecutor) + .setDiskCacheExecutor(glideExecutor) + ) + } +} diff --git a/integration/compose/src/main/AndroidManifest.xml b/integration/compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c1d8467ae6 --- /dev/null +++ b/integration/compose/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt new file mode 100644 index 0000000000..0e8964bd05 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt @@ -0,0 +1,11 @@ +package com.bumptech.glide.integration.compose + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "Glide's Compose integration is experimental. APIs may change or be removed without" + + " warning." +) +@Retention(AnnotationRetention.BINARY) +@kotlin.annotation.Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +public annotation class ExperimentalGlideComposeApi 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 new file mode 100644 index 0000000000..026d81a8da --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt @@ -0,0 +1,202 @@ +package com.bumptech.glide.integration.compose + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics +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.ImmediateGlideSize +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.ResolvableGlideSize +import com.bumptech.glide.integration.ktx.Size + +/** Mutates and returns the given [RequestBuilder] to apply relevant options. */ +public typealias RequestBuilderTransform = (RequestBuilder) -> RequestBuilder + +/** + * Start a request by passing [model] to [RequestBuilder.load] using the given [requestManager] and + * then applying the [requestBuilderTransform] function to add options or apply mutations if the + * caller desires. + * + * [alignment], [contentScale], [alpha], [colorFilter] and [contentDescription] have the same + * defaults (if any) and function identically to the parameters in [Image]. + * + * If you want to restrict the size of this [Composable], use the given [modifier]. If you'd like to + * force the size of the pixels you load to be different than the display area, use + * [RequestBuilder.override]. Often you can get better performance by setting an explicit size so + * that we do not have to wait for layout to fetch the image. If the size set via the [modifier] is + * dependent on the content, Glide will probably end up loading the image using + * [com.bumptech.glide.request.target.Target.SIZE_ORIGINAL]. Avoid `SIZE_ORIGINAL`, implicitly or + * explicitly if you can. You may end up loading a substantially larger image than you need, which + * will increase memory usage and may also increase latency. + * + * If you provide your own [requestManager] rather than using this method's default, consider using + * [remember] at a higher level to avoid some amount of overhead of retrieving it each + * re-composition. + * + * This method will inspect [contentScale] and apply a matching transformation if one exists. Any + * automatically applied transformation can be overridden using [requestBuilderTransform]. Either + * apply a specific transformation instead, or use [RequestBuilder.dontTransform]] + * + * Transitions set via [RequestBuilder.transition] are currently ignored. + * + * 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. + */ +// TODO(judds): the API here is not particularly composeesque, we should consider alternatives +// to RequestBuilder (though thumbnail() may make that a challenge). +// TODO(judds): Consider how to deal with transitions. +@ExperimentalGlideComposeApi +@OptIn(InternalGlideApi::class) +@Composable +public fun GlideImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = 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) + val overrideSize: Size? = requestBuilder.overrideSize() + val (size, finalModifier) = rememberSizeAndModifier(overrideSize, modifier) + + SizedGlideImage( + requestBuilder = requestBuilder, + size = size, + modifier = finalModifier, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + ) +} + +@OptIn(InternalGlideApi::class) +private data class SizeAndModifier(val size: ResolvableGlideSize, val modifier: Modifier) + +@OptIn(InternalGlideApi::class) +@Composable +private fun rememberSizeAndModifier( + overrideSize: Size?, + modifier: Modifier, +) = + remember(overrideSize, modifier) { + if (overrideSize != null) { + SizeAndModifier(ImmediateGlideSize(overrideSize), modifier) + } else { + val sizeObserver = SizeObserver() + SizeAndModifier( + AsyncGlideSize(sizeObserver::getSize), + modifier.sizeObservingModifier(sizeObserver) + ) + } + } + +@Composable +private fun rememberRequestBuilderWithDefaults( + model: Any?, + requestManager: RequestManager, + requestBuilderTransform: RequestBuilderTransform, + contentScale: ContentScale +) = + remember(model, requestManager, requestBuilderTransform, contentScale) { + requestBuilderTransform(requestManager.load(model).contentScaleTransform(contentScale)) + } + +private fun RequestBuilder.contentScaleTransform( + contentScale: ContentScale +): RequestBuilder { + return when (contentScale) { + ContentScale.Crop -> { + centerCrop() + } + ContentScale.Inside, + ContentScale.Fit -> { + // Outside compose, glide would use fitCenter() for FIT. But that's probably not a good + // decision given how unimportant Bitmap re-use is relative to minimizing texture sizes now. + // So instead we'll do something different and prefer not to upscale, which means using + // centerInside(). The UI can still scale the view even if the Bitmap is smaller. + centerInside() + } + else -> { + this + } + } + // TODO(judds): Think about how to handle the various fills +} + +@OptIn(InternalGlideApi::class) +@Composable +private fun SizedGlideImage( + requestBuilder: RequestBuilder, + size: ResolvableGlideSize, + modifier: Modifier, + contentDescription: String?, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, +) { + 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 }), + ) +} + +@OptIn(InternalGlideApi::class) +@Composable +private fun rememberGlidePainter( + requestBuilder: RequestBuilder, + size: ResolvableGlideSize, +): GlidePainter { + val scope = rememberCoroutineScope() + // TODO(judds): Calling onRemembered here manually might make a minor improvement in how quickly + // the image load is started, but it also triggers a recomposition. I can't figure out why it + // triggers a recomposition + return remember(requestBuilder, size) { GlidePainter(requestBuilder, size, scope) } +} + +@OptIn(InternalGlideApi::class) +private fun Modifier.sizeObservingModifier(sizeObserver: SizeObserver): Modifier = + this.layout { measurable, constraints -> + sizeObserver.setSize(constraints.inferredGlideSize()) + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + +internal val DisplayedDrawableKey = + SemanticsPropertyKey>("DisplayedDrawable") +internal var SemanticsPropertyReceiver.displayedDrawable by DisplayedDrawableKey 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 new file mode 100644 index 0000000000..1a75e8e4d6 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt @@ -0,0 +1,115 @@ +package com.bumptech.glide.integration.compose + +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.ktx.ExperimentGlideFlows +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.flowResolvable +import com.google.accompanist.drawablepainter.DrawablePainter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +// This class is inspired by a similar implementation in the excellent Coil library +// (https://github.com/coil-kt/coil), specifically: +// https://github.com/coil-kt/coil/blob/main/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt +@Stable +internal class GlidePainter +@OptIn(InternalGlideApi::class) +constructor( + private val requestBuilder: RequestBuilder, + private val size: ResolvableGlideSize, + scope: CoroutineScope, +) : Painter(), RememberObserver { + internal val currentDrawable: MutableState = mutableStateOf(null) + private var alpha: Float by mutableStateOf(DefaultAlpha) + private var colorFilter: ColorFilter? by mutableStateOf(null) + private var delegate: Painter? by mutableStateOf(null) + private val scope = + scope + SupervisorJob(parent = scope.coroutineContext.job) + Dispatchers.Main.immediate + + override val intrinsicSize: Size + get() = delegate?.intrinsicSize ?: Size.Unspecified + + override fun DrawScope.onDraw() { + delegate?.apply { draw(size, alpha, colorFilter) } + } + + override fun onAbandoned() { + (delegate as? RememberObserver)?.onAbandoned() + } + + override fun onForgotten() { + (delegate as? RememberObserver)?.onForgotten() + } + + override fun onRemembered() { + (delegate as? RememberObserver)?.onRemembered() + launchRequest() + } + + @OptIn(ExperimentGlideFlows::class, InternalGlideApi::class) + private fun launchRequest() { + this.scope.launch { + requestBuilder.flowResolvable(size).collect { + updateDelegate( + when (it) { + is Resource -> it.resource + is Placeholder -> it.placeholder + } + ) + } + } + } + + private fun Drawable.toPainter() = + when (this) { + is BitmapDrawable -> BitmapPainter(bitmap.asImageBitmap()) + is ColorDrawable -> ColorPainter(Color(color)) + else -> DrawablePainter(mutate()) + } + + private fun updateDelegate(drawable: Drawable?) { + val newDelegate = drawable?.toPainter() + val oldDelegate = delegate + if (newDelegate !== oldDelegate) { + (oldDelegate as? RememberObserver)?.onForgotten() + (newDelegate as? RememberObserver)?.onRemembered() + currentDrawable.value = drawable + delegate = newDelegate + } + } + + override fun applyAlpha(alpha: Float): Boolean { + this.alpha = alpha + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + this.colorFilter = colorFilter + return true + } +} diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt new file mode 100644 index 0000000000..9fc1c83a52 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt @@ -0,0 +1,136 @@ +package com.bumptech.glide.integration.compose + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import com.bumptech.glide.Glide +import com.bumptech.glide.ListPreloader +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager + +/** + * Preloads ahead of the users current scroll position for [LazyRow] and + * [androidx.compose.foundation.lazy.LazyColumn], similar to [ListPreloader] and + * [com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader]. + * + * The only time this API is useful is when your UI also loads an item with exactly the same + * options, model and size. Be careful to make sure that your requests are identical in the + * preloader and in the UI, or you might end up hurting performance instead of improving it. + * + * @param state The [LazyListState] provided to the `LazyRow` or `LazyColumn` + * @param data The backing list of metadata that we're going to preload images for. + * @param size The override size we'll pass to [RequestBuilder.override] . + * @param numberOfItemsToPreload The number of items to preload ahead of the user's current + * position. This should be tested for each application. If the total memory size of the preloaded + * images exceeds the memory cache size, preloading for a lazy list is not effective. However if you + * preload too few things, the buffer may be small enough that images are not available when they + * could be, so it's always a balancing act. The smaller the preloaded image, the more you can + * preload. + * @param fixedVisibleItemCount The number of visible items. In some cases this can vary widely in + * which case you can leave this value `null`. If the number of visible items is always one or two, + * it might make sense to just set this to the larger of the two to reduce churn in the preloader. + * @param requestBuilderTransform See [ListPreloader.PreloadModelProvider.getPreloadRequestBuilder] + */ +// TODO(judds): Consider wrapping a LazyRow / LazyColumn and providing state instead of a separate +// function. Wrapping might also make it easier to pass through the size and request builder +// modifications so that it's easier to make sure the preload size matches a size on the +// GlideImage +@Composable +@ExperimentalGlideComposeApi +public fun GlideLazyListPreloader( + state: LazyListState, + data: List, + size: Size, + numberOfItemsToPreload: Int, + fixedVisibleItemCount: Int? = null, + requestBuilderTransform: PreloadRequestBuilderTransform, +) { + val preloader = + rememberGlidePreloader( + data = data, + size = size, + numberOfItemsToPreload = numberOfItemsToPreload, + requestBuilderTransform = requestBuilderTransform, + ) + LaunchPreload(preloader = preloader, state = state, fixedVisibleItemCount = fixedVisibleItemCount) +} + +@Composable +private fun LaunchPreload( + preloader: ListPreloader, + state: LazyListState, + fixedVisibleItemCount: Int? +) = + LaunchedEffect(preloader, state, fixedVisibleItemCount) { + snapshotFlow { state.lazyListVisibleInfo(fixedVisibleItemCount) } + .collect { lazyListVisibleInfo -> + preloader.onScroll( + /* absListView= */ null, + lazyListVisibleInfo.firstVisibleItemIndex, + lazyListVisibleInfo.visibleItemCount, + lazyListVisibleInfo.totalItemCount, + ) + } + } + +@Composable +private fun rememberGlidePreloader( + data: List, + size: Size, + numberOfItemsToPreload: Int, + requestBuilderTransform: PreloadRequestBuilderTransform, +): ListPreloader { + val context = LocalContext.current + val requestManager = remember(context) { Glide.with(context) } + + val updatedData = rememberUpdatedState(data) + val updatedSize = rememberUpdatedState(size) + + return remember(requestManager, requestBuilderTransform, numberOfItemsToPreload) { + ListPreloader( + requestManager, + PreloadModelProvider(requestManager, requestBuilderTransform, updatedData), + { _, _, _ -> intArrayOf(updatedSize.value.width.toInt(), updatedSize.value.height.toInt()) }, + numberOfItemsToPreload, + ) + } +} + +private class PreloadModelProvider( + private val requestManager: RequestManager, + private val requestBuilderTransform: PreloadRequestBuilderTransform, + private val data: State>, +) : ListPreloader.PreloadModelProvider { + override fun getPreloadItems(position: Int): List { + return listOf(this.data.value[position]) + } + + override fun getPreloadRequestBuilder(item: DataTypeT): RequestBuilder<*> { + return requestBuilderTransform(item, requestManager.asDrawable().load(item)) + } +} + +private fun LazyListState.lazyListVisibleInfo(fixedVisibleItemCount: Int?) = + LazyListVisibleInfo( + firstVisibleItemIndex = firstVisibleItemIndex, + visibleItemCount = fixedVisibleItemCount ?: layoutInfo.visibleItemsInfo.size, + totalItemCount = layoutInfo.totalItemsCount + ) + +@Immutable +private data class LazyListVisibleInfo( + val firstVisibleItemIndex: Int, + val visibleItemCount: Int, + val totalItemCount: Int, +) + +private typealias PreloadRequestBuilderTransform = + (item: DataTypeT, requestBuilder: RequestBuilder<*>) -> RequestBuilder<*> diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt new file mode 100644 index 0000000000..7e50d31992 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt @@ -0,0 +1,39 @@ +@file:OptIn(InternalGlideApi::class) + +package com.bumptech.glide.integration.compose + +import androidx.compose.ui.unit.Constraints +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.integration.ktx.isValidGlideDimension +import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.CompletableDeferred + +internal class SizeObserver { + private val size = CompletableDeferred() + + fun setSize(size: Size) { + this.size.complete(size) + } + + suspend fun getSize(): Size { + return size.await() + } +} + +internal fun RequestBuilder.overrideSize(): Size? = + if (isOverrideSizeSet()) { + Size(overrideWidth, overrideHeight) + } else { + null + } + +internal fun RequestBuilder.isOverrideSizeSet(): Boolean = + overrideWidth.isValidGlideDimension() && overrideHeight.isValidGlideDimension() + +internal fun Constraints.inferredGlideSize(): Size = + Size( + if (hasBoundedWidth) maxWidth else Target.SIZE_ORIGINAL, + if (hasBoundedHeight) maxHeight else Target.SIZE_ORIGINAL, + ) diff --git a/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt index 8a9944e399..ad937cb796 100644 --- a/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt +++ b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt @@ -65,7 +65,6 @@ public enum class Status { * only performed on the top level request because we cannot reliably verify all possible * subrequests. */ -@OptIn(InternalGlideApi::class) @ExperimentGlideFlows public fun RequestBuilder.flow(): Flow> { require(isValidOverride) { @@ -149,7 +148,13 @@ public fun RequestBuilder.flow( @ExperimentGlideFlows private fun RequestBuilder.flow( size: Size -): Flow> = flow(ImmediateGlideSize(size)) +): Flow> = flowResolvable(ImmediateGlideSize(size)) + +@OptIn(ExperimentGlideFlows::class) +@InternalGlideApi +public fun RequestBuilder.flowResolvable( + size: ResolvableGlideSize +): Flow> = flow(size) /** * A [Status] and value pair, where the value is either a [Placeholder] or a [Resource] depending on @@ -369,11 +374,19 @@ private class FlowTarget( } } -@InternalGlideApi public data class Size(val width: Int, val height: Int) +@InternalGlideApi +public data class Size(val width: Int, val height: Int) { + init { + require(width.isValidGlideDimension()) + require(height.isValidGlideDimension()) + } +} -private sealed class ResolvableGlideSize +@InternalGlideApi public sealed class ResolvableGlideSize -@InternalGlideApi private data class ImmediateGlideSize(val size: Size) : ResolvableGlideSize() +@InternalGlideApi public data class ImmediateGlideSize(val size: Size) : ResolvableGlideSize() @InternalGlideApi -private data class AsyncGlideSize(val asyncSize: suspend () -> Size) : ResolvableGlideSize() +public data class AsyncGlideSize(val asyncSize: suspend () -> Size) : ResolvableGlideSize() + +@InternalGlideApi public fun Int.isValidGlideDimension(): Boolean = Util.isValidDimension(this) diff --git a/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt b/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt index 25b1635edd..bc784cd8e7 100644 --- a/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt +++ b/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt @@ -574,7 +574,7 @@ class FlowsTest { .append( FakeModel::class.java, File::class.java, - SizeStealingFakeModelLoader.Factory(newImageFile(), result) + SizeObservingFakeModelLoader.Factory(newImageFile(), result) ) return result } @@ -601,7 +601,7 @@ class FlowsTest { class FakeModel - class SizeStealingFakeModelLoader( + class SizeObservingFakeModelLoader( private val fileLoader: ModelLoader, private val fakeResult: File, private val sizeReference: AtomicReference, @@ -622,7 +622,7 @@ class FlowsTest { class Factory(private val fakeResult: File, private val sizeReference: AtomicReference) : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - return SizeStealingFakeModelLoader( + return SizeObservingFakeModelLoader( multiFactory.build(File::class.java, File::class.java), fakeResult, sizeReference diff --git a/library/src/main/java/com/bumptech/glide/util/Util.java b/library/src/main/java/com/bumptech/glide/util/Util.java index f5ea31ada1..1655b2e72d 100644 --- a/library/src/main/java/com/bumptech/glide/util/Util.java +++ b/library/src/main/java/com/bumptech/glide/util/Util.java @@ -141,7 +141,7 @@ public static boolean isValidDimensions(int width, int height) { return isValidDimension(width) && isValidDimension(height); } - private static boolean isValidDimension(int dimen) { + public static boolean isValidDimension(int dimen) { return dimen > 0 || dimen == Target.SIZE_ORIGINAL; } diff --git a/settings.gradle b/settings.gradle index 00fa14b380..08a0115371 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':samples:contacturi' include ':samples:imgur' include ':integration' include ':integration:avif' +include ':integration:compose' include ':integration:concurrent' include ':integration:cronet' include ':integration:gifencoder'