Skip to content

Commit dd2a89b

Browse files
authored
Migrate to Coil3 Multiplatform (#268)
* 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`
1 parent 6cfaf0d commit dd2a89b

16 files changed

Lines changed: 217 additions & 378 deletions

File tree

gradle/libs.versions.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ androidx_work = "2.9.0"
2525
androidx_datastore = "1.1.0-beta01"
2626
androidx_browser = "1.7.0"
2727
androidx_annotation = "1.7.1"
28-
coil = "2.5.0"
28+
coil = "3.0.0-alpha03"
2929
spotless = "6.25.0"
3030
ktfmt = "0.44"
3131
kotlininject = "0.6.3"
@@ -89,7 +89,9 @@ androidx_datastore_okio = { module = "androidx.datastore:datastore-core-okio", v
8989
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx_datastore" }
9090
androidx_browser = { module = "androidx.browser:browser", version.ref = "androidx_browser" }
9191
androidx_annotation= { module = "androidx.annotation:annotation", version.ref = "androidx_annotation" }
92-
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
92+
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
93+
coil_network = { module = "io.coil-kt.coil3:coil-network-ktor", version.ref = "coil" }
94+
coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
9395
kotlininject-compiler = { module = 'me.tatarka.inject:kotlin-inject-compiler-ksp', version.ref = 'kotlininject' }
9496
kotlininject-runtime = { module = 'me.tatarka.inject:kotlin-inject-runtime', version.ref = 'kotlininject' }
9597
material_color_utilities = { module = "dev.sasikanth:material-color-utilities", version.ref = "material_color_utilities" }

shared/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ kotlin {
121121
implementation(libs.bundles.xmlutil)
122122
api(libs.webview)
123123
implementation(libs.uuid)
124+
api(libs.coil.compose)
125+
api(libs.coil.network)
126+
api(libs.coil.svg)
124127
}
125128
commonTest.dependencies {
126129
implementation(libs.kotlin.test)
@@ -134,7 +137,6 @@ kotlin {
134137
api(libs.androidx.browser)
135138
implementation(libs.ktor.client.okhttp)
136139
implementation(libs.sqldelight.driver.android)
137-
implementation(libs.coil.compose)
138140
api(libs.sqliteAndroid)
139141
}
140142
val androidInstrumentedTest by getting {

shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt

Lines changed: 0 additions & 52 deletions
This file was deleted.

shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt renamed to shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.android.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023 Sasikanth Miriyampalli
2+
* Copyright 2024 Sasikanth Miriyampalli
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -13,13 +13,20 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
1617
package dev.sasikanth.rss.reader.di
1718

18-
import dev.sasikanth.rss.reader.components.AndroidImageLoader
19-
import dev.sasikanth.rss.reader.components.image.ImageLoader
19+
import android.content.Context
20+
import coil3.PlatformContext
2021
import me.tatarka.inject.annotations.Provides
22+
import okio.Path
23+
import okio.Path.Companion.toPath
24+
25+
actual interface ImageLoaderPlatformComponent {
2126

22-
internal actual interface ImageLoaderComponent {
27+
@Provides fun providePlatformContext(context: Context): PlatformContext = context
2328

24-
@Provides fun AndroidImageLoader.bind(): ImageLoader = this
29+
@Provides
30+
fun diskCache(application: Context): Path =
31+
application.cacheDir.absolutePath.toPath().resolve("dev_sasikanth_rss_reader_images_cache")
2532
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2024 Sasikanth Miriyampalli
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package dev.sasikanth.rss.reader.utils
18+
19+
import androidx.compose.ui.graphics.ImageBitmap
20+
import androidx.compose.ui.graphics.asImageBitmap
21+
import androidx.core.graphics.drawable.toBitmap
22+
import coil3.Image
23+
import coil3.PlatformContext
24+
import coil3.annotation.ExperimentalCoilApi
25+
26+
@OptIn(ExperimentalCoilApi::class)
27+
actual fun Image.toComposeImageBitmap(context: PlatformContext): ImageBitmap {
28+
return asDrawable(context.resources).toBitmap().asImageBitmap()
29+
}

shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
2020
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
2121
import androidx.compose.runtime.Composable
2222
import androidx.compose.runtime.CompositionLocalProvider
23-
import androidx.compose.runtime.getValue
2423
import androidx.compose.ui.Modifier
24+
import coil3.ImageLoader
25+
import coil3.annotation.ExperimentalCoilApi
26+
import coil3.compose.setSingletonImageLoaderFactory
2527
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children
2628
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation
2729
import com.arkivanov.essenty.backhandler.BackHandler
2830
import dev.sasikanth.rss.reader.about.ui.AboutScreen
2931
import dev.sasikanth.rss.reader.bookmarks.ui.BookmarksScreen
3032
import dev.sasikanth.rss.reader.components.DynamicContentTheme
3133
import dev.sasikanth.rss.reader.components.LocalDynamicColorState
32-
import dev.sasikanth.rss.reader.components.image.ImageLoader
33-
import dev.sasikanth.rss.reader.components.image.LocalImageLoader
3434
import dev.sasikanth.rss.reader.components.rememberDynamicColorState
3535
import dev.sasikanth.rss.reader.home.ui.HomeScreen
3636
import dev.sasikanth.rss.reader.platform.LinkHandler
@@ -48,17 +48,18 @@ typealias App = @Composable () -> Unit
4848

4949
@Inject
5050
@Composable
51-
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
51+
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalCoilApi::class)
5252
fun App(
5353
appPresenter: AppPresenter,
54-
imageLoader: ImageLoader,
5554
shareHandler: ShareHandler,
5655
linkHandler: LinkHandler,
56+
imageLoader: ImageLoader,
5757
) {
58+
setSingletonImageLoaderFactory { imageLoader }
59+
5860
val dynamicColorState = rememberDynamicColorState(imageLoader = imageLoader)
5961

6062
CompositionLocalProvider(
61-
LocalImageLoader provides imageLoader,
6263
LocalWindowSizeClass provides calculateWindowSizeClass(),
6364
LocalDynamicColorState provides dynamicColorState,
6465
LocalShareHandler provides shareHandler,

shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/DynamicContentTheme.kt

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import androidx.compose.runtime.staticCompositionLocalOf
2929
import androidx.compose.ui.graphics.Color
3030
import androidx.compose.ui.graphics.ImageBitmap
3131
import androidx.compose.ui.graphics.lerp
32-
import androidx.compose.ui.unit.IntSize
32+
import coil3.ImageLoader
33+
import coil3.PlatformContext
34+
import coil3.annotation.ExperimentalCoilApi
35+
import coil3.compose.LocalPlatformContext
36+
import coil3.request.ImageRequest
37+
import coil3.size.Scale
3338
import dev.sasikanth.material.color.utilities.dynamiccolor.DynamicColor
3439
import dev.sasikanth.material.color.utilities.dynamiccolor.MaterialDynamicColors
3540
import dev.sasikanth.material.color.utilities.dynamiccolor.ToneDeltaConstraint
@@ -39,12 +44,13 @@ import dev.sasikanth.material.color.utilities.quantize.QuantizerCelebi
3944
import dev.sasikanth.material.color.utilities.scheme.DynamicScheme
4045
import dev.sasikanth.material.color.utilities.scheme.SchemeContent
4146
import dev.sasikanth.material.color.utilities.score.Score
42-
import dev.sasikanth.rss.reader.components.image.ImageLoader
4347
import dev.sasikanth.rss.reader.ui.AppTheme
4448
import dev.sasikanth.rss.reader.utils.Constants.EPSILON
4549
import dev.sasikanth.rss.reader.utils.inverse
50+
import dev.sasikanth.rss.reader.utils.toComposeImageBitmap
4651
import kotlin.math.absoluteValue
4752
import kotlinx.coroutines.Dispatchers
53+
import kotlinx.coroutines.IO
4854
import kotlinx.coroutines.withContext
4955

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

6571
@Composable
6672
internal fun DynamicContentTheme(
67-
dynamicColorState: DynamicColorState = rememberDynamicColorState(),
73+
dynamicColorState: DynamicColorState,
6874
content: @Composable () -> Unit
6975
) {
7076
val colorScheme =
@@ -104,8 +110,9 @@ internal fun rememberDynamicColorState(
104110
defaultSurfaceContainerLowest: Color = AppTheme.colorScheme.surfaceContainerLowest,
105111
defaultSurfaceContainerHigh: Color = AppTheme.colorScheme.surfaceContainerHigh,
106112
defaultSurfaceContainerHighest: Color = AppTheme.colorScheme.surfaceContainerHighest,
107-
imageLoader: ImageLoader? = null
113+
imageLoader: ImageLoader,
108114
): DynamicColorState {
115+
val platformContext = LocalPlatformContext.current
109116
return rememberSaveable(saver = DynamicColorState.Saver) {
110117
DynamicColorState(
111118
defaultTintedBackground,
@@ -124,7 +131,7 @@ internal fun rememberDynamicColorState(
124131
defaultSurfaceContainerHighest,
125132
)
126133
}
127-
.also { it.setImageLoader(imageLoader) }
134+
.apply { setImageLoader(imageLoader, platformContext) }
128135
}
129136

130137
/**
@@ -199,7 +206,9 @@ internal class DynamicColorState(
199206
else -> null
200207
}
201208
private var images = emptyList<String>()
202-
private var imageLoader: ImageLoader? = null
209+
210+
private lateinit var imageLoader: ImageLoader
211+
private lateinit var platformContext: PlatformContext
203212

204213
companion object {
205214
val Saver: Saver<DynamicColorState, *> =
@@ -245,15 +254,16 @@ internal class DynamicColorState(
245254
)
246255
}
247256

248-
fun setImageLoader(imageLoader: ImageLoader?) {
257+
fun setImageLoader(imageLoader: ImageLoader, platformContext: PlatformContext) {
249258
this.imageLoader = imageLoader
259+
this.platformContext = platformContext
250260
}
251261

252-
suspend fun onContentChange(images: List<String>) {
253-
if (!this.images.containsAll(images)) {
254-
this.images = images
255-
this.images.forEach { imageUrl -> fetchDynamicColors(imageUrl) }
262+
suspend fun onContentChange(newImages: List<String>) {
263+
if (images.isEmpty()) {
264+
images = newImages
256265
}
266+
images.forEach { imageUrl -> fetchDynamicColors(imageUrl) }
257267
}
258268

259269
fun updateOffset(
@@ -262,6 +272,7 @@ internal class DynamicColorState(
262272
nextImageUrl: String?,
263273
offset: Float
264274
) {
275+
265276
val previousDynamicColors = previousImageUrl?.let { cache?.get(it) }
266277
val currentDynamicColors = cache?.get(currentImageUrl)
267278
val nextDynamicColors = nextImageUrl?.let { cache?.get(it) }
@@ -397,14 +408,27 @@ internal class DynamicColorState(
397408
surfaceContainerHighest = defaultSurfaceContainerHighest
398409
}
399410

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

407-
val image = imageLoader?.getImage(url, size = IntSize(64, 64))
419+
val imageRequest =
420+
ImageRequest.Builder(platformContext)
421+
.data(url)
422+
.scale(Scale.FILL)
423+
.size(64)
424+
.memoryCacheKey("$url.dynamic_colors")
425+
.build()
426+
427+
val image =
428+
withContext(Dispatchers.IO) {
429+
imageLoader.execute(imageRequest).image?.toComposeImageBitmap(platformContext)
430+
}
431+
408432
return if (image != null) {
409433
extractColorsFromImage(image)
410434
.let { colorsMap ->

shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/AsyncImage.kt

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,24 @@
1515
*/
1616
package dev.sasikanth.rss.reader.components.image
1717

18-
import androidx.compose.foundation.Image
1918
import androidx.compose.foundation.background
2019
import androidx.compose.foundation.layout.Box
2120
import androidx.compose.runtime.Composable
2221
import androidx.compose.runtime.getValue
2322
import androidx.compose.ui.Modifier
2423
import androidx.compose.ui.graphics.Color
2524
import androidx.compose.ui.layout.ContentScale
26-
import androidx.compose.ui.unit.IntSize
25+
import coil3.compose.LocalPlatformContext
26+
import coil3.request.ImageRequest
27+
import coil3.size.Size
2728

2829
@Composable
2930
internal fun AsyncImage(
3031
url: String,
3132
contentDescription: String?,
3233
modifier: Modifier = Modifier,
3334
contentScale: ContentScale = ContentScale.Fit,
34-
size: IntSize? = null,
35+
size: Size = Size.ORIGINAL,
3536
backgroundColor: Color? = null
3637
) {
3738
val backgroundColorModifier =
@@ -42,20 +43,14 @@ internal fun AsyncImage(
4243
}
4344

4445
Box(modifier.then(backgroundColorModifier)) {
45-
val imageState by rememberImageLoaderState(url, size)
46+
val imageRequest =
47+
ImageRequest.Builder(LocalPlatformContext.current).data(url).size(size).build()
4648

47-
when (imageState) {
48-
is ImageLoaderState.Loaded -> {
49-
Image(
50-
modifier = Modifier.matchParentSize(),
51-
bitmap = (imageState as ImageLoaderState.Loaded).image,
52-
contentDescription = contentDescription,
53-
contentScale = contentScale
54-
)
55-
}
56-
else -> {
57-
// TODO: Handle other cases instead of just showing blank space?
58-
}
59-
}
49+
coil3.compose.AsyncImage(
50+
model = imageRequest,
51+
contentDescription = contentDescription,
52+
modifier = Modifier.matchParentSize(),
53+
contentScale = contentScale
54+
)
6055
}
6156
}

0 commit comments

Comments
 (0)