[Android] 海外のアプリで見かけたかっこいいTransitionを真似てみる
背景
citizenMという海外のアプリでかっこいいTransitionを見かけたので真似してみることにしました。
今回実装するUIには以下の観点があると思うので、それぞれ順番に説明していきます。
- 詳細画面が右からスライドする形で画面遷移する
- 画面遷移時に画像が移動して少し大きくなる
趣味が登山なので、今回は山をテーマにしたものを作ります。
実際に作ったのはこのようなものです。細かいところは雑ですが今回の実装の詳細をGitHubに置いています。
1.詳細画面が右からスライドする形で画面遷移する
MountainScreen
→ MountainDetailScreen
の画面遷移をNavigationCompose
で実装します。
それぞれの画面の画像部分は後述するように少し特殊なことをしているのですが、それ以外はLazyColumn
などを用いたシンプルな実装にしています。
まず、横にスライドする画面遷移ですが、NavHost
でenterTransition
とexitTransition
を設定することで実現できます。
...
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での発表がわかりやすかったので、詳細な説明は割愛します。
方針としては以下のようになります。
-
NavigationCompose
でSharedTransitionScope
とAnimatedVisibilityScope
をScreenに渡す - 画面遷移時にAnimationをかけたいComposable(今回だと画像)の
modifier
にsharedElement
を設定
MountainScreen
とMountainDetailScreen
の両方のsharedElement
に同じキーを設定しておくことで、画面遷移時にAnimationさせることができます。
Shared Element Transitions実装の前準備
DroidKaigiの発表に倣って、実装を楽にするための前準備をします。
NavigationCompose
でSharedTransitionScope
とAnimatedVisibilityScope
を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()
}
}
}
- 画面遷移時にAnimationをかけたいComposable(今回だと画像)の
modifier
にsharedElement
を設定
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の適用
まず、NavHost
にSharedTransitionScopeProvider
とAnimatedVisibilityScopeProvider
を設定します。
@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のサイズをMountainScreen
とMountainDetailScreen
で変えることによって、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が少ない気がするので、ユーザー体験を悪くしない範囲で遊びが欲しいな~と思っています。余談ですが、今回使った画像はすべて自分で撮影したものなので、当時を思い出しながら楽しく実装できました。
参考
Discussion