Use Coil in Poxedex-Compose
Glide has a Modifier.Node reuse bug that crashes the home screen's grid when changing the scroll direction. While the bug has been fixed upstream, there's no current release including the fix, so we're switching to Coil now.
The feature flag will let us benchmark e.g. the detail screen or home screen startup with Coil.
Test: Ran `PokedexActivity` with flag on and off, scrolled home screen, openend details screen, navigated back
Change-Id: Ic6849fed79590e4729d8e2483ab7938e0892ce0e
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt
new file mode 100644
index 0000000..a05a3c7
--- /dev/null
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025 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
+ *
+ * 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 com.skydoves.pokedex.compose.core
+
+/** Contains feature flags for the Pokedex hero benchmark target */
+object PokedexFeatureFlags {
+ /**
+ * Whether to configure and use Coil for image loading of all images, or Glide if false. Please
+ * note that Glide will always be configured.
+ */
+ var UseCoil = true
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
index ef9182b..784070e 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
@@ -22,6 +22,7 @@
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -47,6 +48,7 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -60,11 +62,15 @@
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.palette.graphics.Palette
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder
import com.skydoves.pokedex.compose.R
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.data.repository.details.FakeDetailsRepository
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexCircularProgress
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexText
@@ -110,7 +116,6 @@
}
}
-@OptIn(ExperimentalGlideComposeApi::class)
@Composable
private fun DetailsHeader(
sharedTransitionScope: SharedTransitionScope,
@@ -166,7 +171,8 @@
fontSize = 18.sp,
)
- GlideImage(
+ PokemonHeaderImage(
+ pokemon,
modifier =
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 20.dp)
@@ -180,12 +186,7 @@
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform,
- ),
- model = pokemon?.imageUrl,
- contentScale = ContentScale.Inside,
- transition = CrossFade,
- contentDescription = pokemon?.name,
- loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
)
}
@@ -212,6 +213,33 @@
)
}
+@OptIn(ExperimentalGlideComposeApi::class)
+@Composable
+private fun PokemonHeaderImage(pokemon: Pokemon?, modifier: Modifier) {
+ if (PokedexFeatureFlags.UseCoil) {
+ AsyncImage(
+ modifier = modifier,
+ model =
+ ImageRequest.Builder(LocalContext.current)
+ .data(pokemon?.imageUrl)
+ .crossfade(PokemonHeaderImageCrossfadeDurationMillis)
+ .build(),
+ contentDescription = pokemon?.name,
+ contentScale = ContentScale.Inside,
+ placeholder = painterResource(id = R.drawable.pokemon_preview),
+ )
+ } else {
+ GlideImage(
+ modifier = modifier,
+ model = pokemon?.imageUrl,
+ contentScale = ContentScale.Inside,
+ transition = CrossFade(tween(PokemonHeaderImageCrossfadeDurationMillis)),
+ contentDescription = pokemon?.name,
+ loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
+ }
+}
+
@Composable
private fun DetailsInfo(pokemonInfo: PokemonInfo) {
Row(
@@ -311,3 +339,5 @@
)
}
}
+
+private const val PokemonHeaderImageCrossfadeDurationMillis = 250
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
index adf454e..4cb1a37 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
@@ -23,6 +23,7 @@
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -49,6 +50,7 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -60,11 +62,15 @@
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.palette.graphics.Palette
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder
import com.skydoves.pokedex.compose.R
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.data.repository.home.FakeHomeRepository
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexAppBar
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexCircularProgress
@@ -168,7 +174,7 @@
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
- GlideImage(
+ PokemonCardImage(
modifier =
Modifier.align(Alignment.CenterHorizontally)
.padding(top = 20.dp)
@@ -183,11 +189,7 @@
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform,
),
- contentDescription = pokemon.name,
- model = pokemon.imageUrl,
- contentScale = ContentScale.Inside,
- transition = CrossFade,
- loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ pokemon = pokemon
)
Text(
@@ -214,6 +216,33 @@
}
}
+@Composable
+@OptIn(ExperimentalGlideComposeApi::class)
+private fun PokemonCardImage(pokemon: Pokemon, modifier: Modifier = Modifier) {
+ if (PokedexFeatureFlags.UseCoil) {
+ AsyncImage(
+ modifier = modifier,
+ contentDescription = pokemon.name,
+ model =
+ ImageRequest.Builder(LocalContext.current)
+ .data(pokemon.imageUrl)
+ .crossfade(PokemonCardImageCrossfadeDurationMillis)
+ .build(),
+ contentScale = ContentScale.Inside,
+ placeholder = painterResource(id = R.drawable.pokemon_preview),
+ )
+ } else {
+ GlideImage(
+ modifier = modifier,
+ contentDescription = pokemon.name,
+ model = pokemon.imageUrl,
+ contentScale = ContentScale.Inside,
+ transition = CrossFade(tween(PokemonCardImageCrossfadeDurationMillis)),
+ loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
+ }
+}
+
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@@ -249,3 +278,4 @@
}
private const val PaginationBufferSize = 8
+private const val PokemonCardImageCrossfadeDurationMillis = 250
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
index 4613103..bc9c595 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
@@ -26,6 +26,10 @@
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.rememberNavController
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.designsystem.theme.PokedexTheme
import com.skydoves.pokedex.compose.core.navigation.AppComposeNavigator
import com.skydoves.pokedex.compose.core.navigation.LocalComposeNavigator
@@ -48,9 +52,27 @@
Trace.endSection()
onDispose { ModuleLocator.detach() }
}
+ if (PokedexFeatureFlags.UseCoil) {
+ ConfigureCoil()
+ }
val navHostController = rememberNavController()
LaunchedEffect(Unit) { composeNavigator.handleNavigationCommands(navHostController) }
PokedexNavHost(navHostController = navHostController)
}
}
}
+
+@Composable
+private fun ConfigureCoil() {
+ setSingletonImageLoaderFactory { context ->
+ ImageLoader.Builder(context)
+ .components {
+ add(
+ OkHttpNetworkFetcherFactory(
+ callFactory = ModuleLocator.networkModule.okHttpClient.newBuilder().build()
+ )
+ )
+ }
+ .build()
+ }
+}