Skip to content

Commit

Permalink
Migrate to Coil3 Multiplatform (#268)
Browse files Browse the repository at this point in the history
* Bump coil dependency to v3.0.0-alpha03

Also added the coil network package, since that's required to load network images now

* Provide coil `ImageLoader` via `ImageLoaderComponent`

* Add coil extension to convert coil `Image` to compose `ImageBitmap`

* Add coil SVG dependency

* Migrate to Coil3 multiplatform implementation

* Load image in background in `DynamicContentTheme`
  • Loading branch information
msasikanth authored Feb 1, 2024
1 parent 6cfaf0d commit dd2a89b
Show file tree
Hide file tree
Showing 16 changed files with 217 additions and 378 deletions.
6 changes: 4 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ androidx_work = "2.9.0"
androidx_datastore = "1.1.0-beta01"
androidx_browser = "1.7.0"
androidx_annotation = "1.7.1"
coil = "2.5.0"
coil = "3.0.0-alpha03"
spotless = "6.25.0"
ktfmt = "0.44"
kotlininject = "0.6.3"
Expand Down Expand Up @@ -89,7 +89,9 @@ androidx_datastore_okio = { module = "androidx.datastore:datastore-core-okio", v
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx_datastore" }
androidx_browser = { module = "androidx.browser:browser", version.ref = "androidx_browser" }
androidx_annotation= { module = "androidx.annotation:annotation", version.ref = "androidx_annotation" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil_network = { module = "io.coil-kt.coil3:coil-network-ktor", version.ref = "coil" }
coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
kotlininject-compiler = { module = 'me.tatarka.inject:kotlin-inject-compiler-ksp', version.ref = 'kotlininject' }
kotlininject-runtime = { module = 'me.tatarka.inject:kotlin-inject-runtime', version.ref = 'kotlininject' }
material_color_utilities = { module = "dev.sasikanth:material-color-utilities", version.ref = "material_color_utilities" }
Expand Down
4 changes: 3 additions & 1 deletion shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ kotlin {
implementation(libs.bundles.xmlutil)
api(libs.webview)
implementation(libs.uuid)
api(libs.coil.compose)
api(libs.coil.network)
api(libs.coil.svg)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
Expand All @@ -134,7 +137,6 @@ kotlin {
api(libs.androidx.browser)
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.driver.android)
implementation(libs.coil.compose)
api(libs.sqliteAndroid)
}
val androidInstrumentedTest by getting {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Sasikanth Miriyampalli
* Copyright 2024 Sasikanth Miriyampalli
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -13,13 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package dev.sasikanth.rss.reader.di

import dev.sasikanth.rss.reader.components.AndroidImageLoader
import dev.sasikanth.rss.reader.components.image.ImageLoader
import android.content.Context
import coil3.PlatformContext
import me.tatarka.inject.annotations.Provides
import okio.Path
import okio.Path.Companion.toPath

actual interface ImageLoaderPlatformComponent {

internal actual interface ImageLoaderComponent {
@Provides fun providePlatformContext(context: Context): PlatformContext = context

@Provides fun AndroidImageLoader.bind(): ImageLoader = this
@Provides
fun diskCache(application: Context): Path =
application.cacheDir.absolutePath.toPath().resolve("dev_sasikanth_rss_reader_images_cache")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2024 Sasikanth Miriyampalli
*
* 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
*
* http://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.sasikanth.rss.reader.utils

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import coil3.Image
import coil3.PlatformContext
import coil3.annotation.ExperimentalCoilApi

@OptIn(ExperimentalCoilApi::class)
actual fun Image.toComposeImageBitmap(context: PlatformContext): ImageBitmap {
return asDrawable(context.resources).toBitmap().asImageBitmap()
}
13 changes: 7 additions & 6 deletions shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation
import com.arkivanov.essenty.backhandler.BackHandler
import dev.sasikanth.rss.reader.about.ui.AboutScreen
import dev.sasikanth.rss.reader.bookmarks.ui.BookmarksScreen
import dev.sasikanth.rss.reader.components.DynamicContentTheme
import dev.sasikanth.rss.reader.components.LocalDynamicColorState
import dev.sasikanth.rss.reader.components.image.ImageLoader
import dev.sasikanth.rss.reader.components.image.LocalImageLoader
import dev.sasikanth.rss.reader.components.rememberDynamicColorState
import dev.sasikanth.rss.reader.home.ui.HomeScreen
import dev.sasikanth.rss.reader.platform.LinkHandler
Expand All @@ -48,17 +48,18 @@ typealias App = @Composable () -> Unit

@Inject
@Composable
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalCoilApi::class)
fun App(
appPresenter: AppPresenter,
imageLoader: ImageLoader,
shareHandler: ShareHandler,
linkHandler: LinkHandler,
imageLoader: ImageLoader,
) {
setSingletonImageLoaderFactory { imageLoader }

val dynamicColorState = rememberDynamicColorState(imageLoader = imageLoader)

CompositionLocalProvider(
LocalImageLoader provides imageLoader,
LocalWindowSizeClass provides calculateWindowSizeClass(),
LocalDynamicColorState provides dynamicColorState,
LocalShareHandler provides shareHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.IntSize
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.size.Scale
import dev.sasikanth.material.color.utilities.dynamiccolor.DynamicColor
import dev.sasikanth.material.color.utilities.dynamiccolor.MaterialDynamicColors
import dev.sasikanth.material.color.utilities.dynamiccolor.ToneDeltaConstraint
Expand All @@ -39,12 +44,13 @@ import dev.sasikanth.material.color.utilities.quantize.QuantizerCelebi
import dev.sasikanth.material.color.utilities.scheme.DynamicScheme
import dev.sasikanth.material.color.utilities.scheme.SchemeContent
import dev.sasikanth.material.color.utilities.score.Score
import dev.sasikanth.rss.reader.components.image.ImageLoader
import dev.sasikanth.rss.reader.ui.AppTheme
import dev.sasikanth.rss.reader.utils.Constants.EPSILON
import dev.sasikanth.rss.reader.utils.inverse
import dev.sasikanth.rss.reader.utils.toComposeImageBitmap
import kotlin.math.absoluteValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext

private const val TINTED_BACKGROUND = "tinted_background"
Expand All @@ -64,7 +70,7 @@ private const val SURFACE_CONTAINER_HIGHEST = "surface_container_highest"

@Composable
internal fun DynamicContentTheme(
dynamicColorState: DynamicColorState = rememberDynamicColorState(),
dynamicColorState: DynamicColorState,
content: @Composable () -> Unit
) {
val colorScheme =
Expand Down Expand Up @@ -104,8 +110,9 @@ internal fun rememberDynamicColorState(
defaultSurfaceContainerLowest: Color = AppTheme.colorScheme.surfaceContainerLowest,
defaultSurfaceContainerHigh: Color = AppTheme.colorScheme.surfaceContainerHigh,
defaultSurfaceContainerHighest: Color = AppTheme.colorScheme.surfaceContainerHighest,
imageLoader: ImageLoader? = null
imageLoader: ImageLoader,
): DynamicColorState {
val platformContext = LocalPlatformContext.current
return rememberSaveable(saver = DynamicColorState.Saver) {
DynamicColorState(
defaultTintedBackground,
Expand All @@ -124,7 +131,7 @@ internal fun rememberDynamicColorState(
defaultSurfaceContainerHighest,
)
}
.also { it.setImageLoader(imageLoader) }
.apply { setImageLoader(imageLoader, platformContext) }
}

/**
Expand Down Expand Up @@ -199,7 +206,9 @@ internal class DynamicColorState(
else -> null
}
private var images = emptyList<String>()
private var imageLoader: ImageLoader? = null

private lateinit var imageLoader: ImageLoader
private lateinit var platformContext: PlatformContext

companion object {
val Saver: Saver<DynamicColorState, *> =
Expand Down Expand Up @@ -245,15 +254,16 @@ internal class DynamicColorState(
)
}

fun setImageLoader(imageLoader: ImageLoader?) {
fun setImageLoader(imageLoader: ImageLoader, platformContext: PlatformContext) {
this.imageLoader = imageLoader
this.platformContext = platformContext
}

suspend fun onContentChange(images: List<String>) {
if (!this.images.containsAll(images)) {
this.images = images
this.images.forEach { imageUrl -> fetchDynamicColors(imageUrl) }
suspend fun onContentChange(newImages: List<String>) {
if (images.isEmpty()) {
images = newImages
}
images.forEach { imageUrl -> fetchDynamicColors(imageUrl) }
}

fun updateOffset(
Expand All @@ -262,6 +272,7 @@ internal class DynamicColorState(
nextImageUrl: String?,
offset: Float
) {

val previousDynamicColors = previousImageUrl?.let { cache?.get(it) }
val currentDynamicColors = cache?.get(currentImageUrl)
val nextDynamicColors = nextImageUrl?.let { cache?.get(it) }
Expand Down Expand Up @@ -397,14 +408,27 @@ internal class DynamicColorState(
surfaceContainerHighest = defaultSurfaceContainerHighest
}

@OptIn(ExperimentalCoilApi::class)
private suspend fun fetchDynamicColors(url: String): DynamicColors? {
val cached = cache?.get(url)
if (cached != null) {
// If we already have the result cached, return early now...
return cached
}

val image = imageLoader?.getImage(url, size = IntSize(64, 64))
val imageRequest =
ImageRequest.Builder(platformContext)
.data(url)
.scale(Scale.FILL)
.size(64)
.memoryCacheKey("$url.dynamic_colors")
.build()

val image =
withContext(Dispatchers.IO) {
imageLoader.execute(imageRequest).image?.toComposeImageBitmap(platformContext)
}

return if (image != null) {
extractColorsFromImage(image)
.let { colorsMap ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,24 @@
*/
package dev.sasikanth.rss.reader.components.image

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.IntSize
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.size.Size

@Composable
internal fun AsyncImage(
url: String,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
size: IntSize? = null,
size: Size = Size.ORIGINAL,
backgroundColor: Color? = null
) {
val backgroundColorModifier =
Expand All @@ -42,20 +43,14 @@ internal fun AsyncImage(
}

Box(modifier.then(backgroundColorModifier)) {
val imageState by rememberImageLoaderState(url, size)
val imageRequest =
ImageRequest.Builder(LocalPlatformContext.current).data(url).size(size).build()

when (imageState) {
is ImageLoaderState.Loaded -> {
Image(
modifier = Modifier.matchParentSize(),
bitmap = (imageState as ImageLoaderState.Loaded).image,
contentDescription = contentDescription,
contentScale = contentScale
)
}
else -> {
// TODO: Handle other cases instead of just showing blank space?
}
}
coil3.compose.AsyncImage(
model = imageRequest,
contentDescription = contentDescription,
modifier = Modifier.matchParentSize(),
contentScale = contentScale
)
}
}
Loading

0 comments on commit dd2a89b

Please sign in to comment.