diff --git a/.gitignore b/.gitignore index bbedd8eeb..4d12cf501 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ docs/index.md docs/contributing.md docs/coil.md docs/coil/ +docs/picasso.md +docs/picasso/ # Mkdocs temporary serving folder site *.bak \ No newline at end of file diff --git a/README.md b/README.md index 98893ac2e..96af2984e 100644 --- a/README.md +++ b/README.md @@ -3,22 +3,11 @@ Accompanist is a group of libraries that contains some utilities which I've found myself copying around projects which use [Jetpack Compose][compose]. Currently, it contains: 🖼️ [Coil image loading composables](./coil/README.md) +🖼️ [Picasso image loading composables](./picasso/README.md) [Jetpack Compose][compose] is a fast-moving project and I'll be updating these libraries to match the latest tagged release as quickly as possible. Each [release listing](https://github.com/chrisbanes/accompanist/releases) will outline what version of Compose libraries it depends on. -## Download - -```groovy -repositories { - mavenCentral() -} - -dependencies { - implementation "dev.chrisbanes.accompanist:accompanist-coil:" -} -``` - ### Accompanist Snapshots Snapshots of the current development version of Accompanist are available, which track the latest commit. See [here](docs/using-snapshot-version.md) for more information. diff --git a/build.gradle b/build.gradle index 0c83abbf2..c61827327 100644 --- a/build.gradle +++ b/build.gradle @@ -141,6 +141,10 @@ subprojects { packageListUrl.set(new URL("file://$rootDir/package-list-coil-base")) } } + externalDocumentationLink { + url.set(new URL("https://square.github.io/picasso/2.x/picasso/")) + packageListUrl.set(new URL("https://square.github.io/picasso/2.x/picasso/package-list")) + } } } } diff --git a/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt b/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt index 7ac7dc282..ea3cfdd94 100644 --- a/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt +++ b/buildSrc/src/main/java/dev/chrisbanes/accompanist/buildsrc/dependencies.kt @@ -92,7 +92,12 @@ object Libs { const val gif = "io.coil-kt:coil-gif:$version" } + const val picasso = "com.squareup.picasso:picasso:2.8" + const val truth = "com.google.truth:truth:1.0.1" - const val mockWebServer = "com.squareup.okhttp3:mockwebserver:3.12.2" + object OkHttp { + const val okhttp = "com.squareup.okhttp3:okhttp:3.12.2" + const val mockWebServer = "com.squareup.okhttp3:mockwebserver:3.12.2" + } } diff --git a/coil/README.md b/coil/README.md index 6ceec2e33..2cc89dfb1 100644 --- a/coil/README.md +++ b/coil/README.md @@ -65,9 +65,9 @@ CoilImage( ) ``` -### Custom layout +## Custom content -If you need more control over the animation, you can use the `content` composable version of `CoilImage`, to display the result in a `MaterialLoadingImage`: +If you need more control over the animation, or you want to provide custom layout for the loaded image, you can use the `content` composable version of `CoilImage`: ``` kotlin CoilImage( @@ -86,7 +86,6 @@ CoilImage( ImageLoadState.Empty -> /* TODO */ } } -``` ## GIFs diff --git a/coil/build.gradle b/coil/build.gradle index 334ddda88..18bebc541 100644 --- a/coil/build.gradle +++ b/coil/build.gradle @@ -87,7 +87,7 @@ dependencies { androidTestImplementation Libs.junit androidTestImplementation Libs.truth - androidTestImplementation Libs.mockWebServer + androidTestImplementation Libs.OkHttp.mockWebServer androidTestImplementation Libs.Coroutines.test diff --git a/coil/src/androidTest/AndroidManifest.xml b/coil/src/androidTest/AndroidManifest.xml index 50707d4ee..7dcb1f917 100644 --- a/coil/src/androidTest/AndroidManifest.xml +++ b/coil/src/androidTest/AndroidManifest.xml @@ -18,7 +18,7 @@ xmlns:tools="http://schemas.android.com/tools" package="dev.chrisbanes.accompanist.coil.test"> - + diff --git a/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt b/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt index 6dc21d2f6..9aba233f8 100644 --- a/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt +++ b/coil/src/androidTest/java/dev/chrisbanes/accompanist/coil/CoilTest.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse @@ -244,7 +245,9 @@ class CoilTest { // Await the first load runBlocking { - loadCompleteSignal.receive() + withTimeout(5000) { + loadCompleteSignal.receive() + } } // Assert that the content is completely Red @@ -259,7 +262,11 @@ class CoilTest { drawableResId.value = R.drawable.blue_rectangle // Await the second load - runBlocking { loadCompleteSignal.receive() } + runBlocking { + withTimeout(5000) { + loadCompleteSignal.receive() + } + } // Assert that the content is completely Blue composeTestRule.onNodeWithTag(CoilTestTags.Image) @@ -282,7 +289,7 @@ class CoilTest { composeTestRule.setContent { val size = sizeFlow.collectAsState() CoilImage( - data = resourceUri(R.drawable.blue_rectangle), + data = resourceUri(R.drawable.red_rectangle), modifier = Modifier.preferredSize(size.value).testTag(CoilTestTags.Image), onRequestCompleted = { loadCompleteSignal.offer(Unit) } ) diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt index 05cb9d071..169b0934e 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt +++ b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Coil.kt @@ -36,6 +36,8 @@ import coil.imageLoader import coil.request.ImageRequest import coil.request.ImageResult import dev.chrisbanes.accompanist.imageloading.DataSource +import dev.chrisbanes.accompanist.imageloading.DefaultRefetchOnSizeChangeLambda +import dev.chrisbanes.accompanist.imageloading.EmptyRequestCompleteLambda import dev.chrisbanes.accompanist.imageloading.ImageLoad import dev.chrisbanes.accompanist.imageloading.ImageLoadState import dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage @@ -74,8 +76,8 @@ fun CoilImage( modifier: Modifier = Modifier, requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, content: @Composable (imageLoadState: ImageLoadState) -> Unit ) { CoilImage( @@ -123,8 +125,8 @@ fun CoilImage( modifier: Modifier = Modifier, requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, content: @Composable (imageLoadState: ImageLoadState) -> Unit ) { ImageLoad( @@ -210,8 +212,8 @@ fun CoilImage( fadeIn: Boolean = false, requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null, ) { @@ -283,8 +285,8 @@ fun CoilImage( fadeIn: Boolean = false, requestBuilder: (ImageRequest.Builder.(size: IntSize) -> ImageRequest.Builder)? = null, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null, ) { @@ -372,7 +374,3 @@ internal fun Any.toImageRequest(): ImageRequest { remember(this) { ImageRequest.Builder(context).data(this).build() } } } - -internal val emptySuccessLambda: (ImageLoadState) -> Unit = {} - -internal val defaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } diff --git a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt index a432832ae..7898a1dff 100644 --- a/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt +++ b/coil/src/main/java/dev/chrisbanes/accompanist/coil/Crossfade.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.unit.IntSize import coil.ImageLoader import coil.imageLoader import coil.request.ImageRequest +import dev.chrisbanes.accompanist.imageloading.DefaultRefetchOnSizeChangeLambda +import dev.chrisbanes.accompanist.imageloading.EmptyRequestCompleteLambda import dev.chrisbanes.accompanist.imageloading.ImageLoadState import dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage @@ -64,8 +66,8 @@ fun CoilImageWithCrossfade( contentScale: ContentScale = ContentScale.Fit, crossfadeDuration: Int = DefaultTransitionDuration, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null ) { @@ -115,8 +117,8 @@ fun CoilImageWithCrossfade( contentScale: ContentScale = ContentScale.Fit, crossfadeDuration: Int = DefaultTransitionDuration, imageLoader: ImageLoader = ContextAmbient.current.imageLoader, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, error: @Composable ((ImageLoadState.Error) -> Unit)? = null, loading: @Composable (() -> Unit)? = null ) { diff --git a/generate_docs.sh b/generate_docs.sh index 1e30dc081..33664424f 100755 --- a/generate_docs.sh +++ b/generate_docs.sh @@ -22,9 +22,13 @@ sed -i.bak 's/images\/social.png/header.png/' docs/index.md cp coil/README.md docs/coil.md mkdir -p docs/coil cp coil/images/crossfade.gif docs/coil/crossfade.gif - sed -i.bak 's/images\/crossfade.gif/crossfade.gif/' docs/coil.md +cp picasso/README.md docs/picasso.md +mkdir -p docs/picasso +cp picasso/images/crossfade.gif docs/picasso/crossfade.gif +sed -i.bak 's/images\/crossfade.gif/crossfade.gif/' docs/picasso.md + # Convert docs/xxx.md links to just xxx/ sed -i.bak 's/docs\/\([a-zA-Z-]*\).md/\1/' docs/index.md diff --git a/imageloading-core/api/imageloading-core.api b/imageloading-core/api/imageloading-core.api index eeb09c851..4c93ed3a1 100644 --- a/imageloading-core/api/imageloading-core.api +++ b/imageloading-core/api/imageloading-core.api @@ -16,7 +16,9 @@ public final class dev/chrisbanes/accompanist/imageloading/DataSource : java/lan } public final class dev/chrisbanes/accompanist/imageloading/ImageLoad { - public static final fun ImageLoad (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ImageLoad (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun getDefaultRefetchOnSizeChangeLambda ()Lkotlin/jvm/functions/Function2; + public static final fun getEmptyRequestCompleteLambda ()Lkotlin/jvm/functions/Function1; } public abstract class dev/chrisbanes/accompanist/imageloading/ImageLoadState { diff --git a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt index e1c394362..5c71ef0d8 100644 --- a/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt +++ b/imageloading-core/src/main/java/dev/chrisbanes/accompanist/imageloading/ImageLoad.kt @@ -46,6 +46,9 @@ import kotlinx.coroutines.flow.flow * @param request The request to execute. * @param executeRequest Suspending lambda to execute an image loading request. * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param requestKey The object to key this request on. If the request type supports equality then + * the default value will work. Otherwise pass in the `data` value. + * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. * @param transformRequestForSize Optionally transform [request] for the given [IntSize]. * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing * optional re-fetching of the image. Return true to re-fetch the image. @@ -53,16 +56,17 @@ import kotlinx.coroutines.flow.flow * @param content Content to be displayed for the given state. */ @Composable -fun ImageLoad( +fun ImageLoad( request: T, executeRequest: suspend (T) -> ImageLoadState, modifier: Modifier = Modifier, + requestKey: Any = request, transformRequestForSize: (T, IntSize) -> T? = { r, _ -> r }, - shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = defaultRefetchOnSizeChangeLambda, - onRequestCompleted: (ImageLoadState) -> Unit = emptySuccessLambda, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, content: @Composable (imageLoadState: ImageLoadState) -> Unit ) { - var state by stateFor(request) { ImageLoadState.Empty } + var state by stateFor(requestKey) { ImageLoadState.Empty } // This may look a little weird, but allows the launchInComposition callback to always // invoke the last provided [onRequestCompleted]. @@ -77,7 +81,7 @@ fun ImageLoad( val callback = remember { mutableStateOf(onRequestCompleted, referentialEqualityPolicy()) } callback.value = onRequestCompleted - val requestActor = remember(request) { + val requestActor = remember(requestKey) { ImageLoadRequestActor(executeRequest) } @@ -142,6 +146,12 @@ private fun ImageLoadRequestActor( } } -internal val emptySuccessLambda: (ImageLoadState) -> Unit = {} +/** + * Empty lamdba for use in the `onRequestCompleted` parameter. + */ +val EmptyRequestCompleteLambda: (ImageLoadState) -> Unit = {} -internal val defaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } +/** + * Default lamdba for use in the `shouldRefetchOnSizeChange` parameter. + */ +val DefaultRefetchOnSizeChangeLambda: (ImageLoadState, IntSize) -> Boolean = { _, _ -> false } diff --git a/mkdocs.yml b/mkdocs.yml index 8f41ec38a..5ca293c7e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,12 +13,15 @@ repo_url: 'https://github.com/chrisbanes/accompanist' nav: - 'Overview': index.md - 'Coil': coil.md + - 'Picasso': picasso.md - 'API': - 'Coil': api/coil/index.md + - 'Picasso': api/picasso/index.md + - 'Image Loading Core': api/imageloading-core/index.md - 'Snapshots': using-snapshot-version.md - 'Contributing': contributing.md - 'Maintainers': - - 'Update guide': updating.md + - 'Update guide': updating.md # Configuration theme: diff --git a/picasso/README.md b/picasso/README.md new file mode 100644 index 000000000..4275f7a85 --- /dev/null +++ b/picasso/README.md @@ -0,0 +1,106 @@ +# Jetpack Compose + Picasso + +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/dev.chrisbanes.accompanist/accompanist-picasso/badge.svg)](https://search.maven.org/search?q=g:dev.chrisbanes.accompanist) + +This library brings easy-to-use composable which can fetch and display images from external sources, such as network, using the [Picasso][picasso] image loading library. + +## `PicassoImage()` + +The primary API is via the `PicassoImage()` functions. There are a 2 function versions available. + +The simplest usage is like so: + +```kotlin +PicassoImage( + data = "https://loremflickr.com/300/300" +) +``` + +This loads the `data` passed in with [Picasso][Picasso], and then displays the resulting image using the standard `Image` composable. + +There is also a version of this function which accepts a Picasso [`ImageRequest`](https://Picasso-kt.github.io/Picasso/image_requests/), allowing full customization of the request. This allows usage of things like (but not limited to) transformations: + +```kotlin +PicassoImage( + data = "https://loremflickr.com/300/300", + requestBuilder = { + rotate(90f) + } +) +``` + +It also provides optional content 'slots', allowing you to provide custom content to be displayed when the request is loading, and/or if the image request failed: + +``` kotlin +PicassoImage( + data = "https://loremflickr.com/300/300", + loading = { + Box(Modifier.matchParentSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + }, + error = { + Image(asset = imageResource(R.drawable.ic_error)) + } +) +``` + +## Fade-in animation + +This library has built-in support for animating loaded images in, using a [fade-in animation](https://material.io/archive/guidelines/patterns/loading-images.html). + +![](./images/crossfade.gif) + +There are two ways to enable the animation: + +### `fadeIn` parameter + +A `fadeIn: Boolean` parameter is available on `PicassoImage` (default: `false`). When enabled, a default fade-in animation will be used when the image is successfully loaded: + +``` kotlin +PicassoImage( + data = "https://picsum.photos/300/300", + fadeIn = true +) +``` + +## Custom content + +If you need more control over the animation, or you want to provide custom layout for the loaded image, you can use the `content` composable version of `PicassoImage`: + +``` kotlin +PicassoImage( + data = "https://random.image", +) { imageState -> + when (imageState) { + is ImageLoadState.Success -> { + MaterialLoadingImage( + result = imageState, + fadeInEnabled = true, + fadeInDurationMs = 600, + ) + } + is ImageLoadState.Error -> /* TODO */ + ImageLoadState.Loading -> /* TODO */ + ImageLoadState.Empty -> /* TODO */ + } +} +``` + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "dev.chrisbanes.accompanist:accompanist-picasso:" +} +``` + +Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. + +[compose]: https://developer.android.com/jetpack/compose +[snap]: https://oss.sonatype.org/content/repositories/snapshots/dev/chrisbanes/accompanist/accompanist-picasso/ +[picasso]: https://square.github.io/picasso/ diff --git a/picasso/api/picasso.api b/picasso/api/picasso.api new file mode 100644 index 000000000..d7b765784 --- /dev/null +++ b/picasso/api/picasso.api @@ -0,0 +1,5 @@ +public final class dev/chrisbanes/accompanist/picasso/PicassoImage { + public static final fun PicassoImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;Landroidx/compose/ui/graphics/ColorFilter;ZLcom/squareup/picasso/Picasso;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun PicassoImage (Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lcom/squareup/picasso/Picasso;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/picasso/build.gradle b/picasso/build.gradle new file mode 100644 index 000000000..8845408d9 --- /dev/null +++ b/picasso/build.gradle @@ -0,0 +1,102 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import dev.chrisbanes.accompanist.buildsrc.Libs + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +android { + compileSdkVersion 30 + + defaultConfig { + minSdkVersion 21 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + buildConfig false + compose true + } + + composeOptions { + kotlinCompilerVersion Libs.Kotlin.version + kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Certain libraries include licence files in their JARs. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +afterEvaluate { + tasks.withType(org.jetbrains.dokka.gradle.DokkaTask).configureEach { + outputDirectory.set(rootProject.file('docs/api')) + } +} + +dependencies { + api project(':imageloading-core') + + api Libs.picasso + + implementation Libs.OkHttp.okhttp + + implementation Libs.AndroidX.coreKtx + implementation Libs.AndroidX.Compose.runtime + implementation Libs.AndroidX.Compose.foundation + + implementation Libs.Kotlin.stdlib + implementation Libs.Coroutines.android + + androidTestImplementation Libs.junit + androidTestImplementation Libs.truth + + androidTestImplementation Libs.OkHttp.mockWebServer + + androidTestImplementation Libs.Coroutines.test + + androidTestImplementation Libs.AndroidX.Compose.test + androidTestImplementation Libs.AndroidX.Compose.ui + androidTestImplementation Libs.AndroidX.Test.rules + androidTestImplementation Libs.AndroidX.Test.runner +} + +apply plugin: "com.vanniktech.maven.publish" diff --git a/picasso/gradle.properties b/picasso/gradle.properties new file mode 100644 index 000000000..f307457c9 --- /dev/null +++ b/picasso/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=accompanist-picasso +POM_NAME=Accompanist Picasso integration +POM_PACKAGING=aar \ No newline at end of file diff --git a/picasso/images/crossfade.gif b/picasso/images/crossfade.gif new file mode 100644 index 000000000..d05afe4a8 Binary files /dev/null and b/picasso/images/crossfade.gif differ diff --git a/picasso/src/androidTest/AndroidManifest.xml b/picasso/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..7222509ff --- /dev/null +++ b/picasso/src/androidTest/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTest.kt b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTest.kt new file mode 100644 index 000000000..4f66f41d8 --- /dev/null +++ b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTest.kt @@ -0,0 +1,453 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.picasso + +import androidx.compose.foundation.Image +import androidx.compose.foundation.Text +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.test.filters.LargeTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import androidx.ui.test.assertHeightIsAtLeast +import androidx.ui.test.assertHeightIsEqualTo +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.assertPixels +import androidx.ui.test.assertWidthIsAtLeast +import androidx.ui.test.assertWidthIsEqualTo +import androidx.ui.test.captureToBitmap +import androidx.ui.test.createComposeRule +import androidx.ui.test.onNodeWithTag +import androidx.ui.test.onNodeWithText +import com.google.common.truth.Truth.assertThat +import com.squareup.picasso.MemoryPolicy +import dev.chrisbanes.accompanist.imageloading.ImageLoadState +import dev.chrisbanes.accompanist.picasso.test.R +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@LargeTest +@RunWith(JUnit4::class) +class PicassoTest { + @get:Rule + val composeTestRule = createComposeRule() + + // Our MockWebServer. We use a response delay to simulate real-world conditions + private val server = testWebServer(responseDelayMs = 200) + + @Before + fun setup() { + // Start our mock web server + server.start() + } + + @After + fun teardown() { + // Shutdown our mock web server + server.shutdown() + } + + @Test + fun basicLoad_http() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = server.url("/image"), + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.onNodeWithTag(TestTags.Image) + .assertIsDisplayed() + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + } + + @Test + @SdkSuppress(minSdkVersion = 26) // captureToBitmap is SDK 26+ + fun basicLoad_drawable() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = R.drawable.red_rectangle, + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.onNodeWithTag(TestTags.Image) + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + .assertIsDisplayed() + .captureToBitmap() + .assertPixels { Color.Red } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + @SdkSuppress(minSdkVersion = 26) // captureToBitmap is SDK 26+ + fun basicLoad_switchData() { + val loadCompleteSignal = Channel(Channel.UNLIMITED) + val drawableResId = MutableStateFlow(R.drawable.red_rectangle) + + composeTestRule.setContent { + val resId = drawableResId.collectAsState() + PicassoImage( + data = resId.value, + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { loadCompleteSignal.offer(Unit) } + ) + } + + // Await the first load + runBlocking { + withTimeout(5000) { + loadCompleteSignal.receive() + } + } + + // Assert that the content is completely Red + composeTestRule.onNodeWithTag(TestTags.Image) + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + .assertIsDisplayed() + .captureToBitmap() + .assertPixels { Color.Red } + + // Now switch the data URI to the blue drawable + drawableResId.value = R.drawable.blue_rectangle + + // Await the second load + runBlocking { + withTimeout(5000) { + loadCompleteSignal.receive() + } + } + + // Assert that the content is completely Blue + composeTestRule.onNodeWithTag(TestTags.Image) + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + .assertIsDisplayed() + .captureToBitmap() + .assertPixels { Color.Blue } + + // Close the signal channel + loadCompleteSignal.close() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun basicLoad_changeSize() { + val loadCompleteSignal = Channel(Channel.UNLIMITED) + val sizeFlow = MutableStateFlow(128.dp) + + composeTestRule.setContent { + val size = sizeFlow.collectAsState() + PicassoImage( + data = R.drawable.red_rectangle, + modifier = Modifier.preferredSize(size.value).testTag(TestTags.Image), + onRequestCompleted = { loadCompleteSignal.offer(it) } + ) + } + + // Await the first load + runBlocking { + + val result = loadCompleteSignal.receive() + + if (result is ImageLoadState.Error) { + throw result.throwable + } + + assertThat(result) + .isInstanceOf(ImageLoadState.Success::class.java) + } + + // Now change the size + sizeFlow.value = 256.dp + + // Await the potential second load (which shouldn't come) + runBlocking { + val result = withTimeoutOrNull(3000) { loadCompleteSignal.receive() } + assertThat(result).isNull() + } + + // Close the signal channel + loadCompleteSignal.close() + } + + @Test + fun basicLoad_nosize() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = R.raw.sample, + modifier = Modifier.testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.onNodeWithTag(TestTags.Image) + .assertWidthIsAtLeast(1.dp) + .assertHeightIsAtLeast(1.dp) + .assertIsDisplayed() + } + + @Test + fun errorStillHasSize() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = server.url("/noimage"), + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + // Assert that the layout is in the tree and has the correct size + composeTestRule.onNodeWithTag(TestTags.Image) + .assertIsDisplayed() + .assertWidthIsEqualTo(128.dp) + .assertHeightIsEqualTo(128.dp) + } + + @Test + fun content_error() { + val latch = CountDownLatch(1) + val states = ArrayList() + + composeTestRule.setContent { + PicassoImage( + data = server.url("/noimage"), + modifier = Modifier.preferredSize(128.dp, 128.dp), + // Disable any caches. If the item is in the cache, the fetch is + // synchronous which means the Loading state is skipped + requestBuilder = { + memoryPolicy(MemoryPolicy.NO_CACHE) + }, + onRequestCompleted = { latch.countDown() } + ) { state -> + states.add(state) + } + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.runOnIdle { + assertThat(states).hasSize(3) + assertThat(states[0]).isEqualTo(ImageLoadState.Empty) + assertThat(states[1]).isEqualTo(ImageLoadState.Loading) + assertThat(states[2]).isInstanceOf(ImageLoadState.Error::class.java) + } + } + + @Test + fun content_success() { + val latch = CountDownLatch(1) + val states = ArrayList() + + composeTestRule.setContent { + PicassoImage( + data = server.url("/image"), + modifier = Modifier.preferredSize(128.dp, 128.dp), + // Disable any caches. If the item is in the cache, the fetch is + // synchronous which means the Loading state is skipped + requestBuilder = { + memoryPolicy(MemoryPolicy.NO_CACHE) + }, + onRequestCompleted = { latch.countDown() } + ) { state -> + states.add(state) + } + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + composeTestRule.runOnIdle { + assertThat(states).hasSize(3) + assertThat(states[0]).isEqualTo(ImageLoadState.Empty) + assertThat(states[1]).isEqualTo(ImageLoadState.Loading) + assertThat(states[2]).isInstanceOf(ImageLoadState.Success::class.java) + } + } + + @Test + @SdkSuppress(minSdkVersion = 26) // captureToBitmap is SDK 26+ + fun content_custom() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = R.raw.sample, + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) { _ -> + // Return an Image which just draws cyan + Image(painter = ColorPainter(Color.Cyan)) + } + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + // Assert that the whole layout is drawn cyan + composeTestRule.onNodeWithTag(TestTags.Image) + .assertIsDisplayed() + .captureToBitmap() + .assertPixels { Color.Cyan } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun loading_slot() { + val dispatcher = TestCoroutineDispatcher() + val loadLatch = CountDownLatch(1) + + dispatcher.runBlockingTest { + pauseDispatcher() + + composeTestRule.setContent { + PicassoImage( + data = server.url("/image"), + modifier = Modifier.preferredSize(128.dp, 128.dp), + // Disable any caches. If the item is in the cache, the fetch is + // synchronous which means the Loading state is skipped + requestBuilder = { + memoryPolicy(MemoryPolicy.NO_CACHE) + }, + loading = { Text(text = "Loading") }, + onRequestCompleted = { loadLatch.countDown() } + ) + } + + // Assert that the loading component is displayed + composeTestRule.onNodeWithText("Loading").assertIsDisplayed() + + // Now resume the dispatcher to start the Coil request + dispatcher.resumeDispatcher() + } + + // We now wait for the request to complete + loadLatch.await(5, TimeUnit.SECONDS) + + // And assert that the loading component no longer exists + composeTestRule.onNodeWithText("Loading").assertDoesNotExist() + } + + @Test + @SdkSuppress(minSdkVersion = 26) // captureToBitmap is SDK 26+ + fun error_slot() { + val latch = CountDownLatch(1) + + composeTestRule.setContent { + PicassoImage( + data = server.url("/noimage"), + error = { + // Return failure content which just draws red + Image(painter = ColorPainter(Color.Red)) + }, + modifier = Modifier.preferredSize(128.dp, 128.dp).testTag(TestTags.Image), + onRequestCompleted = { latch.countDown() } + ) + } + + // Wait for the onRequestCompleted to release the latch + latch.await(5, TimeUnit.SECONDS) + + // Assert that the whole layout is drawn red + composeTestRule.onNodeWithTag(TestTags.Image) + .assertIsDisplayed() + .captureToBitmap() + .assertPixels { Color.Red } + } +} + +/** + * [MockWebServer] which returns a valid response at the path `/image`, and a 404 for anything else. + * We add a small delay to simulate 'real-world' network conditions. + */ +private fun testWebServer(responseDelayMs: Long = 0): MockWebServer { + val dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + "/image" -> { + val res = InstrumentationRegistry.getInstrumentation().targetContext.resources + + // Load the image into a Buffer + val imageBuffer = Buffer().apply { + readFrom(res.openRawResource(R.raw.sample)) + } + + MockResponse() + .setHeadersDelay(responseDelayMs, TimeUnit.MILLISECONDS) + .addHeader("Content-Type", "image/jpeg") + .setBody(imageBuffer) + } + else -> + MockResponse() + .setHeadersDelay(responseDelayMs, TimeUnit.MILLISECONDS) + .setResponseCode(404) + } + } + + return MockWebServer().apply { + setDispatcher(dispatcher) + } +} diff --git a/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTestApplication.kt b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTestApplication.kt new file mode 100644 index 000000000..d20de8711 --- /dev/null +++ b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/PicassoTestApplication.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.picasso + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.os.StrictMode + +class PicassoTestApplication : Application() { + override fun onCreate() { + super.onCreate() + + registerActivityLifecycleCallbacks( + object : DefaultActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedState: Bundle?) { + // [CoilTest] uses MockWebServer.url() which internally does a network check, + // and triggers StrictMode. To workaround that in the tests, we allow network + // on main thread. + val threadPolicy = StrictMode.ThreadPolicy.Builder() + .detectAll() + .permitNetwork() + .build() + StrictMode.setThreadPolicy(threadPolicy) + } + } + ) + } +} + +/** + * [Application.ActivityLifecycleCallbacks] which adds default empty method implementations. + */ +private interface DefaultActivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, savedState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit +} diff --git a/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/TestTags.kt b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/TestTags.kt new file mode 100644 index 000000000..70154470b --- /dev/null +++ b/picasso/src/androidTest/java/dev/chrisbanes/accompanist/picasso/TestTags.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.picasso + +object TestTags { + const val Image = "image" +} diff --git a/picasso/src/androidTest/res/drawable-nodpi/blue_rectangle.png b/picasso/src/androidTest/res/drawable-nodpi/blue_rectangle.png new file mode 100644 index 000000000..c6283cd0b Binary files /dev/null and b/picasso/src/androidTest/res/drawable-nodpi/blue_rectangle.png differ diff --git a/picasso/src/androidTest/res/drawable-nodpi/red_rectangle.png b/picasso/src/androidTest/res/drawable-nodpi/red_rectangle.png new file mode 100644 index 000000000..670daa360 Binary files /dev/null and b/picasso/src/androidTest/res/drawable-nodpi/red_rectangle.png differ diff --git a/picasso/src/androidTest/res/raw/sample.jpg b/picasso/src/androidTest/res/raw/sample.jpg new file mode 100644 index 000000000..0aac3e556 Binary files /dev/null and b/picasso/src/androidTest/res/raw/sample.jpg differ diff --git a/picasso/src/androidTest/res/values/themes.xml b/picasso/src/androidTest/res/values/themes.xml new file mode 100644 index 000000000..3e00f3d14 --- /dev/null +++ b/picasso/src/androidTest/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/picasso/src/main/AndroidManifest.xml b/picasso/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8e7707d8e --- /dev/null +++ b/picasso/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/picasso/src/main/java/dev/chrisbanes/accompanist/picasso/Picasso.kt b/picasso/src/main/java/dev/chrisbanes/accompanist/picasso/Picasso.kt new file mode 100644 index 000000000..b69912038 --- /dev/null +++ b/picasso/src/main/java/dev/chrisbanes/accompanist/picasso/Picasso.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("PicassoImage") +@file:JvmMultifileClass + +package dev.chrisbanes.accompanist.picasso + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageAsset +import androidx.compose.ui.graphics.asImageAsset +import androidx.compose.ui.graphics.painter.ImagePainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.IntSize +import com.squareup.picasso.Picasso +import com.squareup.picasso.RequestCreator +import dev.chrisbanes.accompanist.imageloading.DataSource +import dev.chrisbanes.accompanist.imageloading.DefaultRefetchOnSizeChangeLambda +import dev.chrisbanes.accompanist.imageloading.EmptyRequestCompleteLambda +import dev.chrisbanes.accompanist.imageloading.ImageLoad +import dev.chrisbanes.accompanist.imageloading.ImageLoadState +import dev.chrisbanes.accompanist.imageloading.MaterialLoadingImage +import dev.chrisbanes.accompanist.imageloading.toPainter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.HttpUrl +import java.io.File + +/** + * Creates a composable that will attempt to load the given [data] using [Picasso], and provides + * complete content of how the current state is displayed: + * + * ``` + * PicassoImage( + * data = "https://www.image.url", + * ) { imageState -> + * when (imageState) { + * is ImageLoadState.Success -> // TODO + * is ImageLoadState.Error -> // TODO + * ImageLoadState.Loading -> // TODO + * ImageLoadState.Empty -> // TODO + * } + * } + * ``` + * + * @param data The data to load. See [RequestCreator.data] for the types allowed. + * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param requestBuilder Optional builder for the [RequestCreator]. + * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing + * optional re-fetching of the image. Return true to re-fetch the image. + * @param onRequestCompleted Listener which will be called when the loading request has finished. + * @param content Content to be displayed for the given state. + */ +@Composable +fun PicassoImage( + data: Any, + modifier: Modifier = Modifier, + picasso: Picasso = Picasso.get(), + requestBuilder: (RequestCreator.(size: IntSize) -> RequestCreator)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, + content: @Composable (imageLoadState: ImageLoadState) -> Unit +) { + ImageLoad( + request = data.toRequestCreator(picasso), + requestKey = data, // Picasso RequestCreator doesn't support equality so we use the data + executeRequest = { r -> + @OptIn(ExperimentalCoroutinesApi::class) + suspendCancellableCoroutine { cont -> + val target = object : com.squareup.picasso.Target { + override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) { + val state = ImageLoadState.Success( + painter = ImagePainter(bitmap.asImageAsset()), + source = from.toDataSource() + ) + cont.resume(state) { + // Not much we can do here. Ignore this + } + } + + override fun onBitmapFailed(exception: Exception, errorDrawable: Drawable?) { + val state = ImageLoadState.Error( + throwable = exception, + painter = errorDrawable?.toPainter(), + ) + cont.resume(state) { + // Not much we can do here. Ignore this + } + } + + override fun onPrepareLoad(placeholder: Drawable?) = Unit + } + + cont.invokeOnCancellation { + // If the coroutine is cancelled, cancel the request + picasso.cancelRequest(target) + } + + // Now kick off the image load into our target + r.into(target) + } + }, + transformRequestForSize = { r, size -> + val sizedRequest = when { + // If the size contains an unspecified sized dimension, we don't specify a size + // in the Coil request + size.width < 0 || size.height < 0 -> r + // If we have a non-zero size, we can modify the request to include the size + size != IntSize.Zero -> r.resize(size.width, size.height).onlyScaleDown() + // Otherwise we have a zero size, so no point executing a request + else -> null + } + + if (sizedRequest != null && requestBuilder != null) { + // If we have a transformed request and builder, let it run + requestBuilder(sizedRequest, size) + } else { + // Otherwise we just return the sizedRequest + sizedRequest + } + }, + shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + onRequestCompleted = onRequestCompleted, + modifier = modifier, + content = content + ) +} + +/** + * Creates a composable that will attempt to load the given [data] using [Picasso], and then + * display the result in an [Image]. + * + * This version of the function is more opinionated, providing: + * + * - Support for displaying alternative content while the request is 'loading'. + * See the [loading] parameter. + * - Support for displaying alternative content if the request was unsuccessful. + * See the [error] parameter. + * - Support for automatically fading-in the image once loaded. See the [fadeIn] parameter. + * + * ``` + * PicassoImage( + * data = "https://www.image.url", + * fadeIn = true, + * loading = { + * Stack(Modifier.fillMaxSize()) { + * CircularProgressIndicator(Modifier.align(Alignment.Center)) + * } + * } + * ) + * ``` + * + * @param data The data to load. See [RequestCreator.data] for the types allowed. + * @param modifier [Modifier] used to adjust the layout algorithm or draw decoration content. + * @param alignment Optional alignment parameter used to place the loaded [ImageAsset] in the + * given bounds defined by the width and height. + * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be + * used if the bounds are a different size from the intrinsic size of the loaded [ImageAsset]. + * @param colorFilter Optional colorFilter to apply for the [Painter] when it is rendered onscreen. + * @param error Content to be displayed when the request failed. + * @param loading Content to be displayed when the request is in progress. + * @param fadeIn Whether to run a fade-in animation when images are successfully loaded. + * Default: `false`. + * @param requestBuilder Optional builder for the [RequestCreator]. + * @param shouldRefetchOnSizeChange Lambda which will be invoked when the size changes, allowing + * optional re-fetching of the image. Return true to re-fetch the image. + * @param onRequestCompleted Listener which will be called when the loading request has finished. + */ +@Composable +fun PicassoImage( + data: Any, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + fadeIn: Boolean = false, + picasso: Picasso = Picasso.get(), + requestBuilder: (RequestCreator.(size: IntSize) -> RequestCreator)? = null, + shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda, + onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda, + error: @Composable ((ImageLoadState.Error) -> Unit)? = null, + loading: @Composable (() -> Unit)? = null, +) { + PicassoImage( + data = data, + modifier = modifier, + requestBuilder = requestBuilder, + picasso = picasso, + shouldRefetchOnSizeChange = shouldRefetchOnSizeChange, + onRequestCompleted = onRequestCompleted, + ) { imageState -> + when (imageState) { + is ImageLoadState.Success -> { + MaterialLoadingImage( + result = imageState, + fadeInEnabled = fadeIn, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter + ) + } + is ImageLoadState.Error -> if (error != null) error(imageState) + ImageLoadState.Loading -> if (loading != null) loading() + ImageLoadState.Empty -> Unit + } + } +} + +private fun Picasso.LoadedFrom.toDataSource(): DataSource = when (this) { + Picasso.LoadedFrom.MEMORY -> DataSource.MEMORY + Picasso.LoadedFrom.DISK -> DataSource.DISK + Picasso.LoadedFrom.NETWORK -> DataSource.NETWORK +} + +private fun Any.toRequestCreator(picasso: Picasso): RequestCreator = when (this) { + is String -> picasso.load(this) + is Uri -> picasso.load(this) + is File -> picasso.load(this) + is Int -> picasso.load(this) + is HttpUrl -> picasso.load(Uri.parse(toString())) + else -> throw IllegalArgumentException("Data is not of a type which Picasso supports: ${this::class.java}") +} diff --git a/sample/build.gradle b/sample/build.gradle index 0a1ed10e3..217c8b613 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -50,6 +50,7 @@ android { } dependencies { + implementation project(':picasso') implementation project(':coil') implementation Libs.Coil.gif diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 95f008403..ec98a2049 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -67,6 +67,33 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilSampleUtils.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/ImageLoadingSampleUtils.kt similarity index 94% rename from sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilSampleUtils.kt rename to sample/src/main/java/dev/chrisbanes/accompanist/sample/ImageLoadingSampleUtils.kt index d15f5e962..b3038da76 100644 --- a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilSampleUtils.kt +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/ImageLoadingSampleUtils.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package dev.chrisbanes.accompanist.sample.coil +package dev.chrisbanes.accompanist.sample private val rangeForRandom = (0..100000) diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilBasicSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilBasicSample.kt index 2464da7d4..72f986117 100644 --- a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilBasicSample.kt +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilBasicSample.kt @@ -19,7 +19,6 @@ package dev.chrisbanes.accompanist.sample.coil import android.content.Context import android.os.Build.VERSION.SDK_INT import android.os.Bundle -import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ScrollableColumn import androidx.compose.foundation.Text @@ -34,7 +33,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.Recomposer import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ContextAmbient @@ -48,15 +46,13 @@ import coil.request.ImageRequest import coil.transform.CircleCropTransformation import dev.chrisbanes.accompanist.coil.CoilImage import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl class CoilBasicSample : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val contentView = FrameLayout(this) - setContentView(contentView) - - contentView.setContent(Recomposer.current()) { + setContent { MaterialTheme { Sample() } diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilGridSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilGridSample.kt index c849341d2..abd46698f 100644 --- a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilGridSample.kt +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilGridSample.kt @@ -17,7 +17,6 @@ package dev.chrisbanes.accompanist.sample.coil import android.os.Bundle -import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ScrollableColumn import androidx.compose.foundation.Text @@ -29,22 +28,19 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.Recomposer import androidx.compose.ui.Modifier import androidx.compose.ui.platform.setContent import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.chrisbanes.accompanist.coil.CoilImage import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl class CoilGridSample : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val contentView = FrameLayout(this) - setContentView(contentView) - - contentView.setContent(Recomposer.current()) { + setContent { MaterialTheme { Sample() } diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilLazyColumnSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilLazyColumnSample.kt index 2a946c8cd..5b5823b1d 100644 --- a/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilLazyColumnSample.kt +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/coil/CoilLazyColumnSample.kt @@ -17,7 +17,6 @@ package dev.chrisbanes.accompanist.sample.coil import android.os.Bundle -import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Text import androidx.compose.foundation.layout.ExperimentalLayout @@ -31,7 +30,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.Recomposer import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.setContent @@ -39,15 +37,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.chrisbanes.accompanist.coil.CoilImage import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl class CoilLazyColumnSample : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val contentView = FrameLayout(this) - setContentView(contentView) - - contentView.setContent(Recomposer.current()) { + setContent { MaterialTheme { Sample() } diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoBasicSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoBasicSample.kt new file mode 100644 index 000000000..bd198e3e7 --- /dev/null +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoBasicSample.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.sample.picasso + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.ScrollableColumn +import androidx.compose.foundation.Text +import androidx.compose.foundation.layout.ExperimentalLayout +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Stack +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.setContent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.chrisbanes.accompanist.picasso.PicassoImage +import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl + +class PicassoBasicSample : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Sample() + } + } + } +} + +@OptIn(ExperimentalLayout::class) +@Composable +private fun Sample() { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.coil_title_basic)) } + ) + } + ) { + ScrollableColumn(modifier = Modifier.padding(16.dp)) { + FlowRow( + mainAxisSpacing = 4.dp, + crossAxisSpacing = 4.dp + ) { + // PicassoImage with data parameter + PicassoImage( + data = randomSampleImageUrl(), + modifier = Modifier.preferredSize(128.dp) + ) + + // PicassoImage with ImageRequest builder parameter + PicassoImage( + data = randomSampleImageUrl(), + requestBuilder = { + rotate(90f) + }, + modifier = Modifier.preferredSize(128.dp) + ) + + // PicassoImage with loading slot + PicassoImage( + data = randomSampleImageUrl(), + loading = { + Stack(Modifier.fillMaxSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + }, + modifier = Modifier.preferredSize(128.dp) + ) + + // PicassoImage with crossfade and data parameter + PicassoImage( + data = randomSampleImageUrl(), + fadeIn = true, + modifier = Modifier.preferredSize(128.dp) + ) + + // PicassoImage with crossfade and loading slot + PicassoImage( + data = randomSampleImageUrl(), + fadeIn = true, + loading = { + Stack(Modifier.fillMaxSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + }, + modifier = Modifier.preferredSize(128.dp) + ) + + // PicassoImage with an implicit size + PicassoImage( + data = randomSampleImageUrl(), + loading = { + Stack(Modifier.fillMaxSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + ) + } + } + } +} diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoGridSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoGridSample.kt new file mode 100644 index 000000000..992af5354 --- /dev/null +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoGridSample.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.sample.picasso + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.ScrollableColumn +import androidx.compose.foundation.Text +import androidx.compose.foundation.layout.ExperimentalLayout +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.setContent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.chrisbanes.accompanist.picasso.PicassoImage +import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl + +class PicassoGridSample : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Sample() + } + } + } +} + +private const val NumberItems = 60 + +@OptIn(ExperimentalLayout::class) +@Composable +private fun Sample() { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.coil_title_grid)) } + ) + } + ) { + ScrollableColumn(modifier = Modifier.padding(16.dp)) { + FlowRow( + mainAxisSpacing = 4.dp, + crossAxisSpacing = 4.dp + ) { + for (i in 0 until NumberItems) { + PicassoImage( + data = randomSampleImageUrl(i), + modifier = Modifier.preferredSize(112.dp) + ) + } + } + } + } +} diff --git a/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoLazyColumnSample.kt b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoLazyColumnSample.kt new file mode 100644 index 000000000..65846b411 --- /dev/null +++ b/sample/src/main/java/dev/chrisbanes/accompanist/sample/picasso/PicassoLazyColumnSample.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.chrisbanes.accompanist.sample.picasso + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.Text +import androidx.compose.foundation.layout.ExperimentalLayout +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.lazy.LazyColumnFor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.setContent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.chrisbanes.accompanist.picasso.PicassoImage +import dev.chrisbanes.accompanist.sample.R +import dev.chrisbanes.accompanist.sample.randomSampleImageUrl + +class PicassoLazyColumnSample : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Sample() + } + } + } +} + +private const val NumberItems = 60 + +@OptIn(ExperimentalLayout::class, ExperimentalStdlibApi::class) +@Composable +private fun Sample() { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.coil_title_grid)) } + ) + } + ) { + val items = buildList { + repeat(NumberItems) { add(randomSampleImageUrl(it)) } + } + LazyColumnFor(items, modifier = Modifier.padding(16.dp)) { imageUrl -> + Row(Modifier.padding(16.dp)) { + PicassoImage( + data = imageUrl, + modifier = Modifier.preferredSize(64.dp) + ) + + Spacer(Modifier.preferredWidth(8.dp)) + + Text( + text = "Text", + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.weight(1f).align(Alignment.CenterVertically) + ) + } + } + } +} diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 6b9f776e9..d1cd94c3d 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -16,7 +16,12 @@ Accompanist Sample - Coil sample: Basic - Coil sample: Grid - Coil sample: Lazy row + + Coil: Basic + Coil: Grid + Coil: Lazy row + + Picasso: Basic + Picasso: Grid + Picasso: Lazy row diff --git a/settings.gradle b/settings.gradle index d9a48b61a..d00db1e5c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,5 +15,6 @@ */ include ':coil' +include ':picasso' include ':imageloading-core' include ':sample'