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()
+    }
+}