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'