使用JetPack Compose实现抽屉菜单(Drawer)动画

477 阅读7分钟

本文目的是拆解Google Android官方Compose项目JetLagged中抽屉菜单栏的实现过程,JetLagged 项目地址。文章中的代码是我自己实现过程总结,和官方的案例代码会有一些出入,请注意。

最终效果

Well..你可能不想看那么多文字,而是想直接看效果

final.gif

知识点

  1. 使用Modifier.draggable()实现拖拽视图组件
  2. 使用Modifier.graphicsLayer()实现图形变换
  3. 使用Animatable实现动画平滑效果
  4. 使用SplineBasedDecay 计算惯性效果

前期设置

在正式开始之前,需要一个Container性质的组件容纳后续实现的所有组件

@Composable
fun MainPage(name: String, modifier: Modifier = Modifier) {

}

开始

步骤一:必须的组件和它们排列的位置

从最终效果来看,一共有两个视图组件:

  1. 菜单视图 Menu
  2. 内容视图 Content

所以先创建两个Composable:

@Composable
fun Menu() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Menu",
            modifier = Modifier,
            color = Color.Black
        )
    }
}

@Composable
fun Content() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

并且Content位置覆盖在Menu上,所以:

@Composable
fun MainPage(name: String, modifier: Modifier = Modifier) {
    Menu()
    Content()
}

步骤二:拖动“内容组件”到屏幕边缘

接下来我们需要实现手指横向拖动Content时,Content会随着手指移动,所以Content Composable需要能够检测到拖动事件:

@Composable
fun Content() {
		// ADD
		val draggableState = rememberDraggableState { delta ->
       // 检测到拖拽事件后这部分代码会被调用
    }
    Box(
        modifier = Modifier
		        .draggable(draggableState, Orientation.Horizontal) // ADD
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

我们调用了Modifier的draggable()方法,设置这个回调目的是告诉它我们需要检测到横向的拖拽事件,每次检测到目标事件后,传入draggable()draggableState变量的回调方法会被调用。接下来我们需要实现拖拽时,Content的横坐标,也就是X轴上的坐标会改变:

@Composable
fun Content() {
		var translationX by remember { mutableFloatStateOf(0f) } // ADD
		val draggableState = rememberDraggableState { delta ->
       // ADD
       translationX += delta
    }
    Box(
        modifier = Modifier
		        .graphicsLayer(translationX = translationX) // ADD
		        .draggable(draggableState, Orientation.Horizontal)
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

graphicsLayer 是 Jetpack Compose 中一个强大的 Modifier,用于直接操作视图的图层属性。它允许对元素进行图形变换,如旋转、缩放、透明度调整、投影等,所有这些操作都在一个独立的渲染层上完成,这里transitionX/Y的作用是沿 X 或 Y 轴平移图层的像素距离

💡注意Modifier链式调用方法的的顺序,如果background()和fillMaxSize()顺序放置在最前面,会产生的效果是,拖拽Content移动时,背景的灰色不会移动,只有文本会移动。详细请学习Modifier的机制。

step1.gif

现在已经实现Content在横向拖动,现在我们需要给它限定一个移动的范围,因为我们不想Content完全被拖拽出屏幕外,所以使用了coerceIn()这个函数,限制数值在0dp270dp之间:

@Composable
fun Content() {
		// ADD
		val menuWidth = LocalDensity.current.density * 270
		var translationX by remember { mutableFloatStateOf(0f) }
		val draggableState = rememberDraggableState { delta ->
       // ADD
       translationX = (translationX + delta).coerceIn(0f, menuWidth)
    }
    Box(
        modifier = Modifier
		        .graphicsLayer(translationX = translationX) // ADD
		        .draggable(draggableState, Orientation.Horizontal)
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

step2.gif

步骤三:优化拖动效果,增加滑动惯性

平时我们在使用滑动手势时,并不会从起点滑动到终点,更可能是从起点滑动到一半就将手指离开屏幕,让物体以它的惯性完成剩下的动画,关于惯性动画可以使用SplineBasedDecay,然后我们需要在onDragStopped()回调中增加一段代码:

@Composable
fun Content() {
		val coroutineScope = rememberCoroutineScope() // ADD
		val menuWidth = LocalDensity.current.density * 270
		var translationX by remember { mutableFloatStateOf(0f) }
		val draggableState = rememberDraggableState { delta ->
       translationX = (translationX + delta).coerceIn(0f, menuWidth)
    }
    val decay = rememberSplineBasedDecay<Float>() // ADD
    Box(
        modifier = Modifier
		        .graphicsLayer(translationX = translationX)
		        .draggable(draggableState, Orientation.Horizontal, onDragStopped = { velocity: Float ->
				        // ADD
				        // 拖拽结束后会调用这部分代码
				        coroutineScope.launch {
                    val targetTransitionX = decay.calculateTargetValue(translationX, velocity)
                    val actualTransitionX = if (targetTransitionX >= menuWidth * 0.5) {
                        menuWidth
                    } else {
                        0f
                    }
                    translationX = actualTransitionX
                }
		        })
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

这里使用SplineBasedDecay,它是 Jetpack Compose 中的一个工具,用于模拟拖动、滑动或其他手势操作后,组件的惯性衰减过程。 SplineBasedDecay依据Content滑动的速度、手指离开时Content在X轴上的位置,可以计算出Content最终停止的位置,用targetTransitionX表示。这里我还使用了Menu X轴的中线位置作为判断标准:

  1. 如果targetTransitionX没有超过Menu中线,则认为Content到达目的地失败,让Content回到原来的位置
  2. 如果targetTransitionX超过Menu中线,则认为Content到达目的地成功

step3.gif

步骤四:优化拖动效果,增加动画平滑效果

实现完上述所有步骤之后,会发现目前为止动画的过渡都太为生硬,总是一下子就改变了Content的位置,所以我们可以利用Animatable实现动画平滑的效果。

💡在JetPack Compose当中Animatable并不是唯一实现平滑动画的工具,这里使用它只是因为它可以更精细控制物体的动画效果。

@Composable
fun Content() {
		val coroutineScope = rememberCoroutineScope()
		val menuWidth = LocalDensity.current.density * 270
		val translationX = remember { Animatable(0f) } // ADD
		val draggableState = rememberDraggableState { delta ->
				//ADD
				coroutineScope.launch {
				    val targetValue = (translationX.value + delta).coerceIn(0f, menuWidth)
				    translationX.snapTo(targetValue)
				}
    }
    val decay = rememberSplineBasedDecay<Float>()
    Box(
        modifier = Modifier
		        .graphicsLayer(translationX = translationX.value) // ADD
		        .draggable(draggableState, Orientation.Horizontal, onDragStopped = { velocity: Float ->
				        coroutineScope.launch {
                    val targetTransitionX = decay.calculateTargetValue(translationX.value, velocity)
                    val actualTransitionX = if (targetTransitionX >= menuWidth * 0.5) {
                        menuWidth
                    } else {
                        0f
                    }
                    translationX.animateTo(actualTransitionX) // ADD
                }
		        })
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

使用了Animatable之后实现了以下效果:

step4.gif

  1. rememberDraggableState的回调方法调用时,我们需要Content跟随手指平滑移动,所以使用了AnimatablesnapTo()函数:

    val draggableState = rememberDraggableState { delta ->
    				coroutineScope.launch {
    				    val targetValue = (translationX.value + delta).coerceIn(0f, menuWidth)
    				    translationX.snapTo(targetValue)
    				}
        }
    
  2. 当拖动结束时,onDragStopped()方法会被调用,此时可以使用AnimatableanimateTo()函数,让Content自行滑动到目的地:

    onDragStopped = { velocity: Float ->
        coroutineScope.launch {
            val targetTransitionX = decay.calculateTargetValue(translationX.value, velocity)
            val actualTransitionX = if (targetTransitionX >= menuWidth * 0.5) {
                menuWidth
            } else {
                0f
            }
            translationX.animateTo(actualTransitionX)
    }
    

步骤五:优化拖动效果,增加拖动过程的图形变化

现在看起来动画效果已经很好了,但我们还可以再增加些效果,让动画看起来更漂亮。在拖拽Content的过程中,可以让Content的高度逐渐变小、圆角逐渐变大、阴影逐渐增加,这些都可以通过Modifier.graphicsLayer()来实现,前面我们已经使用这个方法实现Content在X轴的移动了,但这个方法能做的还不止这一项,graphicsLayer 提供了以下功能,通过其参数可以控制各种视觉效果,这里只列出了一部分我会使用到的属性:

属性作用
translationX/Y沿 X 或 Y 轴平移图层的像素距离。
scaleX/Y沿 X 或 Y 轴缩放图层的比例。
shadowElevation图层的投影高度,用于实现阴影效果。
shape定义图层的形状,可用于裁剪内容。
clip是否启用裁剪(true 时,图层内容会被裁剪到指定的 shape 中)。
@Composable
fun Content() {
		val coroutineScope = rememberCoroutineScope()
		val menuWidth = LocalDensity.current.density * 270
		val translationX = remember { Animatable(0f) }
		val draggableState = rememberDraggableState { delta ->
				coroutineScope.launch {
				    val targetValue = (translationX.value + delta).coerceIn(0f, menuWidth)
				    translationX.snapTo(targetValue)
				}
    }
    val decay = rememberSplineBasedDecay<Float>()
    // ADD
    val scaleY = lerp(1f, 0.8f, translationX.value / menuWidth)
    val roundedCorners =
        lerp(0f, LocalDensity.current.density * 30f, translationX.value / menuWidth)
    val shadowElevation =
        lerp(0f, LocalDensity.current.density * 32f, translationX.value / menuWidth)
        
    Box(
        modifier = Modifier
		        .graphicsLayer( // ADD
				        translationX = translationX.value,
                scaleY = scaleY,
                shape = RoundedCornerShape(roundedCorners),
                clip = true,
                shadowElevation = shadowElevation
		        )
		        .draggable(draggableState, Orientation.Horizontal, onDragStopped = { velocity: Float ->
				        coroutineScope.launch {
                    val targetTransitionX = decay.calculateTargetValue(translationX.value, velocity)
                    val actualTransitionX = if (targetTransitionX >= menuWidth * 0.5) {
                        menuWidth
                    } else {
                        0f
                    }
                    translationX.animateTo(actualTransitionX)
                }
		        })
            .fillMaxSize()
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Content",
            modifier = Modifier,
            color = Color.White
        )
    }
}

这里我使用了lerp()函数去计算拖拽过程中Content高度、圆角大小、阴影大小的变化过程值。lerp线性插值(Linear Interpolation) 的简写,在数学和编程中广泛应用。它的作用是根据给定的插值因子,在两个数值或对象之间计算出一个中间值。

val scaleY = lerp(1f, 0.8f, translationX.value / menuWidth)
val roundedCorners =
    lerp(0f, LocalDensity.current.density * 30f, translationX.value / menuWidth)
val shadowElevation =
    lerp(0f, LocalDensity.current.density * 32f, translationX.value / menuWidth)

lerp的前两个参数是起始值(start)和终止值(end),第三个参数是插值因子(fraction),我利用拖拽过程中Content X轴的位置作为了插值因子的一部分,为的是让目标值受TransitionX的影响去改变。

再来看一次最终的实现效果吧,其实你可以自己在增加一些效果,例如拖拽过程中,让Content逐渐变得透明,实现过程都是类似的。

final.gif

总结

Google官方给出了很多Compose制作的Sample App,你可以直接在Android Studio中直接获取到Sample App的代码,并且专注其中的一个小的效果、功能去实现,这样不至于在眼花撩乱的代码和功能中迷失。希望我的教程能够帮到你,如果你有更好的意见和建议以及讨论,可以在评论区留言。