共享元素转场或容器转换是在两个UI元素之间建立视觉联系的动画, 可显著增强应用程序的美感和用户体验. 通过实现屏幕之间的无缝转场和整合, 共享元素转场有助于保持用户在应用程序中的参与度和空间感.
使用共享元素转场动画可让用户将注意力集中在重要元素上, 从而减少认知负荷和困惑, 提升整体用户体验. 这些动画使应用程序导航更加直观, 并赋予动态和吸引人的感觉, 从而大大提高了交互质量.
在 Jetpack Compose 中, 可以使用 LookaheadScope或Orbital等库实现共享元素转场. 不过, 将这些动画与 Compose Navigation集成仍存在一些限制.
幸运的是,1.7.0-alpha07
版的 Compose UI 为共享元素转场引入了新的 API. 在本文中, 你将了解如何使用最新版本的 Compose UI 在各种用例中无缝实现共享元素转场和容器转换.
依赖配置
要使用新的共享元素转场 API, 请确保你使用的是最新版本的 Jetpack Compose UI 和动画(1.7.0-alpha07
之后), 如下所示:
dependencies {
implementation(androidx.compose.ui:ui:1.7.0-alpha07)
implementation(androidx.compose.animation:animation:1.7.0-alpha07)
}
SharedTransitionLayout
和Modifier.sharedElement
Compose UI 和动画版本1.7.0-alpha07
引入了允许你实现共享元素转场的主要 API, 它们是SharedTransitionLayout
和Modifier.sharedElement
:
SharedTransitionLayout
: 该可组合元素作为一个容器, 提供了SharedTransitionScope
功能, 从而可以使用Modifier.sharedElement
以及其他相关的 API. 共享元素转场的核心功能都在此可组合元素中实现. 在此之下,SharedTransitionScope
利用了LookaheadScope API 来促进这些转换. 不过, 我们没有必要详细了解LookaheadScope
, 因为新的 API 可以有效地封装这种复杂性.Modifier.sharedElement
: 该Modifier
可确定SharedTransitionLayout
中的哪些可组合元素应与同一SharedTransitionScope
中的另一个可组合元素进行转换. 它有效地标记了参与共享元素转场的元素.
现在, 让我们看看如何利用这两个 API 的示例:
SharedTransitionLayout {
var isExpanded by remember { mutableStateOf(false) }
val boundsTransform = { _: Rect, _: Rect -> tween<Rect>(550) }
AnimatedContent(targetState = isExpanded) { target ->
if (!target) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(6.dp)
.clickable {
isExpanded = !isExpanded
}
) {
Image(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "image"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = boundsTransform,
)
.size(130.dp),
painter = painterResource(id = R.drawable.pokemon_preview),
contentDescription = null
)
Text(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "name"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = boundsTransform,
)
.fillMaxWidth()
.padding(12.dp),
text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. ",
fontSize = 12.sp,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.clickable {
isExpanded = !isExpanded
}
) {
Image(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "image"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = boundsTransform,
)
.fillMaxWidth()
.height(320.dp),
painter = painterResource(id = R.drawable.pokemon_preview),
contentDescription = null
)
Text(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "name"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = boundsTransform,
)
.fillMaxWidth()
.padding(21.dp),
text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. ",
fontSize = 12.sp,
)
}
}
}
}
Let’s examine the example in detail. The Row
contains an image and text displayed horizontally. When you click on the Row
, it transforms into a Column
where the image and text are arranged vertically. You may have noticed that the sharedElement
modifier is used within the SharedTransitionLayout
. It receives the following three parameters:
让我们详细看看这个示例. Row
包含横向显示的图像和文本. 点击Row
后, 它将转变为Column
, 其中的图片和文本垂直排列. 你可能已经注意到, SharedTransitionLayout
中使用了Modifier.sharedElement
. 它接收以下三个参数:
state
:SharedContentState
设计用于允许访问sharedBounds
/sharedElement
的属性, 例如是否在SharedTransitionScope
中找到了相同键的匹配. 你可以使用rememberSharedContentState
API 创建一个SharedContentState
实例. 提供一个key
, 用于标识动画期间应与哪个组件匹配. 此关键字可确保转场发生时链接到正确的组件.animatedVisibilityScope
: 此参数根据animatedVisibilityScope
的目标状态定义共享元素的边界. 它可以与NavGraphBuilder.composable
函数集成, 从而与 Compose navigation 无缝协作. 我们将在后面的章节中对此进行更详细的探讨.boundsTransform
: 该 lambda 函数接收并返回一个FiniteAnimationSpec
, 用于为共享元素转场应用适当的动画规范.
运行上述代码后, 你将看到如下结果:
带有导航功能的共享元素转场
新的共享元素转场 API 与Compose Navigation库兼容. 通过这一增强功能, 你可以在位于不同导航图中的 Composable 函数之间实现共享元素转场, 从而在整个应用程序中实现更流畅的导航流.
让我们通过创建两个简单的屏幕来探索如何将共享元素转场与导航库整合在一起:一个主屏幕(包括一个列表)和一个详细信息屏幕. 这将演示如何使用LazyColum
在这两个不同的导航图之间平滑转场元素.
首先, 你应设置一个带有空可组合屏幕的NavHost
, 如下例所示:
@Composable
fun NavigationComposeShared() {
SharedTransitionLayout {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable(route = "home") {
}
composable(
route = "details/{pokemon}",
arguments = listOf(navArgument("pokemon") { type = NavType.IntType })
)
{ backStackEntry ->
}
}
}
}
要使用导航库实现共享元素转场, 必须将NavHost
包含在SharedTransitionLayout
中. 这种设置可确保在不同的导航目的地正确处理共享元素转场.
然后, 定义一个名为Pokemon
的示例数据类, 其中包括name
和image
的属性. 然后, 创建一个模拟Pokemon
数据列表, 如下例所示:
data class Pokemon(
val name: String,
@DrawableRes val image: Int
)
SharedTransitionLayout {
val pokemons = remember {
listOf(
Pokemon("Pokemon1", R.drawable.pokemon_preview),
Pokemon("Pokemon2", R.drawable.pokemon_preview),
Pokemon("Pokemon3", R.drawable.pokemon_preview),
Pokemon("Pokemon4", R.drawable.pokemon_preview)
)
}
val boundsTransform = { _: Rect, _: Rect -> tween<Rect>(1400) }
..
接下来, 让我们来实现主屏幕的Composable, 其中包含一个pokemon列表. 列表中的每个项目都将显示为一行, 其中包含横向排列的图片和文本:
composable("home") {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(pokemons) { index, item ->
Row(
modifier = Modifier.clickable {
navController.navigate("details/$index")
}
) {
Image(
painter = painterResource(id = item.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image-$index"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
.padding(horizontal = 20.dp)
.size(100.dp)
)
Text(
text = item.name,
fontSize = 18.sp,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "text-$index"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
.align(Alignment.CenterVertically)
)
}
}
}
}
在提供的示例中, 你会注意到图像和文本组件的修改器都使用了Modifier.sharedElement
函数. 每个元素都分配了一个唯一的key
值, 以便在列表中的多个项目中加以区分.
为确保共享元素转场功能正常运行, 为起始屏幕中的元素分配的特定key
值必须与导航流中目的地屏幕上相应元素所使用的key
值相匹配. 这种匹配对于在浏览不同屏幕时实现可组合元素之间的无缝转场至关重要.
最后, 让我们来实现详细信息屏幕. 该屏幕将简单地显示图片和文字. 此外, 它还包括在点击屏幕时返回主屏幕的功能:
composable(
route = "details/{pokemon}",
arguments = listOf(navArgument("pokemon") { type = NavType.IntType })
) { backStackEntry ->
val pokemonId = backStackEntry.arguments?.getInt("pokemon")
val pokemon = pokemons[pokemonId!!]
Column(
Modifier
.fillMaxSize()
.clickable {
navController.navigate("home")
}) {
Image(
painterResource(id = pokemon.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.fillMaxWidth()
.sharedElement(
rememberSharedContentState(key = "image-$pokemonId"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
)
Text(
pokemon.name, fontSize = 18.sp, modifier =
Modifier
.fillMaxWidth()
.sharedElement(
rememberSharedContentState(key = "text-$pokemonId"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
)
}
}
因此, 整个代码将如下所示:
@Composable
fun NavigationComposeShared() {
SharedTransitionLayout {
val pokemons = remember {
listOf(
Pokemon("Pokemon1", R.drawable.pokemon_preview),
Pokemon("Pokemon2", R.drawable.pokemon_preview),
Pokemon("Pokemon3", R.drawable.pokemon_preview),
Pokemon("Pokemon4", R.drawable.pokemon_preview)
)
}
val boundsTransform = { _: Rect, _: Rect -> tween<Rect>(1400) }
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(pokemons) { index, item ->
Row(
modifier = Modifier.clickable {
navController.navigate("details/$index")
}
) {
Image(
painter = painterResource(id = item.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image-$index"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
.padding(horizontal = 20.dp)
.size(100.dp)
)
Text(
text = item.name,
fontSize = 18.sp,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "text-$index"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
.align(Alignment.CenterVertically)
)
}
}
}
}
composable(
"details/{pokemon}",
arguments = listOf(navArgument("pokemon") { type = NavType.IntType })
) { backStackEntry ->
val pokemonId = backStackEntry.arguments?.getInt("pokemon")
val pokemon = pokemons[pokemonId!!]
Column(
Modifier
.fillMaxSize()
.clickable {
navController.navigate("home")
}) {
Image(
painterResource(id = pokemon.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.fillMaxWidth()
.sharedElement(
rememberSharedContentState(key = "image-$pokemonId"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
)
Text(
pokemon.name, fontSize = 18.sp, modifier =
Modifier
.fillMaxWidth()
.sharedElement(
rememberSharedContentState(key = "text-$pokemonId"),
animatedVisibilityScope = this@composable,
boundsTransform = boundsTransform
)
)
}
}
}
}
}
运行所提供的示例代码后, 你将看到以下结果:
如果你有兴趣了解真实世界的使用案例, 可以浏览 GitHub 上的 Pokedex-Compose开源项目, 该项目演示了共享元素转场 API 的实际应用.
用Modifier.sharedBounds
进行容器转换
现在, 让我们来探索一下容器转换. Modifier.sharedBounds()
类似于Modifier.sharedElement()
, 但有一个关键区别: Modifier.sharedBounds()
适用于在不同转换中视觉效果不同的内容, 而Modifier.sharedElement()
则用于视觉效果保持一致的内容, 如图片. 这种区别在容器转换模式等情况下尤其有用.
使用前面的示例实现容器转换非常简单. 你可以移除Modifier.sharedElement()
函数, 并在 Composable 树的根层次结构中添加Modifier.sharedBounds()
. 通过这种修改, UI组件中视觉上不同的元素之间可以实现转换.
让我们像下面这样调整上一节的代码:
@Composable
fun NavigationComposeShared() {
SharedTransitionLayout {
val pokemons = remember {
listOf(
Pokemon("Pokemon1", R.drawable.pokemon_preview),
Pokemon("Pokemon2", R.drawable.pokemon_preview),
Pokemon("Pokemon3", R.drawable.pokemon_preview),
Pokemon("Pokemon4", R.drawable.pokemon_preview)
)
}
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(pokemons) { index, item ->
Row(
modifier = Modifier.clickable {
navController.navigate("details/$index")
}
.sharedBounds(
rememberSharedContentState(key = "pokemon-$index"),
animatedVisibilityScope = this@composable,
)
.fillMaxWidth()
) {
Image(
painter = painterResource(id = item.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(horizontal = 20.dp)
.size(100.dp)
)
Text(
text = item.name,
fontSize = 18.sp,
modifier = Modifier
.align(Alignment.CenterVertically)
)
}
}
}
}
composable(
"details/{pokemon}",
arguments = listOf(navArgument("pokemon") { type = NavType.IntType })
) { backStackEntry ->
val pokemonId = backStackEntry.arguments?.getInt("pokemon")
val pokemon = pokemons[pokemonId!!]
Column(
Modifier
.fillMaxWidth()
.clickable {
navController.navigate("home")
}
.sharedBounds(
rememberSharedContentState(key = "pokemon-$pokemonId"),
animatedVisibilityScope = this@composable,
)
) {
Image(
painterResource(id = pokemon.image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.fillMaxWidth()
)
Text(
pokemon.name, fontSize = 18.sp, modifier =
Modifier
.fillMaxWidth()
)
}
}
}
}
}
如果检查细节, 你会发现主页中的Row
和详情页中的Column
都使用了Modifier.sharedElement()
, 如上例所示. 这就是全部内容! 运行代码后, 你将看到如下所示的动画效果:
总结一下
在本文中, 你已经学会了如何使用各种示例实现共享元素转场和容器转换. Jetpack Compose 的发展令人印象深刻, 它让我们可以轻松创建复杂的动画. 这两种类型的动画都能使屏幕导航更直观, 更动态, 从而大大提升用户体验. 不过, 谨慎使用这些动画也很重要. 适当而不是过度地使用它们, 可以确保自然而吸引人的用户体验.
同样, 如果你有兴趣了解真实世界的使用案例, 可以访问 GitHub 上的Pokedex-Compose开源项目, 该项目展示了共享元素转场 API 和几个 Jetpack 库的实际应用.
祝你编码愉快!