触摸处理:从 View 到 Jetpack Compose
传统 View 系统的触摸处理
在传统 View 系统中要自定义触摸反馈,通常需要去重写各类与事件处理相关的方法,像这样:
class CustomViewGroup(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 视图布局逻辑
TODO()
}
// 事件分发:所有触摸事件的入口,拥有最高控制权,决定事件如何向下传递。
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
// 精细化控制事件分发逻辑
return super.dispatchTouchEvent(ev)
}
// 事件拦截:仅存在于 ViewGroup。用于判断是否拦截当前事件序列,
// 如果返回 true,则事件由当前 ViewGroup 的 onTouchEvent 处理,不再传递给子 View。
// 如果返回 false,则事件继续传递给子 View(或当前 ViewGroup 的 onTouchEvent,如果无子 View 可处理)。
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
// 根据需求判断是否拦截
return super.onInterceptTouchEvent(ev)
}
// 事件处理:实际处理触摸事件的地方。
// 对于普通 View,直接重写此方法。
// 对于 ViewGroup,如果 onInterceptTouchEvent 拦截了事件,或者没有子 View 处理事件,则会调用此方法。
override fun onTouchEvent(event: MotionEvent?): Boolean {
// 处理具体的触摸反馈
return super.onTouchEvent(event)
}
}
简单来说:
-
重写
onTouchEvent方法来监测和处理触摸事件,这是对于没有子 View 的 View 来说的,也就是继承了 View 而没有继承 ViewGroup 的类,比如TextView、ImageView。 -
而对于继承了 ViewGroup 的类,比如
LinearLayout,除了onTouchEvent,我们还需要重写onInterceptTouchEvent来决定是否将事件拦截下来自己处理,还是传递给子 View。 -
在更复杂的场景下,你还需要去重写最底层的
dispatchTouchEvent,控制事件的分发,来实现更精细化的定制。
此外 Android SDK 也提供了一些辅助类 GestureDetector 和 ScaleGestureDetector,来帮助我们识别常见的手势(如单击、双击、长按、滑动、捏合缩放等)。
Compose 中的触摸处理
而到了 Compose,对于触摸反馈精细化的定制,你可以这样写:
Modifier.pointerInput(key1 = Unit) { // key1 用于在输入发生变化时重启协程
awaitEachGesture { // 循环等待并处理每一组手势事件
// 循环获取原始触摸事件(Pointer Events)
val event = awaitPointerEvent()
// 根据 event 进行计算和响应
println(event.changes.first().position) // 打印第一个触摸点的位置
}
}
多次点击的运行效果:
D/System.out Offset(63.0, 71.9)
D/System.out Offset(63.0, 71.9)
D/System.out Offset(63.0, 71.9)
但我们一般不这么写,因为 Compose 已经提供了相当完备并且简易的上层 API 来处理常见的触摸交互。
对于最简单的手势处理,比如点击、双击、长按,你可以看我的这篇文章:传送门
本文详细讲解一维滑动相关的 Modifier 修饰符:draggable 和 scrollable。其中 scrollable 的底层实现就是draggable,只不过scrollable针对滑动场景增加了一些额外的功能支持,那么,我们先来看较为底层的 draggable。
draggable: 实现一维拖拽
参数详解
draggable 用于处理一维(水平或垂直)的拖拽手势。
fun Modifier.draggable(
state: DraggableState,
orientation: Orientation,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
startDragImmediately: Boolean = false,
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
reverseDirection: Boolean = false
): Modifier
它有两个必填参数,分别是 state 和 orientation。
state 和 orientation 参数
什么是 state 状态呢?其实每一个可操作/交互的组件和修饰符,都有一个 state 参数。
就比如 LazyColumn 组件就有 state 参数,这个参数可以让我们去手动操作界面,准确来说是修改界面所依赖的状态对象,从而完成操作界面的效果。例如:
@Preview(showBackground = true)
@Composable
fun LazyColumnStateSample() {
val scope = rememberCoroutineScope()
val state = rememberLazyListState() // LazyColumn 的状态
Column {
LazyColumn(
modifier = Modifier.border(1.dp, Color.LightGray).size(160.dp, 160.dp),
state = state // 将状态与组件关联
) {
items(items = (1..100).map { it }) {
Text("Item $it")
}
}
Button(
onClick = {
scope.launch {
// 以动画的形式滚动到第一个列表项
state.animateScrollToItem(0)
}
}
) { Text("点击跳转到第一项") }
}
}
预览效果:
而 draggable 的 state 参数也是类似的道理,是用于管理拖拽状态的,只需调用 rememberDraggableState 函数就能创建一个 DraggableState 对象:
val draggableState = rememberDraggableState(
onDelta = { delta -> // Δ 拖拽的位移变化量
// ..
}
)
其中要传入一个 onDelta 回调,当用户的手指在屏幕上拖动时,它会被连续调用。delta 就是每一次拖拽时的位移量,单位是像素。并且这个位移是一维的,只表示水平或垂直方向上的位移量,正负代表不同的拖动方向。
而 orientation 参数就能指定拖拽的方向,Orientation.Horizontal(水平) 或 Orientation.Vertical(垂直)。
了解完这两个必填参数就可以来写一个简单的 Demo 了。
@Composable
fun DraggableLogSample() {
val draggableState = rememberDraggableState(
onDelta = { delta ->
Log.d("DraggableLogSample", "此次拖拽的位移量是:$delta 像素")
}
)
Text(
text = "水平拖动我",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(Color.LightGray)
.draggable(
state = draggableState,
orientation = Orientation.Horizontal
)
)
}
其它重要参数
再来看看 draggable 的其它可选参数,这些参数都有默认值:
fun Modifier.draggable(
state: DraggableState,
orientation: Orientation,
enabled: Boolean = true, // 拖拽是否生效
interactionSource: MutableInteractionSource? = null, // 交互数据源
startDragImmediately: Boolean = false, // 是否立即开始拖拽监测
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, // 拖动开始时的回调(过了 "touch slop" 阶段),参数 startedPosition 表示拖拽开始时的位置
onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, // 拖动结束时的回调,参数 velocity 是松手时在拖拽方向上的速度,单位是像素每秒
reverseDirection: Boolean = false // 是否反转手势方向,向上滑动就变为向下滑动,向右滑动就变为向左滑动
): Modifier
其中就讲两个参数,分别是 interactionSource 和 startDragImmediately,其它都挺简单的:
-
interactionSource: 交互数据源,可以观察组件是否正在被拖拽。@Composable fun DraggableInteractionSourceTextSample() { val draggableState = rememberDraggableState( onDelta = { delta -> Log.d("draggable", "此次拖拽的位移量是:$delta") } ) // 交互数据源 val interactionSource = remember { MutableInteractionSource() } val isDraggable = interactionSource.collectIsDraggedAsState() // 收集拖拽状态 Column { Text( "Hello draggable!", Modifier.draggable( state = draggableState, orientation = Orientation.Horizontal, interactionSource = interactionSource ) ) Text(text = if (isDraggable.value) "文本正在拖拽中" else "文本静止") } }运行结果:
-
startDragImmediately:为 false 时,用户按下后,需要移动一小段距离(称为 "touch slop"),才会开始真正的拖拽监测并触发onDelta回调,这样可以防止因误触造成的点击导致组件的晃动,提高了用户的体验感;为 true 时,用户手指一接触屏幕,就会立即启动拖拽监测,onDelta回调也会被触发。
示例:拖动文字组件
我们来写文字组件被左右拖动的示例:
@Composable
fun DraggableTextDemo() {
var offsetX by remember { mutableFloatStateOf(0f) } // 水平偏移量
Text(
text = "左右拖动我!",
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal, // 水平拖动
state = rememberDraggableState { delta ->
// 修改当前位移
offsetX += delta
}
)
.fillMaxWidth()
.padding(12.dp)
)
}
运行效果:
注意:这里所涉及到的所有偏移量单位都是像素(px)。
scrollable: 更强大的滑动支持
首先搞清楚,scrollable 和 Modifier.verticalScroll()、Modifier.horizontalScroll() 是不一样的,后两个是给本身不具备滚动能力的组件增加滑动功能的,而 scrollable 只是做滑动行为监测的,管理滑动状态的。
scrollable 相对于 draggable 所增加的功能就是:
-
支持惯性滑动:用户松手后,内容可以继续按一定速度滑动并逐渐减速停止。
-
支持嵌套滑动:协调父子组件之间的滑动事件分发与处理。
-
支持滑动到边界的效果:滑动到内容边界时继续拖拽产生的视觉效果(如光晕)。
有了这几种效果的支持,使得 scrollable 非常适合滑动布局这类场景。
参数详解
onDelta 回调
scrollable 的写法其实和 draggable 是差不多的,先来看它的函数原型:
@Stable
@ExperimentalFoundationApi
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?,
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null,
bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
)
也是有 state、orientation 参数,state 参数用于管理滑动状态,我们通常使用 rememberScrollableState() 函数来创建它:
val scrollableState = rememberScrollableState(
consumeScrollDelta = { delta -> // 滑动量
}
)
和 draggable 不同的是,在它的 consumeScrollDelta 回调中还需要一个 Float 类型的返回值,表示当前组件实际消耗掉的滑动距离,这个返回值是为了支持嵌套滑动的,而未被消费的滑动距离可以被传递给父组件或子组件进行处理。
orientation 参数用于指定滑动方向(水平或垂直)。
我写一个简单的示例来展示onDelta回调的写法:
@Composable
fun ScrollableDemo() {
Text(
text = "scrollable的作用是监测滑动",
modifier = Modifier.scrollable(
orientation = Orientation.Horizontal,
state = rememberScrollableState { delta -> // 表示此次滑动的偏移量
println("此次滑动,偏移量为 $delta 像素")
delta // 表示当前组件消费了全部的滑动增量
}
)
)
}
其他重要参数
我们再来看看 scrollable 的其它重要参数,好几个参数都在 draggable 中讲解过了,唯一不同的是 flingBehavior、 overscrollEffect 和 bringIntoViewSpec:
-
flingBehavior参数是用于定义惯性滑动的行为的,即用户快速滑动后松手时,内容如何减速和停止。通常使用默认值即可,因为都是慢慢减速直到停下来,除非你有特殊的需求才需要自己实现,比如定制惯性滑动曲线或停止条件。 -
overscrollEffect参数定义的是滑动到内容边界时继续拖拽产生的视觉效果,一般也是不填(有默认值)。 -
bringIntoViewSpec参数定义的是当子项请求显示在视图中时,父组件滚动的行为。就比如一个文本输入框获得焦点时,要确保它在滚动视图中可见。你可以配置滚动的动画规格(动画曲线、滚动多快)和子项最终的位置逻辑(刚好这个组件可见,还是尝试显示在视图的中心)。
我们前面说过了 scrollable 非常适合作为滑动布局,事实上,Compose也是这样做的,Modifier.verticalScroll()、LazyColumn 底层使用的都是 scrollable。
总结
draggable 提供的是基础的、通用的一维拖拽手势识别。适用于简单的自定义拖动行为的场景,比如:自定义进度条的拖动控制。
而scrollable 是在 draggable 基础上,增加了惯性滑动、嵌套滑动和滑动边界效果的支持,专用于滑动布局场景。比如:可滚动页面容器。
实际开发中,如何选择?
-
如果你只需简单的内容/列表滚动,直接使用
LazyColumn、LazyRow、Modifier.verticalScroll、Modifier.horizontalScroll。 -
需要自定义一个元素的拖拽行为,无需额外的效果时,可以使用
draggable。 -
当你需要自定义一个具备惯性滑动,或者父组件或子组件需要协调滑动行为的组件时,就使用
scrollable。