⛰️

[Android] 海外のアプリで見かけたかっこいいTransitionを真似てみる

に公開

背景

citizenMという海外のアプリでかっこいいTransitionを見かけたので真似してみることにしました。
今回実装するUIには以下の観点があると思うので、それぞれ順番に説明していきます。

  1. 詳細画面が右からスライドする形で画面遷移する
  2. 画面遷移時に画像が移動して少し大きくなる

趣味が登山なので、今回は山をテーマにしたものを作ります。
実際に作ったのはこのようなものです。細かいところは雑ですが今回の実装の詳細をGitHubに置いています。

1.詳細画面が右からスライドする形で画面遷移する

MountainScreenMountainDetailScreenの画面遷移をNavigationComposeで実装します。
それぞれの画面の画像部分は後述するように少し特殊なことをしているのですが、それ以外はLazyColumnなどを用いたシンプルな実装にしています。

まず、横にスライドする画面遷移ですが、NavHostenterTransitionexitTransitionを設定することで実現できます。

...
composable<MountainDetailRoute>(
    enterTransition = {
        slideInHorizontally(
            initialOffsetX = { it },
            animationSpec = tween(durationMillis = AnimationDurationMilliSeconds)
        ) + fadeIn(animationSpec = tween(durationMillis = AnimationDurationMilliSeconds))
    },
    exitTransition = {
        slideOutHorizontally(
            targetOffsetX = { it },
            animationSpec = tween(durationMillis = AnimationDurationMilliSeconds)
        ) + fadeOut(animationSpec = tween(durationMillis = AnimationDurationMilliSeconds))
    }
)
...

次に、詳細画面で表示するデータを渡す部分について説明します。
Googleはidなどの簡易な情報を渡して単一情報源からfetchするのを推奨しているようですが、今回はすたぜろさんの記事を参考にdata classを渡すやり方をやってみます。

まず、@Serializable@Parcelizeをつけたdata classを定義します。

@Serializable
@Parcelize
data class Mountain(
    val id: Int,
    val name: String,
    val area: String,
    val description: String,
    @DrawableRes val imageRes: Int,
    @DrawableRes val iconRes: Int,
) : Parcelable

次に、Custom NavTypeを定義してNavigationComposeで渡せるようにします。

object MountainNavType : NavType<Mountain>(
    isNullableAllowed = false
) {
    override fun put(bundle: Bundle, key: String, value: Mountain) {
        bundle.putParcelable(key, value)
    }

    override fun get(bundle: Bundle, key: String): Mountain {
        return requireNotNull(BundleCompat.getParcelable(bundle, key, Mountain::class.java))
    }

    override fun serializeAsValue(value: Mountain): String {
        return Uri.encode(Json.encodeToString(value))
    }

    override fun parseValue(value: String): Mountain {
        return Json.decodeFromString(value)
    }
}

最後に、NavHostで先ほどのCustom NavTypeを適用すると完了です。
Animationも含めた全体像は次のようになります。

@Serializable
data object MountainRoute

@Serializable
data class MountainDetailRoute(
    val mountain: Mountain,
)

fun NavController.navigateToMountainDetail(mountain: Mountain, navOptions: NavOptions? = null) {
    navigate(route = MountainDetailRoute(mountain = mountain), navOptions = navOptions)
}

@Composable
fun SharedElementTransitionNavHost() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = MountainRoute,
    ) {
        composable<MountainRoute> {
            MountainScreen(
                onMountainClick = { mountain ->
                    navController.navigateToMountainDetail(mountain = mountain)
                },
            )
        }

        composable<MountainDetailRoute>(
            typeMap = mapOf(
                typeOf<Mountain>() to MountainNavType,
            ),
            enterTransition = {
                slideInHorizontally(
                    initialOffsetX = { it },
                    animationSpec = tween(durationMillis = AnimationDurationMilliSeconds)
                ) + fadeIn(animationSpec = tween(durationMillis = AnimationDurationMilliSeconds))
            },
            exitTransition = {
                slideOutHorizontally(
                    targetOffsetX = { it },
                    animationSpec = tween(durationMillis = AnimationDurationMilliSeconds)
                ) + fadeOut(animationSpec = tween(durationMillis = AnimationDurationMilliSeconds))
            }
        ) { backStackEntry ->
            val mountain = backStackEntry.toRoute<MountainDetailRoute>().mountain
            MountainDetailScreen(
                mountain = mountain,
                onBackPressed = {
                    navController.popBackStack()
                },
            )
        }
    }
}

2.画面遷移時に画像が移動して少し大きくなる

Shared Element Transitionsを用いて実現します。
Shared Element TransitionsについてはhyogaさんのDroidKaigiでの発表がわかりやすかったので、詳細な説明は割愛します。

方針としては以下のようになります。

  1. NavigationComposeSharedTransitionScopeAnimatedVisibilityScopeをScreenに渡す
  2. 画面遷移時にAnimationをかけたいComposable(今回だと画像)のmodifiersharedElementを設定

MountainScreenMountainDetailScreenの両方のsharedElementに同じキーを設定しておくことで、画面遷移時にAnimationさせることができます。

Shared Element Transitions実装の前準備

DroidKaigiの発表に倣って、実装を楽にするための前準備をします。

  1. NavigationComposeSharedTransitionScopeAnimatedVisibilityScopeをScreenに渡す

愚直にNavHostから引数として渡していく場合、Composableを切り分けていればいるほど子から子へと何回も渡す必要があり大変になります。
そこで、CompositionLocalProviderを設定しておきます。
これにより、LocalSharedTransitionScope.current, LocalAnimatedVisibilityScope.currentとして簡単にアクセスできるようになります。

val LocalAnimatedVisibilityScope = staticCompositionLocalOf<AnimatedVisibilityScope> {
    error("No AnimatedVisibilityScope provided")
}

@Composable
fun AnimatedVisibilityScopeProvider(
    animatedVisibilityScope: AnimatedVisibilityScope,
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalAnimatedVisibilityScope provides animatedVisibilityScope,
    ) {
        content()
    }
}
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = staticCompositionLocalOf<SharedTransitionScope> {
    error("No SharedTransitionScope provided")
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SharedTransitionScopeProvider(
    content: @Composable () -> Unit,
) {
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this,
        ) {
            content()
        }
    }
}

  1. 画面遷移時にAnimationをかけたいComposable(今回だと画像)のmodifiersharedElementを設定

modifierに設定するときには毎回以下のようなコードを書く必要があります。

with(LocalSharedTransitionScope.current) {
    ...
    modifier = Modifier
        .sharedElement(
            state = rememberSharedContentState(key = key),
            animatedVisibilityScope = LocalAnimatedVisibilityScope.current,
        )
        ...

毎回同じようなコードを書くのは大変ですが、変わるのはキーの部分だけになるので、キーだけを受け取るようなModifierの拡張関数を定義しておきます。

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.easySharedElement(key: Any): Modifier {
    with(LocalSharedTransitionScope.current) {
        return this@easySharedElement.sharedElement(
            state = rememberSharedContentState(key = key),
            animatedVisibilityScope = LocalAnimatedVisibilityScope.current
        )
    }
}

Shared Element Transitionsの適用

まず、NavHostSharedTransitionScopeProviderAnimatedVisibilityScopeProviderを設定します。

kotlin
@Composable
fun SharedElementTransitionNavHost() {
+    SharedTransitionScopeProvider {
        val navController = rememberNavController()
        NavHost(
            navController = navController,
            startDestination = MountainRoute,
        ) {
            composable<MountainRoute> {
+                AnimatedVisibilityScopeProvider(animatedVisibilityScope = this) {
                    ...
                }
            }

            composable<MountainDetailRoute>(
                ...
            ) { backStackEntry ->
+                AnimatedVisibilityScopeProvider(animatedVisibilityScope = this) {
                    ...
                }
            }
        }
    }
}

最後に、easySharedElementをAnimationさせたいCompposableに設定します。
今回はメインとなる山の画像と山のアイコンの2つに設定します。
画像の高さとcategoryIconのサイズをMountainScreenMountainDetailScreenで変えることによって、Transition後にちょっとだけ大きくなるようにします。

@Composable
fun ImageWithCategoryIcon(
    sharedTransitionImageKey: String,
    sharedTransitionCategoryKey: String,
    @DrawableRes imageRes: Int,
    @DrawableRes iconRes: Int,
    modifier: Modifier = Modifier,
    imageHeight: Dp = 200.dp,
    categoryIconSize: Dp = 64.dp,
) {
    Column(modifier = modifier) {
        Image(
            painter = painterResource(imageRes),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .easySharedElement(key = sharedTransitionImageKey)
                .fillMaxWidth()
                .height(imageHeight)
        )

        CategoryIcon(
            sharedTransitionKey = sharedTransitionCategoryKey,
            iconRes = iconRes,
            categoryIconSize = categoryIconSize,
            modifier = Modifier.offset(x = 16.dp, y = (-32).dp)
        )
    }
}

@Composable
fun CategoryIcon(
    sharedTransitionKey: String,
    @DrawableRes iconRes: Int,
    categoryIconSize: Dp,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
            .easySharedElement(key = sharedTransitionKey)
            .size(categoryIconSize)
            .background(Color.DarkGray)
    ) {
        Icon(
            painter = painterResource(id = iconRes),
            contentDescription = "mountain",
            tint = Color.White,
            modifier = Modifier
                .size(categoryIconSize * 0.6f)
                .align(Alignment.BottomStart)
                .padding(8.dp),
        )
    }
}

まとめ

今回はShared Element Transitionsを用いた画面遷移をやってみました。
最近のアプリは面白いUIが少ない気がするので、ユーザー体験を悪くしない範囲で遊びが欲しいな~と思っています。余談ですが、今回使った画像はすべて自分で撮影したものなので、当時を思い出しながら楽しく実装できました。

参考

https://developer.android.com/develop/ui/compose/animation/shared-elements
https://2024.droidkaigi.jp/timetable/693259/
https://star-zero.medium.com/safe-argsでcustom-navtypeを使う-037e1eb805c9

Discussion