使用Compose Shared Element实现卡片到详情页的平滑过渡

1,439 阅读6分钟

最终效果

step5-2.gif

知识点

  1. 如何使用sharedBounds()sharedElement()为两个页面之间添加过渡效果
  2. sharedBounds()sharedElement()的区别
  3. AnimatedVisibilityScope的使用
  4. 使用resizeMode 改变共享元素过渡时测量子元素大小的方式
  5. 使用clipInOverlayDuringTransition 限制子元素在过渡过程中不会渲染出边界

前期准备

  1. 电影卡片的实现过程可以参考这篇博客
  2. 设计稿来自于Dribbble的设计师分享

步骤一:增加电影详情页MovieDetail

在点击电影卡片MovieCard的海报图片Image之后,会展开电影详情页面MovieDetail,这个页面目前还没有实现,详情页面中包含的内容有:

  1. 电影海报
  2. 电影名称
  3. 演员列表
  4. 电影描述
  5. 购买按钮

除了演员列表和电影描述之外,其余的组件在电影卡片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
        }
    }

}

step1-1.gif

步骤二:给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, whereas sharedElement() 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 Text composables, sharedBounds() is preferred to support font changes such as transitioning between italic and bold or color changes.

对于文本,更推荐使用sharedBounds(),因为文字可能会有字体粗细、颜色等变化,所以电影名称Text,我们将会使用sharedBounds()

1. SharedTransitionLayout & AnimatedContent

针对需要过渡的layouts,使用SharedTransitionLayoutAnimatedContent包围起来,再将发生过渡的状态标志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(...)
            }
        }
    }
    ...
}

SharedTransitionLayoutAnimatedContent的作用域下,将两者作为参数传递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已经在SharedTransitionLayoutAnimatedContent作用域内了,所以这里没必要修改MovieCard内部,也不必将SharedTransitionLayoutAnimatedContent作为参数传递。

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
        )

step2-1.gif

步骤三:给页面Box和电影海报Image增加圆角过渡

Shared Element可以帮我们做大小、位置、颜色的过渡,但是像形状和圆角等过渡需要自己设置。这里可以利用AnimatedVisibilityScope,它持有的transition可以帮助我们在过渡过程中自定义动画。在layout过渡时,有三种状态:

  • EnterExitState.PreEnter
  • EnterExitState.Visible
  • EnterExitState.PostExit

在点击MovieCard时,MovieCard处于Exit transition,MovieDetail处于Enter transition:

currentStatetargetState
MovieCardVisiblePostExit
MovieDetailPreEnterVisible

所以可以根据处于不同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)
        ) {
            ...
        }
    }
}

step3-1.gif

给海报图片增加圆角动画也是相同的操作:

@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(...)
        }
    }
}

step3-2.gif

可以看到现在的效果有点奇怪,转换的过程中,MovieCard形状在视觉上会突然改变比例,然后才开始过渡,仔细看会发现MovieCard比例会突然改变成MovieDetail的比例,这是resizeMode 导致的,默认情况resizeMode的值是ScaleToBounds ,系统会使用目标布局(MovieDetail)的尺寸去测量当前布局(MovieCard),然后对当前布局进行缩放,这种方式保证在过渡过程中不会重新触发测量布局,避开了一些性能开销,但它不适用于我们当前这个情况。

还有一种resizeModeRemeasureToBounds ,在过渡过程中的每一帧都可能会重新测量布局,也就不会造成一开始的“比例突变”情况。这里给出Google文档的关于两种测量模式的描述,可以根据原文理解一下:

ScaleToBounds first measures the child layout with the lookahead (or target) constraints. Then the child's stable layout is scaled to fit in the shared bounds. ScaleToBounds can be thought of as a "graphical scale" between the states.

Whereas RemeasureToBounds re-measures and re-layouts the child layout of sharedBounds with 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
)

step3-3.gif

步骤四:给电影名称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)
)

step4-1.gif

步骤五:给购买按钮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)
)

step5-1.gif

购买按钮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)

step5-2.gif