前言
在上一篇文章中,我介绍了如何在Compose中完成自定义View和自定义ViewGroup,相较于原生的View体系,Compose的自定义View会更加简单方便。那么在这篇文章中,我将会介绍在Compose中如何完成触摸事件和嵌套滑动的处理。
1 Compose中的触摸事件
在原生的View体系中,常见的触摸事件有:ACTION_DOWN、ACTION_MOVE、ACTION_UP,当手指按下时,会遍历View树型结构拿到mFirstTouchTarget
,以此将后续的MOVE事件和UP事件都交给这个组件消费,在View中消费事件是通过onTouchEvent
方法处理的。
如果我们想要对事件进行拦截,通常会重写onInterceptTouchEvent
,根据具体的业务场景来判断是否拦截事件,以及在嵌套的滑动组件中,对于事件冲突的处理尤为重要,所以本节我将会介绍在Compose中如何完成触摸事件的处理。
1.1 Compose中的点击事件
在Compose当中的Modifier提供了clickable
函数用于处理点击事件;
kotlin
Text(text = "点击我", Modifier.clickable {
Log.d(TAG, "TestTouchEvent: 单击事件")
})
而对于双击,长按,则是另一个函数combinedClickable
来完成。
kotlin
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
){
// ......
}
其实从源码中可以看到(这里我不带大家看了,可以自行查看,很简单的源码),像点击手势的处理,是通过Modifier.pointInput
来处理的。
kotlin
Text(text = "点击我", Modifier.pointerInput(Unit) {
awaitEachGesture {
val event = awaitPointerEvent()
when(event.type){
PointerEventType.Press ->{
Log.d(TAG, "TestTouchEvent: Press")
}
PointerEventType.Move->{
Log.d(TAG, "TestTouchEvent: Move")
}
PointerEventType.Exit->{
Log.d(TAG, "TestTouchEvent: Exit")
}
PointerEventType.Scroll->{
Log.d(TAG, "TestTouchEvent: Scroll")
}
}
}
})
Modifier.pointInput
算是Compose对于触摸反馈最底层的处理了,通过awaitPointerEvent
可以获取用户输入的事件,根据类型判断是Press
(点击)、Move
(移动)、Scroll
(滑动)等事件类型。
kotlin
Text(text = "点击我", Modifier.pointerInput(Unit) {
detectTapGestures {
Log.d(TAG, "TestTouchEvent: 点击了")
}
})
或者直接在pointInputScope
中通过detectTapGestures
来监测点击事件。
这是我之前在介绍Compose时,已经使用过点击事件,这里是简单的对点击事件的底层实现做了介绍,接下来我要介绍一下滑动事件。
1.2 Compose中的滑动事件 - draggable
在Compose中,提供了draggable
和scrollable
函数,用于处理滑动事件,先看下draggable
函数。
kotlin
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 {
// ......
}
在draggable
函数中,有两个必填的值,state
和orientation
,在滑动组件中,例如LazyColumn
、Pager
等,必须要有一个state对象。
1.2.1 LazyColumn中的state对象
在LazyColumn
中,state默认是执行了rememberLazyListState
函数,利用其返回值,也就是得到了LazyListState
对象。
kotlin
@Composable
fun TestScrollableState() {
val list = remember {
mutableStateListOf("A", "B", "C", "D", "E", "F")
}
val scope = rememberCoroutineScope()
val state = rememberLazyListState()
LazyColumn(state = state) {
items(list) {
Text(
text = "当前字母:$it",
Modifier
.fillMaxWidth()
.height(200.dp)
)
}
}
Button(onClick = {
scope.launch {
state.scrollToItem(list.size - 1)
}
}) {
Text(text = "定位")
}
}
那么这个state是干什么用的呢?其实就是为了用来处理列表的滑动,或者监听列表滑动。 我们常见的一个需求就是,当进到某个页面时,需要定位到列表中的某个元素,那么如果使用RecyclerView
,那么可以通过scrollToPosition(index)
来完成。
但是Compose是声明式的UI,无法拿到组件的实例对象,因此就是通过state
来完成滑动的控制,例如点击按钮滑动到列表最后一位,那么就调用state的scrollToItem
函数来完成。
1.2.2 draggable函数分析
再回到draggable
函数,除了state
之外,还需要设置orientation
,就是滑动的方向。因为draggable
是监听一维方向的滑动, 因此只能拿到x轴或者y轴方向上滑动偏移量。
kotlin
@Composable
fun TestDraggable() {
Text(
text = "悬浮窗",
Modifier
.size(200.dp)
.background(Color.Blue)
.draggable(rememberDraggableState {
Log.d(TAG, "TestDraggable: $it")
}, Orientation.Horizontal)
)
}
因为draggable
也需要一个state
,一般情况下都是会使用rememberDraggableState
来生成一个DraggableState
,我们看其回调值其实就是一个float类型的参数,意味着draggable
就是用来检测一维方向上的偏移量。
kotlin
@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {
val onDeltaState = rememberUpdatedState(onDelta)
return remember { DraggableState { onDeltaState.value.invoke(it) } }
}
还有一个参数,不是必填项,就是interactionSource
,能够反映此时的用户与界面的交互状态,假设有个需求,当组件拖拽的时候,需要显示某个文案;停止拖拽之后显示另一个文案。
kotlin
@Composable
fun TestDraggable() {
val interaction = remember {
MutableInteractionSource()
}
Column {
Text(
text = "悬浮窗",
Modifier
.size(200.dp)
.background(Color.Blue)
.draggable(rememberDraggableState {
Log.d(TAG, "TestDraggable: $it")
}, Orientation.Horizontal, interactionSource = interaction)
)
val isDragged by interaction.collectIsDraggedAsState()
if (isDragged) {
Text(text = "正在拖拽")
} else {
Text(text = "静止状态中")
}
}
}
可以将MutableInteractionSource
转换为可监听的State,当拖动状态发生变化时,可以监听到。
1.2.3 通过draggable实现拖拽效果
在前面我提到,所有的滑动组件都会使用到state
,像draggable
中使用到的rememberDraggableState
可以拿到一维方向的偏移量,那么肯定能够在偏移量上做文章,实现拖拽效果。
kotlin
@Composable
fun TestDraggable() {
val interaction = remember {
MutableInteractionSource()
}
// x轴的滑动距离
var scrollX = remember {
mutableStateOf(0)
}
Column {
Text(
text = "悬浮窗",
Modifier
.draggable(rememberDraggableState {
// 发起重组
scrollX.value += it.toInt()
}, Orientation.Horizontal, interactionSource = interaction)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints = constraints)
layout(placeable.width, placeable.height) {
//摆放位置
placeable.placeRelative(IntOffset(scrollX.value, 0))
}
}
.size(200.dp)
.background(Color.Blue)
)
val isDragged by interaction.collectIsDraggedAsState()
if (isDragged) {
Text(text = "正在拖拽")
} else {
Text(text = "静止状态中")
}
}
}
例如记录一个scrollX
,用于记录水平方向的偏移量,这个值是累加的,每次拖拽都会触发重组重新测量布局,在Modifier.layout
函数中进行布局的重新摆放逻辑。
1.3 Compose中的滑动事件 - scrollable
在上一节中,介绍了draggable
的使用,这一节将会介绍scrollable
的使用,其实如果看过scrollable
的源码,会发现它在底层还是通过draggable
实现的。
kotlin
@ExperimentalFoundationApi
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?,
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null
): Modifier {
// ......
}
那么为什么不统一用draggable
,而是要单独加了一个scrollable
?原因就是通过scrollable
要做一些精细化的效果处理,例如:惯性滑动、嵌套滑动nestscroll、边界回弹等。
其实很好理解,draggable
从字面意思上看就是拖拽,虽然滑动也是拖拽的一种,但是并不意味着所有的拖拽场景都需要所谓的惯性滑动、嵌套滑动,例如设置中的进度条。
来看下使用,我下面是用scrollable
实现了组件的横向移动能力,
kotlin
@Composable
fun TestScrollable() {
val currentX = remember {
mutableStateOf(0)
}
Text(
text = "悬浮窗",
Modifier
.offset {
IntOffset(currentX.value, 0)
}
.scrollable(rememberScrollableState {
currentX.value += it.toInt()
it
}, Orientation.Horizontal)
.size(200.dp)
.background(Color.Blue)
)
}
和draggable
一样,scrollable
也需要一个state,Compose给提供好了就是rememberScrollableState
函数。
kotlin
@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
val lambdaState = rememberUpdatedState(consumeScrollDelta)
return remember { ScrollableState { lambdaState.value.invoke(it) } }
}
但是需要注意的是,和rememberDraggableState
不同的是,它需要一个返回值,这个返回值代表当前横滑的距离被某个组件消费了多少。 以便将剩余的滑动距离交给父容器或者子组件消费,这就是嵌套滑动的原理所在。
除此之外,scrollable
中还提供了overscrollEffect
参数,用于处理触边后的回弹效果,Compose中提供了默认的实现ScrollableDefaults.overscrollEffect()
。
flingBehavior
则是用于处理惯性滑动,默认可以不传值,在底层使用默认值。
kotlin
// 如果没有特殊的惯性滑动需求,底层使用默认值。
val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()
所以scrollable
在draggable
的基础之上,增加了几种滑动效果的逻辑处理。
1.4 Compose的二维滑动
前面我在介绍draggable
和scrollable
的时候说过,他们只支持一维方向的滑动,所以需要设置orientation
属性,如果想要监听二维的滑动,Compose没有提供直接使用的API,需要Modifier.pointerInput
来配合完成。
在1.1 小节中,我介绍点击事件的时候,提到过detectTapGestures
可以从底层监听点击事件,那么如果想要监听二维滑动,那么可以通过detectDragGestures
来完成。
kotlin
Text(text = "二维滑动",
Modifier
.size(200.dp)
.background(Color.Blue)
.pointerInput(Unit){
detectDragGestures { change, dragAmount ->
}
})
在detectDragGestures
中,有两个参数,第一个参数:PointerInputChange,代表手指点按的信息,每一个手指按下都有对应的id和位置信息等,以此来处理手势的抬起和按下;第二个参数:代表的是手指滑动的位置信息,是一个Offset类型的数据,记录x和y轴的偏移量。
kotlin
@Composable
fun TestMultiScroll() {
val currentX = remember {
mutableStateOf(0)
}
val currentY = remember {
mutableStateOf(0)
}
Text(text = "二维滑动",
Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(currentX.value, currentY.value)
}
}
.size(200.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
currentX.value += dragAmount.x.roundToInt()
currentY.value += dragAmount.y.roundToInt()
}
})
}
所以要实现悬浮窗的拖动,就可以使用detectDragGestures
来实现。
2 Compose中的嵌套滑动
相关文章:
Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果
在之前的文章中,我详细介绍过在传统的View体系中,如何通过嵌套滑动机制完成一些需求,它的实现还是比较复杂的,需要实现NestScrollingParent
和NestScrollingChild
接口。
而在Compose中实现嵌套滑动,在1.3小节中,我介绍过了Modifier.scrollable
,其实就是在其基础之上实现。
2.1 Compose自有的嵌套滑动组件
在传统的View体系中,像RecyclerView
,NestScrollView
等,都具备嵌套滑动的能力,例如RecyclerView:
java
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 { // ......}
它实现了NestedScrollingChild2
接口,所以它具备嵌套滑动的能力。所以在Compose当中通过Modifier.scrollable
实现的滑动组件,大概率都具备嵌套滑动的能力,比如LazyColumn
。
gif可能看不太清楚,目前效果就是两个LazyColumn
嵌套在一起,当滑动内部的LazyColumn
的时候,外部的LazyColumn
不会滑动,只有当内部的LazyColumn
到底之后,外部的LazyColumn
才可以继续滑动。
kotlin
@Composable
fun TestNestScroll() {
LazyColumn {
item {
LazyColumn(
Modifier
.fillMaxWidth()
.height(200.dp)
) {
items(10) {
Text(
text = "child $it",
Modifier
.fillMaxWidth()
.height(30.dp)
.background(Color.Red)
)
}
}
}
items(20) {
Text(
text = "parent $it",
Modifier
.fillMaxWidth()
.height(30.dp)
.background(Color.Blue)
)
}
}
}
当然,作为程序员,面对一些定制化的需求,还是需要自己实现的。
2.2 自定义实现嵌套滑动
在Compose当中,提供了Modifier.nestedScroll
来实现嵌套滑动,既然我要讲嵌套滑动,首先需要明确一下,嵌套滑动的原理:
其实嵌套滑动很简单,在Compose当中对于父容器是不会主动处理滑动事件,是子组件通过回调通知父容器是否需要滑动,通常是在子组件滑动之前「询问」父容器是否要消费滑动距离,以及在子组件滑动完成之后,也要询问父容器是否需要消费剩余的滑动距离。
ok,知道原理之后,就知道该做哪些事了!
- 通知父容器是否消费事件,分两次进行;
- 父容器接收到回调之后,选择是否处理事件消费
那么如何通知父容器是否消费事件,就是采用NestedScrollDispatcher
来进行嵌套滑动的事件分发,也就是nestedScroll
函数的第二个参数。
kotlin
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "nestedScroll"
properties["connection"] = connection
properties["dispatcher"] = dispatcher
}
) {
val scope = rememberCoroutineScope()
// provide noop dispatcher if needed
val resolvedDispatcher = dispatcher ?: remember { NestedScrollDispatcher() }
remember(connection, resolvedDispatcher, scope) {
resolvedDispatcher.originNestedScrollScope = scope
NestedScrollModifierLocal(resolvedDispatcher, connection)
}
}
接下来带大家实现一个嵌套滑动组件。
kotlin
@Composable
fun TestNestScroll2() {
var currentY = remember {
mutableStateOf(0)
}
Column(
Modifier
.fillMaxWidth()
.offset {
IntOffset(0, currentY.value)
}
.draggable(rememberDraggableState {
currentY.value += it.roundToInt()
}, Orientation.Vertical)
) {
for (index in 1..20) {
Text(
text = "第 $index 个组件",
Modifier
.fillMaxWidth()
.height(20.dp)
)
}
}
}
这个组件具备了上下滑动的能力,接下来会处理嵌套滑动的逻辑。
2.2.1 NestedScrollDispatcher
伙伴们重点看下NestedScrollDispatcher
关于滑动事件分发函数的注释:
kotlin
class NestedScrollDispatcher {
// ......
/**
* 用于子组件处理滑动之前,回调通知父容器是否需要消费事件
*
* @param available 一次滑动事件的距离
* @param source 滑动事件的来源
*
* @return 祖先节点,或者说父容器消费的滑动距离
*/
fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
return parent?.onPreScroll(available, source) ?: Offset.Zero
}
/**
* 子组件滑动完成之后,再次通知父容器是否需要消费事件
*
* @param consumed 当前子组件消费的距离
* @param available 当前父容器可以再次消费的剩余距离
* @param source 滑动事件的来源
*
* @return the amount of scroll that was consumed by all ancestors
*/
fun dispatchPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
}
// 下面两个惯性函数其实是一样的。
suspend fun dispatchPreFling(available: Velocity): Velocity {
return parent?.onPreFling(available) ?: Velocity.Zero
}
suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
return parent?.onPostFling(consumed, available) ?: Velocity.Zero
}
}
NestedScrollDispatcher
就是用来通知父容器是否需要消费事件的工具,所以我对draggable
的内部逻辑进行了修改。
kotlin
Modifier.draggable(rememberDraggableState { duration ->
//滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
val parentConsumed =
dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
//那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
currentY.value += availableDuration
// 滑动结束之后,再次通知父容器,要不要消费?
dispatch.dispatchPostScroll(
Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
Offset.Zero, // 父容器可消费的滑动距离为0
NestedScrollSource.Drag
)
}, Orientation.Vertical)
2.2.2 NestedScrollConnection
那么子组件通过NestScrollDispatcher
发起的回调,父容器在哪接收到呢?就是通过NestedScrollConnection
,它是一个接口,所以在用的时候需要自己实现一个实例,或者创建一个匿名内部类都可以。
接口函数的注释可以阅读一下:
kotlin
@JvmDefaultWithCompatibility
interface NestedScrollConnection {
/**
* Pre scroll event chain. 它会在子组件允许父容器消费滑动事件的时候回调,是在子组件滑动之前接收到的回调。
*
* @param available 父容器可以消费的滑动距离,即dispatch.dispatchPreScroll传入的第一个参数
* @param source 滑动事件来源
*
* @return 当前组件消费多少的滑动事件
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
* Post scroll event pass. 子组件完成滑动之后会回调
* @param consumed 子组件消费的滑动距离,即dispatch.dispatchPostScroll传入的第一个参数
* @param available 父容器可以消费的滑动距离,即dispatch.dispatchPostScroll传入的第二个参数
* @param source 滑动事件来源
*
* @return the amount that was consumed by this connection
*/
fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = Offset.Zero
// 惯性的我先不管了
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
所以总体的嵌套滑动处理,在理解了其中的原理之后,其实就大概能写出来其中的核心逻辑了,当然这个demo不存在嵌套滑动的逻辑,我在内部再加一个LazyColumn。
kotlin
@Composable
fun TestNestScroll2() {
var currentY = remember {
mutableStateOf(0)
}
//分发给父容器
val dispatch = remember {
NestedScrollDispatcher()
}
val connection = remember {
object : NestedScrollConnection{
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 假设全消费了
Log.d(TAG, "onPreScroll: $available ")
return available
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
Log.d(TAG, "onPostScroll: consumed $consumed available $available")
//子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
return super.onPostScroll(consumed, available, source)
}
}
}
Column(
Modifier
.fillMaxWidth()
.offset {
IntOffset(0, currentY.value)
}
.nestedScroll(connection, dispatch)
.draggable(rememberDraggableState { duration ->
//滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
val parentConsumed =
dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
//那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
Log.d(TAG, "TestNestScroll2: availableDuration $availableDuration")
currentY.value += availableDuration
// 滑动结束之后,再次通知父容器,要不要消费?
dispatch.dispatchPostScroll(
Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
Offset.Zero, // 父容器可消费的滑动距离为0
NestedScrollSource.Drag
)
}, Orientation.Vertical)
) {
for (index in 1..20) {
Text(
text = "第 $index 个组件",
Modifier
.fillMaxWidth()
.height(20.dp)
)
}
//内部嵌套一个LazyColumn。
LazyColumn(Modifier.height(80.dp)){
items(10){
Text(
text = "第 $it 个内部组件",
Modifier
.fillMaxWidth()
.height(20.dp)
)
}
}
}
}
先看下这个布局结构
当子组件滑动的时候,自定义的ScrollView(父容器)会首先收到子组件的回调,在onPreScroll
中处理决定是否要消费事件:
- 假设在
onPreScroll
中,父容器消费了全部的滑动距离,那么内部的LazyColumn
就不能滑动了; - 默认不处理
onPreScroll
,在子组件不能再滑动的时候,就继续滑动父容器,需要做下面的逻辑。
kotlin
val connection = remember {
object : NestedScrollConnection{
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
Log.d(TAG, "onPostScroll: consumed $consumed available $available")
//子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
currentY.value += available.y.toInt()
return available
}
}
}
因为子组件已经无法滑动了,因此所有的事件子组件不再消费,从而在onPostScroll
中会将事件原封不动的回调给父容器,父容器从而消费事件继续滑动。
如果把定义的ScrollView放在其他的容器中,那么其自身就会成为子组件,会执行draggable
中的嵌套滑动逻辑。