Jetpack 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)
}

SharedTransitionLayoutModifier.sharedElement

Compose UI 和动画版本1.7.0-alpha07引入了允许你实现共享元素过渡的主要 API, 它们是SharedTransitionLayoutModifier.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的示例数据类, 其中包括nameimage的属性. 然后, 创建一个模拟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.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()
          )
        }
      }
    }
  }
}

总结一下

在本文中, 你已经学会了如何使用各种示例实现共享元素转场和容器转换. Jetpack Compose 的发展令人印象深刻, 它让我们可以轻松创建复杂的动画. 这两种类型的动画都能使屏幕导航更直观, 更动态, 从而大大提升用户体验. 不过, 谨慎使用这些动画也很重要. 适当而不是过度地使用它们, 可以确保自然而吸引人的用户体验.

祝你编码愉快!
如果你看到了这里,觉得文章写得不错就给个赞呗?
更多Android进阶指南 可以扫码 解锁更多Android进阶资料


在这里插入图片描述
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )

  • 30
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值