最终效果
知识点
- 如何使用
sharedBounds()和sharedElement()为两个页面之间添加过渡效果 sharedBounds()和sharedElement()的区别AnimatedVisibilityScope的使用- 使用
resizeMode改变共享元素过渡时测量子元素大小的方式 - 使用
clipInOverlayDuringTransition限制子元素在过渡过程中不会渲染出边界
前期准备
步骤一:增加电影详情页MovieDetail
在点击电影卡片MovieCard的海报图片Image之后,会展开电影详情页面MovieDetail,这个页面目前还没有实现,详情页面中包含的内容有:
- 电影海报
- 电影名称
- 演员列表
- 电影描述
- 购买按钮
除了演员列表和电影描述之外,其余的组件在电影卡片MovieCard也有出现,共同拥有的这部分是页面过渡的关注点,要想两个页面的过渡看起来流畅,元素在视觉上的连贯很重要,这也是接下来需要实现的。目前我们先关注创建MovieDetail页面:
@Composable
fun MovieDetail(
movie: MovieItem,
onClickImage: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize().background(Color.White)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxSize()
.verticalScroll(scrollState)
) {
Image( // 电影海报
painter = painterResource(movie.resId),
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClickImage() // 点击海报后,关闭详情页面,回到卡片页面
}
.fillMaxWidth()
.height(520.dp),
contentScale = ContentScale.FillBounds,
contentDescription = null
)
Text( // 电影名称
modifier = Modifier
.padding(top = 20.dp, bottom = 20.dp)
.align(Alignment.CenterHorizontally),
text = movie.name,
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold)
)
// 电影详细内容(演员列表,电影故事线)
DetailContent(movie.description)
}
val context = LocalContext.current
BookNow( // 购买按钮
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }) {
Toast
.makeText(context, "Book Now", Toast.LENGTH_SHORT)
.show()
}
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(50.dp))
.background(Color.Black)
)
}
}
/**
* 购买按钮
*/
@Composable
fun BookNow(modifier: Modifier = Modifier) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(
text = "BOOK NOW",
color = Color.White,
fontSize = 12.sp
)
}
}
/**
* 电影详细内容(演员列表,电影故事线)
*/
@Composable
fun DetailContent(movieDescription: String) {
Column(modifier = Modifier.padding(top = 16.dp, bottom = 100.dp)) {
DetailSubTitle("Cast")
Cast()
DetailSubTitle("Storyline")
Text(
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.wrapContentHeight(), text = movieDescription,
color = Color.Gray
)
}
}
/**
* 电影详细内容标题
*/
@Composable
fun DetailSubTitle(text: String) {
Text(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
text = text,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.DarkGray
)
)
}
/**
* 演员列表
*/
@Composable
fun Cast() {
Row(
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.height(100.dp)
.horizontalScroll(rememberScrollState())
) {
Box(
modifier = Modifier
.padding(end = 24.dp)
.fillMaxHeight()
.width(100.dp)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(10.dp)
}
.background(Color.LightGray)
)
Box(
modifier = Modifier
.padding(end = 24.dp)
.fillMaxHeight()
.width(100.dp)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(10.dp)
}
.background(Color.LightGray)
)
Box(
modifier = Modifier
.padding(end = 24.dp)
.fillMaxHeight()
.width(100.dp)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(10.dp)
}
.background(Color.LightGray)
)
}
}
完成MovieDetail之后,增加与MovieCard的交互逻辑:点击海报图片之后,MovieCard和MovieDetail可以互相切换:
@Composable
fun MovieCard(
modifier: Modifier,
page: Int,
movie: MovieItem,
onClickImage: () -> Unit // ADD
) {
// ...
Image( // 电影海报
painter = painterResource(movie.resId),
contentDescription = "",
contentScale = ContentScale.FillBounds,
modifier = Modifier
.clickable( // ADD
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClickImage() // ADD
}
.fillMaxWidth()
.height(290.dp)
.clip(RoundedCornerShape(100.dp))
)
// ...
}
@Composable
fun MainPage(modifier: Modifier = Modifier) {
...
var isExpanded by remember { mutableStateOf(false) } // ADD
if (!isExpanded) { // ADD
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxSize(),
verticalAlignment = Alignment.Bottom,
pageSpacing = 20.dp,
contentPadding = PaddingValues(horizontal = 50.dp)
) { page ->
val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
MovieCard(
... ,
onClickImage = { // ADD
isExpanded = !isExpanded // ADD
}
)
}
} else {
MovieDetail(movieData[pagerState.currentPage]) {
isExpanded = !isExpanded // ADD
}
}
}
步骤二:给MovieCard和MovieDetail增加页面过渡
前面我们提到,不同界面的相同元素过渡的连贯,会影响视觉上的连贯和流畅性,如果只是点击然后立马弹出新页面,显得有些生硬,我也想过为什么不在点击海报图片之后,直接修改MovieCard,使它的页面长宽扩大、圆角缩小,但这样做始终都只保持在一个页面,我想要电影详情页是独立的页面,与MovieCard无关的页面。
如果想在两个页面转换之间增加过渡,可以使用Google在Compose 1.7.0 新增的Shared Element功能,在1.7.0版本之前,这只是个实验功能,使用时需要在Composable方法前增加@OptIn(ExperimentalSharedTransitionApi::*class*) 注解。
在使用共享元素之前面临一个选择,是使用sharedBounds() 还是sharedElement()方法?官方文档中有这样一句话:
sharedBounds()is for content that is visually different but should share the same area between states, whereassharedElement()expects the content to be the same.
Shared bounds 和 shared element,前者希望内容改变,但装载内容的区域不会改变,后者希望内容一直保持不变,所以关键的地方在于这个内容物Content在视图转换之后是否改变。根据这个规则可以得出哪个组件使用sharedBounds(),哪个使用sharedElement():
- MovieCard和MovieDetail的Container:sharedBounds()
- 电影海报Image:sharedElement()
- 电影名称Text:sharedElement()
- 购买按钮BookNow:sharedElement()
但是Google又针对Text增加了一条说明:
When using
Textcomposables,sharedBounds()is preferred to support font changes such as transitioning between italic and bold or color changes.
对于文本,更推荐使用sharedBounds(),因为文字可能会有字体粗细、颜色等变化,所以电影名称Text,我们将会使用sharedBounds()。
1. SharedTransitionLayout & AnimatedContent
针对需要过渡的layouts,使用SharedTransitionLayout和AnimatedContent包围起来,再将发生过渡的状态标志isExpanded传递给AnimatedContent :
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainPage(modifier: Modifier = Modifier) {
...
var isExpanded by remember { mutableStateOf(false) }
SharedTransitionLayout { // ADD
AnimatedContent( // ADD
targetState = isExpanded,
label = "basic transition"
) { targetState ->
if (!targetState) {
MovieCard(...)
} else {
MovieDetail(...)
}
}
}
...
}
在SharedTransitionLayout和AnimatedContent的作用域下,将两者作为参数传递MovieDetail:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainPage(modifier: Modifier = Modifier) {
...
var isExpanded by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(
targetState = isExpanded,
label = "basic transition"
) { targetState ->
if (!targetState) {
MovieCard(...)
} else {
MovieDetail(
...,
sharedTransitionScope = this@SharedTransitionLayout, // ADD
animatedVisibilityScope = this@AnimatedContent, // ADD
)
}
}
}
...
}
在MovieDetail内部,将需要过渡的内容放在SharedTransitionScope的作用域下,只有在这个作用域中Modifier才能使用sharedBounds()和sharedElement() :
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MovieDetail(
movie: MovieItem,
sharedTransitionScope: SharedTransitionScope, // ADD
animatedVisibilityScope: AnimatedVisibilityScope, // ADD
onClickImage: () -> Unit,
) {
with(sharedTransitionScope) { // ADD
...
}
}
对于MovieCard,因为它的Modifier是从外部传递进去的,而且在MainPage中,MovieCard已经在SharedTransitionLayout和AnimatedContent作用域内了,所以这里没必要修改MovieCard内部,也不必将SharedTransitionLayout和AnimatedContent作为参数传递。
2. Modifier.sharedBounds()
接下来分别针对MovieCard和MovieDetail需要过渡的layouts增加sharedBounds():
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainPage(modifier: Modifier = Modifier) {
...
var isExpanded by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(
targetState = isExpanded,
label = "basic transition"
) { targetState ->
if (!targetState) {
HorizontalPager(...) { page ->
val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
MovieCard(
modifier = Modifier
.padding(bottom = lerp(96.dp, 56.dp, pageOffset.absoluteValue))
.width(260.dp)
.height(480.dp)
.sharedBounds( // ADD
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = this@AnimatedContent,
)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(130.dp)
shadowElevation = 30f
spotShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
ambientShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
scaleY = lerp(1f, 0.9f, pageOffset.absoluteValue)
}
.background(color = Color.White)
.padding(top = 32.dp, start = 32.dp, end = 32.dp),
...
)
}
} else {
MovieDetail(...)
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MovieDetail(
page: Int, // ADD
movie: MovieItem,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClickImage: () -> Unit,
) {
with(sharedTransitionScope) {
Box(
modifier = Modifier
.sharedBounds( // ADD
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = animatedVisibilityScope,
)
.fillMaxSize()
.background(Color.White)
) {
...
}
}
}
每个sharedBounds()和sharedElement()都有一个唯一的key,相同的元素应该使用同一个key,这样才能正确地产生过渡效果。并且sharedBounds()和sharedElement()在Modifier链中的顺序不是随意的,sharedBounds()和sharedElement()之前的Modifier链是不需要产生过渡效果的,在它们后面的才会产生过渡效果。
为了更好地看出页面过渡时的变化,将动画时间调整更长些:
const val animationDuration = 2000
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _,_ -> tween(animationDuration) }, // ADD
)
步骤三:给页面Box和电影海报Image增加圆角过渡
Shared Element可以帮我们做大小、位置、颜色的过渡,但是像形状和圆角等过渡需要自己设置。这里可以利用AnimatedVisibilityScope,它持有的transition可以帮助我们在过渡过程中自定义动画。在layout过渡时,有三种状态:
- EnterExitState.PreEnter
- EnterExitState.Visible
- EnterExitState.PostExit
在点击MovieCard时,MovieCard处于Exit transition,MovieDetail处于Enter transition:
| currentState | targetState | |
|---|---|---|
| MovieCard | Visible | PostExit |
| MovieDetail | PreEnter | Visible |
所以可以根据处于不同transition时的状态变化去定义一个圆角动画:
fun MainPage(modifier: Modifier = Modifier) {
...
// ADD
val cardCornerAnimation by
(this@AnimatedContent as AnimatedVisibilityScope).transition.animateDp(
label = "movie card corner animation",
transitionSpec = {
tween(animationDuration)
}) { state: EnterExitState ->
when (state) {
EnterExitState.PreEnter -> 0.dp
EnterExitState.Visible -> 130.dp
EnterExitState.PostExit -> 0.dp
}
}
MovieCard(
modifier = Modifier
.padding(bottom = lerp(96.dp, 56.dp, pageOffset.absoluteValue))
.width(260.dp)
.height(480.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = { _, _ -> tween(animationDuration) }
)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(cardCornerAnimation) // ADD
shadowElevation = 30f
spotShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
ambientShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
scaleY = lerp(1f, 0.9f, pageOffset.absoluteValue)
}
.background(color = Color.White)
.padding(top = 32.dp, start = 32.dp, end = 32.dp),
page = page,
movie = movieData[page],
onClickImage = {
isExpanded = !isExpanded
}
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MovieDetail(...) {
// ADD
val detailPageCornerAnimation by
animatedVisibilityScope.transition.animateDp(
label = "movie detail corner animation",
transitionSpec = {
tween(animationDuration)
}) { state: EnterExitState ->
when (state) {
EnterExitState.PreEnter -> 130.dp
EnterExitState.Visible -> 0.dp
EnterExitState.PostExit -> 130.dp
}
}
with(sharedTransitionScope) {
Box(
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> tween(animationDuration) },
)
.fillMaxSize()
.clip(RoundedCornerShape(detailPageCornerAnimation)) // ADD
.background(Color.White)
) {
...
}
}
}
给海报图片增加圆角动画也是相同的操作:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MovieCard(...,
animatedVisibilityScope: AnimatedVisibilityScope, // ADD
sharedTransitionScope: SharedTransitionScope // ADD
) {
// ADD
val posterCornerAnimation by animatedVisibilityScope.transition.animateDp(
label = "movie poster corner animation",
transitionSpec = {
tween(animationDuration)
}) { state: EnterExitState ->
when (state) {
EnterExitState.PreEnter -> 0.dp
EnterExitState.Visible -> 100.dp
EnterExitState.PostExit -> 0.dp
}
}
with(sharedTransitionScope) { // ADD
Box(modifier = modifier) {
Column(...) {
Image(
...,
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClickImage()
}
.sharedElement( // ADD
state = rememberSharedContentState(key = "image$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ ->
tween(durationMillis = animationDuration)
},
)
.fillMaxWidth()
.height(290.dp)
.clip(RoundedCornerShape(posterCornerAnimation)) // ADD
)
Text(...)
}
BookNow(...)
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MovieDetail(...) {
// ADD
val posterCornerAnimation by animatedVisibilityScope.transition.animateDp(
label = "movie poster corner animation",
transitionSpec = {
tween(animationDuration)
}) { state: EnterExitState ->
when (state) {
EnterExitState.PreEnter -> 100.dp
EnterExitState.Visible -> 0.dp
EnterExitState.PostExit -> 100.dp
}
}
with(sharedTransitionScope) {
Box(...) {
val scrollState = rememberScrollState()
Column(...) {
Image( // 电影海报
painter = painterResource(movie.resId),
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClickImage()
}
.sharedElement( // ADD
state = rememberSharedContentState(key = "image$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ ->
tween(durationMillis = animationDuration)
},
)
.fillMaxWidth()
.height(520.dp)
.clip(RoundedCornerShape(posterCornerAnimation)), // ADD
contentScale = ContentScale.FillBounds,
contentDescription = null
)
Text(...)
DetailContent(movie.description)
}
BookNow(...)
}
}
}
可以看到现在的效果有点奇怪,转换的过程中,MovieCard形状在视觉上会突然改变比例,然后才开始过渡,仔细看会发现MovieCard比例会突然改变成MovieDetail的比例,这是resizeMode 导致的,默认情况resizeMode的值是ScaleToBounds ,系统会使用目标布局(MovieDetail)的尺寸去测量当前布局(MovieCard),然后对当前布局进行缩放,这种方式保证在过渡过程中不会重新触发测量布局,避开了一些性能开销,但它不适用于我们当前这个情况。
还有一种resizeMode是RemeasureToBounds ,在过渡过程中的每一帧都可能会重新测量布局,也就不会造成一开始的“比例突变”情况。这里给出Google文档的关于两种测量模式的描述,可以根据原文理解一下:
ScaleToBoundsfirst measures the child layout with the lookahead (or target) constraints. Then the child's stable layout is scaled to fit in the shared bounds.ScaleToBoundscan be thought of as a "graphical scale" between the states.
Whereas
RemeasureToBoundsre-measures and re-layouts the child layout ofsharedBoundswith animated fixed constraints based on the target size. The re-measurement is triggered by the bounds size change, which could potentially be every frame.
接下来修改resizeMode:
sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = { _, _ -> tween(animationDuration) },
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, // ADD
)
步骤四:给电影名称Text增加过渡
// MovieCard
Text(
text = movie.name,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterHorizontally)
.sharedBounds( // ADD
sharedContentState = rememberSharedContentState(key = "movie_name$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = {_, _ -> tween(animationDuration) }
)
)
// MovieDetail
Text(
modifier = Modifier
.padding(top = 20.dp, bottom = 20.dp)
.align(Alignment.CenterHorizontally)
.sharedBounds( // ADD
sharedContentState = rememberSharedContentState(key = "movie_name$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = {_, _ -> tween(animationDuration) }
),
text = movie.name,
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold)
)
步骤五:给购买按钮BookNow增加过渡
// MovieCard
BookNow(
modifier = Modifier
.sharedElement( // ADD
state = rememberSharedContentState(key = "booknow$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = {_, _ -> tween(animationDuration)}
)
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(50.dp))
.background(Color.Black)
)
// MovieDetial
val context = LocalContext.current
BookNow( // 购买按钮
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }) {
Toast
.makeText(context, "Book Now", Toast.LENGTH_SHORT)
.show()
}
.padding(bottom = 16.dp)
.sharedElement( // ADD
state = rememberSharedContentState(key = "booknow$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = {_, _ -> tween(animationDuration)}
)
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(50.dp))
.background(Color.Black)
)
购买按钮BookNow在过渡过程中,组件渲染超出了卡片范围,为了解决这个问题,需要在MovieCard和MovieDetail的layout作改动,在它们的sharedBounds()内增加clipInOverlayDuringTransition 去限制一个渲染区域,保证子元素不会超出这个范围。
// MovieCard
Modifier
.padding(bottom = lerp(96.dp, 56.dp, pageOffset.absoluteValue))
.width(260.dp)
.height(480.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = { _, _ -> tween(animationDuration) },
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
// ADD
clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(cardCornerAnimation))
)
// MovieDetail
Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "movie$page"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> tween(animationDuration) },
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
// ADD
clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(detailPageCornerAnimation))
)
.fillMaxSize()
.clip(RoundedCornerShape(detailPageCornerAnimation))
.background(Color.White)