本文目的是拆解Google Android官方Compose项目JetLagged中抽屉菜单栏的实现过程,JetLagged 项目地址。文章中的代码是我自己实现过程总结,和官方的案例代码会有一些出入,请注意。
最终效果
Well..你可能不想看那么多文字,而是想直接看效果
知识点
- 使用
Modifier.draggable()
实现拖拽视图组件 - 使用
Modifier.graphicsLayer()
实现图形变换 - 使用
Animatable
实现动画平滑效果 - 使用
SplineBasedDecay
计算惯性效果
前期设置
在正式开始之前,需要一个Container性质的组件容纳后续实现的所有组件
@Composable
fun MainPage(name: String, modifier: Modifier = Modifier) {
}
开始
步骤一:必须的组件和它们排列的位置
从最终效果来看,一共有两个视图组件:
- 菜单视图 Menu
- 内容视图 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的机制。
现在已经实现Content在横向拖动,现在我们需要给它限定一个移动的范围,因为我们不想Content完全被拖拽出屏幕外,所以使用了coerceIn()
这个函数,限制数值在0dp
到270dp
之间:
@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
)
}
}
步骤三:优化拖动效果,增加滑动惯性
平时我们在使用滑动手势时,并不会从起点滑动到终点,更可能是从起点滑动到一半就将手指离开屏幕,让物体以它的惯性完成剩下的动画,关于惯性动画可以使用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轴的中线位置作为判断标准:
- 如果
targetTransitionX
没有超过Menu中线,则认为Content到达目的地失败,让Content回到原来的位置 - 如果
targetTransitionX
超过Menu中线,则认为Content到达目的地成功
步骤四:优化拖动效果,增加动画平滑效果
实现完上述所有步骤之后,会发现目前为止动画的过渡都太为生硬,总是一下子就改变了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之后实现了以下效果:
-
当
rememberDraggableState
的回调方法调用时,我们需要Content跟随手指平滑移动,所以使用了Animatable
的snapTo()
函数:val draggableState = rememberDraggableState { delta -> coroutineScope.launch { val targetValue = (translationX.value + delta).coerceIn(0f, menuWidth) translationX.snapTo(targetValue) } }
-
当拖动结束时,
onDragStopped()
方法会被调用,此时可以使用Animatable
的animateTo()
函数,让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逐渐变得透明,实现过程都是类似的。
总结
Google官方给出了很多Compose制作的Sample App,你可以直接在Android Studio中直接获取到Sample App的代码,并且专注其中的一个小的效果、功能去实现,这样不至于在眼花撩乱的代码和功能中迷失。希望我的教程能够帮到你,如果你有更好的意见和建议以及讨论,可以在评论区留言。