Compose 实践与探索十六 ——滑动检测与嵌套滑动

在传统的 View 体系中做自定义触摸反馈时,通常可能会重写 View 的 onTouchEvent() 与 ViewGroup 的 onInterceptTouchEvent(),少数情况需要更深入地去重写 dispatchTouchEvent()。当然,原生也提供了上层 API 来简化手势检测,比如 GestureDetectorCompat 与 ScaleGestureDetectorCompat。

Compose 的情况也是类似的,底层可以通过在 pointerInput() 内调用 awaitEachGesture(),在后者内部调用 awaitPointerEvent() 获得触摸事件:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        // 获得一个触摸事件
        val event = awaitPointerEvent()
    }
}

上层 API 则提供了 clickable() 与 combinedClickable() 来检测点击事件,scrollable() 与 draggable() 检测滑动手势。下面我们先来介绍这些检测滑动手势的 API。

我们在讲 Modifier 时讲过如下与手势检测相关的 Modifier:

Modifier.clickable { }
Modifier.combinedClickable { }
Modifier.pointerInput {
 detectTapGestures { }
}

本节就不再对以上内容再做过多的介绍了。

1、一维滑动检测

滑动手势检测有两个常用的 API scrollable() 与 draggable(),后者是前者的底层支撑。

1.1 draggable()

先了解 draggable() 各个参数的含义:

/**
* 为单个方向的 UI 元素配置触摸拖动。将拖动距离报告给 DraggableState,允许用户根据拖动增量做出反应
* 并更新它们的状态。这个组件的常见用例是当您需要能够在屏幕上的组件内拖动某物并通过一个浮点值表示该
* 状态时。如果您需要控制整个拖动流程,请考虑使用 pointerInput,配合像 detectDragGestures 这样的
* 辅助函数。如果您正在实现滚动/快速滑动行为,请考虑使用 scrollable。
* 参数:
* state - DraggableState 可拖动对象的状态。定义了用户端逻辑如何解释拖动事件
* orientation - 拖动的方向
* enabled - 是否启用拖动,是决定 Modifier 是否生效的一个条件性的、临时的开关
* interactionSource - MutableInteractionSource,用于在拖动时发出 DragInteraction.Start
* startDragImmediately - 当设置为 true 时,可拖动对象将立即开始拖动,并阻止其他手势检测器对
* “按下”事件做出反应(以阻止组合的基于按压的手势)。这旨在允许最终用户通过按压在动画小部件上“捕捉”它。
* 当您拖动的值正在稳定/动画化时,设置此选项非常有用
* onDragStarted - 当拖动即将在起始位置开始时将调用的回调,允许用户暂停并准备拖动,如果需要的话。
* 此挂起函数与可拖动范围一起调用,允许进行异步处理,如果需要的话
* onDragStopped - 当拖动完成时将调用的回调,允许用户根据速度做出反应并处理。此挂起函数与可拖动范围
* 一起调用,允许进行异步处理,如果需要的话
* reverseDirection - 反转滚动的方向,因此从顶部到底部的滚动将表现得像从底部到顶部,从左到右的滚动将
* 表现得像从右到左
*/
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。orientation 表示拖动方向这个没太多可说的,我们主要来了解 state。

state

在 Compose 中,所有可操作的组件或 Modifier 都会接收一个 state 参数用于手动操作界面。因为 Compose 是一个严格的声明式 UI 框架,开发者拿不到那些实际的组件对象,更不用提直接操作它们了。所以只能通过操作组件对象依赖的状态实现 UI 的改变。比如说对于 LazyColumn 而言:

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    userScrollEnabled: Boolean = true,
    content: LazyListScope.() -> Unit
)

可以通过修改它的 state 参数改变 UI 界面:

@Composable
fun LazyColumnSample() {
    val listState = rememberLazyListState()
    // animateScrollToItem() 是挂起函数,需要协程;scrollToItem() 是瞬间跳到指定 Item
    val scope = rememberCoroutineScope()
    Column {
        LazyColumn(Modifier.weight(1f), listState) {
            items(List(50) { it + 1 }) {
                Text("Number $it", Modifier.padding(5.dp))
            }
        }

        Button(
            onClick = { scope.launch { listState.animateScrollToItem(20) } },
            Modifier.height(40.dp)
        ) {
            Text("修改 LazyColumn 状态")
        }
    }
}

点击按钮操作 state,让列表以动画方式滚动到第 21 个列表项:

请添加图片描述

因此,修改组件依赖的 state 就是外界控制 UI 变化的一种手段。对于 draggable() 来说,它依赖的 state 类型为 DraggableState,我们可以通过 rememberDraggableState() 来提供 DraggableState 对象:

@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {
    val onDeltaState = rememberUpdatedState(onDelta)
    return remember { DraggableState { onDeltaState.value.invoke(it) } }
}

参数 onDelta 是一个回调函数,Float 参数就是这一段拖动相较于上一段拖动在指定方向上产生的位移量,指定方向可以是水平或垂直方向,在 draggable() 的第二个参数上指定:

Box(
    Modifier
        .size(50.dp)
        .background(Color.Red)
        .draggable(rememberDraggableState {
            println("本次拖动距离为:$it")
        }, Orientation.Horizontal)
)

向右滑动时输出正数,向左滑动时输出负数。

其他参数

重点说一下 interactionSource 等三个参数,没提到的参考 draggable() 注释上的参数说明即可。

interactionSource 是交互源,对 draggable() 修饰的范围进行触摸相关的状态监控的,比如说:

setContent {
    // 创建一个 InteractionSource 对象
    val interactionSource = remember { MutableInteractionSource() }
    // 监听 InteractionSource 所在的组件的拖拽状态
    val isDragged by interactionSource.collectIsDraggedAsState()
    Column {
        Box(
            Modifier
                .size(50.dp)
                .background(Color.Red)
                .draggable(
                    rememberDraggableState {
                        println("本次拖动距离为:$it")
                    },
                    Orientation.Horizontal,
                    interactionSource = interactionSource
                )
        )
        // 根据 Box 的拖拽状态显示不同的文字
        Text(if (isDragged) "拖动中" else "静止")
    }
}

InteractionSource 可以监听所在组件的交互状态,有四个函数可用:

在这里插入图片描述

分别监听组件的拖拽、聚焦、悬空、按压状态。我们举的例子是监听了组件的拖拽状态,效果如下:

请添加图片描述

startDragImmediately 参数指是否在用户手指按下后立即开始拖动流程,如设置为 false 则会在用户手指拖动一小段距离后再开始拖动流程。传统的 ViewGroup 也有这个选项,比如用户点击 RecyclerView 中的列表项时,可能会有一个很微小的拖动,如果 startDragImmediately 设置为 true,那么这个微小的拖动会导致列表产生相应的微小位移。但如果为 false,则 RecyclerView 不认为这个点击时产生的微小位移是拖动行为,进而不去滑动列表。设置为 false 用户体验会好一些。

onDragStarted 与 onDragStopped 是两个挂起回调函数,用于响应在开始拖拽与结束拖拽时的额外需求,比如开始拖动时震动一下。

示例

演示一下如何利用 draggable() 在单个方向上拖动组件:

@Composable
fun DraggableText() {
    var offsetX by remember { mutableStateOf(0f) }
    Box(Modifier.fillMaxSize()) {
        Text("Compose",
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), 0) }
                .draggable(rememberDraggableState { offsetX += it }, Orientation.Horizontal)
        )
    }
}

1.2 scrollable()

scrollable() 只是一个滑动检测工具,不具备 verticalScroll() 与 horizontalScroll() 那种可以让组件具有滑动功能的效果,就像在传统 View 体系下为一个不具备滑动功能的组件在外面套上了一个 ScrollView。但 verticalScroll() 与 horizontalScroll() 底层是通过 scrollable() 进行滑动检测的。

draggable() 是 scrollable() 的底层支撑,scrollable() 在 draggable() 的基础上,在滑动布局场景下增加了三个功能:

  1. 惯性滑动
  2. 嵌套滑动
  3. 滑动触边效果 overScroll

对于类似于 ScrollView、RecyclerView 这种布局组件而言,在滑动时具备惯性滑动、嵌套滑动,在滑动到边缘时展示触边效果才有用。但手指滑动的监测未必都是用于滑动布局,比如进度条一般是用不上新增的三个效果的,所以对于这类组件只需要 draggable() 提供的基础功能即可。

scrollable() 的用法与 draggable() 相似,区别就在于 scrollable() 新增的三个功能,使用它们需要配置参数:

@ExperimentalFoundationApi
fun Modifier.scrollable(
    state: ScrollableState, // 滚动状态,包含嵌套滑动
    orientation: Orientation,
    overscrollEffect: OverscrollEffect?, // 滚动触边效果
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null, // 惯性滑动
    interactionSource: MutableInteractionSource? = null
)

第一个参数 state 的类型是 ScrollableState,该类型支持 scrollable() 实现嵌套滑动。在指定这个参数时,可以通过 rememberScrollableState() 创建一个 ScrollableState 对象,需要在该函数的 lambda 表达式返回一个 Float 值表明自己消耗了多少滚动距离:

Modifier.scrollable(
    rememberScrollableState {
        println("滚动了 $it 个像素")
        it // 必须把消耗了多少滚动距离返回,因为要实现嵌套滑动功能
    },
    orientation = Orientation.Horizontal,
    overscrollEffect = ScrollableDefaults.overscrollEffect(),
)

第二个参数 overscrollEffect 用来指定滑动到边缘时的效果,该参数可以为空,为空时没有效果。可以通过 ScrollableDefaults 提供的 overscrollEffect() 指定一个默认效果。

第三个参数 flingBehavior 用于指定惯性滑动,它可以为空并且默认值就给了 null。但是 scrollable() 的底层实现 pointerScrollable() 会在传入的 flingBehavior 为 null 时给它指定一个默认值 ScrollableDefaults.flingBehavior():

object ScrollableDefaults {

    // 创建并且记住默认的 [FlingBehavior],它表示自然投掷(惯性滑动)的曲线
    @Composable
    fun flingBehavior(): FlingBehavior {
        val flingSpec = rememberSplineBasedDecay<Float>()
        return remember(flingSpec) {
            DefaultFlingBehavior(flingSpec)
        }
    }

    // 创建并且记住默认的 [OverscrollEffect],它用于展示组件滑动到边缘时的触边效果
    @Composable
    @ExperimentalFoundationApi
    fun overscrollEffect(): OverscrollEffect {
        return rememberOverscrollEffect()
    }
}

无论是惯性滑动还是触边效果,都可以使用 ScrollableDefaults 提供的默认效果即可。

1.3 swipeable()

swipeable() 与 scrollable() 一样,都对 draggable() 实现了定制,只不过场景不同。scrollable() 是用于横向或纵向的滑动布局组件,swipeable() 适用于有明确终点的滑动场景,比如滑动删除、侧滑菜单、滑动解锁等。

swipeable() 在 material 和 material2 包中可见,在 material3 中被隐藏了。因此只能使用由它实现的组件,比如滑动删除组件 SwipeToDismiss。

2、嵌套滑动

2.1 概述

在传统的 View 体系最初是不支持嵌套滑动的,像较原始的 ScrollView 与 ListView 都不支持嵌套滑动。后来随着需求的增加,Google 以 Jetpack 库的方式开始支持嵌套滑动,如 RecyclerView、NestedScrollView 等。Compose 作为 Jetpack 库中比较年轻的成员,对嵌套滑动有更全面、更完善的支持,比如 Modifier 的 scrollable()、LazyColumn/LazyRow 都支持嵌套滑动。

除了提供基础的 Modifier 函数与组件外,Compose 还实现了很多常见的嵌套滑动需求,比如 Scaffold 配合 LargeTopAppBar 可以实现顶部 AppBar 与页面内容的嵌套滑动。但无论框架提供了多完善的 API,在应用千变万化的需求中,总会遇到 API 无法直接实现的情况,这也是我们学习嵌套滑动的目的。

在 Compose 中可以通过 Modifier.nestedScroll() 自定义嵌套滑动,在介绍 nestedScroll() 之前,先介绍一下 Compose 嵌套滑动的整体逻辑。

Compose 的嵌套滑动由最内层的组件负责触摸事件的处理,它的外层组件并不直接负责触摸事件的处理,而是只接受它的子滑动组件发送过来的滑动事件的回调通知,以实现整体的嵌套滑动。

具体来说,每一个组件在进行滑动之前会先去询问它的父组件是否要消费这一段滑动距离,如果父组件不消费或者不完全消费,剩余的距离才会由该组件自己消费。如果自己没有完全消费掉这段距离,会第二次询问父组件是否消费。也就是说,子组件在滑动之前与滑动之后会对父组件进行两次询问,以应对父组件优先滑动与子组件优先滑动的不同情况。父组件需要开放子组件滑动之前与滑动之后两个回调函数,这样子组件在滑动前后会分别调用这两个接口通知父组件:子组件要进行滑动了,这样父组件可以根据自身需求决定是否在子组件之前或之后滑动。

2.2 Modifier.nestedScroll()

接下来看 nestedScroll() 的具体内容:

/**
* 修改元素以使其参与嵌套滚动层次结构。
* 有两种参与嵌套滚动的方式:作为滚动子元素,通过 NestedScrollDispatcher 将滚动事件传递到嵌套滚动链;
* 作为嵌套滚动链的成员,提供 NestedScrollConnection,当下面的另一个嵌套滚动子元素分派滚动事件时将调用它。
* 在链中以 NestedScrollConnection 的形式参与是强制性的,但滚动事件的分派是可选的,因为有些情况下,元素
* 希望参与嵌套滚动,但本身并不是可滚动的。
* 参数:
* connection - 与嵌套滚动系统连接以参与事件链接,当可滚动的后代正在滚动时接收事件
* dispatcher - 要附加到嵌套滚动系统上的对象,可以在其上调用 dispatch* 方法,以通知嵌套滚动系统中的
* 祖先发生的滚动
*/
fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier

每调用一次 nestedScroll() 就会向 Compose 的 UI 树中插入一个嵌套滑动的节点,该函数的两个参数就作为节点信息被保存。

如果把嵌套滑动看作一个链条,为了让这个链条中插入一个新的滑动组件后还能正常运转,被插入的滑动组件需要做三件事:

  1. 作为嵌套滑动的子组件,在滑动前和滑动后都去调用一下嵌套滑动父组件的相应的回调函数(由 NestedScrollDispatcher 实现)
  2. 作为嵌套滑动的父组件,在嵌套滑动子组件滑动前调用父组件的回调函数时,做出正确的处理:
    • 再向上,调用自己的嵌套滑动父组件的回调函数(nestedScroll() 已经实现)
    • 如果父组件不消费或者没有完全消费,则触发自身的滑动逻辑(由 NestedScrollConnection 实现)

其中,第 2 点中的第一条已经由 nestedScroll() 实现了,因此完成余下的两件事即可实现自定义的嵌套滑动。

下面我们通过示例来说明如何实现。首先准备一个支持滑动但不支持嵌套滑动的组件 Column,然后在该组件内部添加一个 LazyColumn 作为嵌套滑动的内部组件:

@Composable
fun NestedScrollSample() {
    var offsetY by remember { mutableStateOf(0f) }
    Column(
        Modifier
            .offset { IntOffset(0, offsetY.roundToInt()) }
        	// draggable() 没支持嵌套滑动
            .draggable(rememberDraggableState { offsetY += it }, Orientation.Vertical)
    ) {
        for (i in 1..10) {
            Text("第 $i 项")
        }
        LazyColumn(Modifier.height(50.dp).background(Color.Yellow)) {
            items(5) {
                Text("内部 List - 第 $it 项")
            }
        }
    }
}

然后我们给 Column 的 Modifier 加上 nestedScroll(),使其变为一个支持嵌套滑动的组件,主要问题在于如何提供 nestedScroll() 的两个参数 NestedScrollConnection 和 NestedScrollDispatcher。

NestedScrollConnection 会让组件作为父组件去响应子组件滑动时,父组件应该去做哪些事。对于我们的例子来说,当子组件 LazyColumn 滑动时,我们是优先让子组件滑动,子组件滑动之后如果有未消费完的距离进行二次询问时,Column 作为父组件才进行消费,因此在实现 NestedScrollConnection 时要重写 onPostScroll():

	val connection = remember {
        object : NestedScrollConnection {
            // onPostScroll() 负责子组件滑动之后父组件做出的对应处理,如果父组件需要
            // 在子组件滑动之前进行滑动的话,需要重写 onPreScroll()
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                offsetY += available.y
                // 返回消耗了多少滑动距离
                return available
            }
        }
        // 惯性滑动可以用 ScrollableDefaults.flingBehavior().performFling()
    }

NestedScrollConnection 接口内实际定义了四个函数,分别是 onPreScroll()、onPostScroll()、onPreFling() 与 onPostFling(),分别用于实现子组件滑动前、子组件滑动后、子组件惯性滑动前、子组件惯性滑动后,父组件是否消费以及如何消费滑动距离的逻辑。如果实际需求中对惯性滑动也有要求,可以使用上节讲过的 ScrollableDefaults.flingBehavior() 获取一个默认行为的 FlingBehavior,再调用它的 performFling() 进行惯性滑动。

以上是对 nestedScroll() 的第一个参数 NestedScrollConnection 的讲解。第二个参数 NestedScrollDispatcher 要做的就是在子组件滑动前与滑动后回调父组件对应的滑动函数,将处理权交给父组件,并根据父组件的滑动结果做出相应的处理:

	// 创建一个 NestedScrollDispatcher 对象
	val dispather = remember { NestedScrollDispatcher() }
	Column(
        Modifier
            .offset { IntOffset(0, offsetY.roundToInt()) }
            .draggable(rememberDraggableState {
                // 子组件滑动前,先询问父组件是否滑动
                val consumed = dispather.dispatchPreScroll(Offset(0f, it), NestedScrollSource.Drag)
                // 子组件滑动
                offsetY += it - consumed.y
                // 子组件滑动后,再次询问父组件是否滑动
                dispather.dispatchPostScroll(Offset(0f, it), Offset.Zero, NestedScrollSource.Drag)
            }, Orientation.Vertical)
            .nestedScroll(connection, dispather)
    )

dispatchPreScroll() 有两个参数 available 与 source:

	/**
	* 触发预滚动传递。这会触发所有祖先的 NestedScrollConnection.onPreScroll,使它们有可能在需要时
	* 预先消费增量。
	* 参数:
	* available - 从滚动事件中获得的增量
	* source - 滚动事件的来源
	* 返回所有祖先在链中预先消耗的总增量。此增量对于此节点不可用,因此它应相应地调整消耗。
	*/
	fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return parent?.onPreScroll(available, source) ?: Offset.Zero
    }

了解 dispatchPreScroll() 的参数与返回值:

  • available 表示子组件传过来的本次可以滑动的偏移总量,在这个嵌套滑动链上的所有祖先本次预滑动的偏移量不能超过这个值。在例子中,这个参数传的是 Offset(0f, it),表示把垂直方向上本次可以滑动的所有增量都给了父组件
  • source 表示滑动事件的来源,常见的来源有 Drag 滑动、Fling 惯性滑动两种
  • 返回值 Offset 表示父组件消费了多少距离,由于我们的例子中没有让 NestedScrollConnection 重写 onPreScroll(),因此 dispatchPreScroll() 就没有消费,所以返回 0

接下来就是子组件滑动,这里是用 offsetY += it - consumed.y 让子组件消费了所有距离。因为 consumed.y 是 0,那么 offsetY 的增量就是本次所有的滑动增量 it。

最后再调用 dispatchPostScroll() 再次询问父组件是否进行滑动,它有三个参数,第一个是子组件消费了多少距离,第二个参数是给父组件剩余的可滑动距离是多少。由于前面已经让子组件消费了所有距离,因此第一个参数填子组件消费掉的 Offset(0f, it),第二个参数填剩余可滑动距离,实际上是 0,也即 Offset.Zero。

完整的代码如下:

@Composable
fun NestedScrollSample() {
    var offsetY by remember { mutableStateOf(0f) }
    val dispather = remember { NestedScrollDispatcher() }
    val connection = remember {
        object : NestedScrollConnection {
            // onPostScroll() 负责子组件滑动之后父组件做出的对应处理,如果父组件需要
            // 在子组件滑动之前进行滑动的话,需要重写 onPreScroll()
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                offsetY += available.y
                // 返回消耗了多少滑动距离
                return available
            }
        }
    }
    Column(
        Modifier
            .offset { IntOffset(0, offsetY.roundToInt()) }
            .draggable(rememberDraggableState {
                val consumed = dispather.dispatchPreScroll(Offset(0f, it), NestedScrollSource.Drag)
                offsetY += it - consumed.y
                dispather.dispatchPostScroll(Offset(0f, it), Offset.Zero, NestedScrollSource.Drag)
            }, Orientation.Vertical)
            .nestedScroll(connection, dispather)
    ) {
        for (i in 1..10) {
            Text("第 $i 项")
        }
        LazyColumn(
            Modifier
                .height(50.dp)
                .background(Color.Yellow)) {
            items(5) {
                Text("内部 List - 第 $it 项")
            }
        }
    }
}

效果如下:

请添加图片描述

3、二维滑动检测

Compose 没有提供可以直接检测二维滑动的 Modifier 函数,因此我们只能用更底层的函数来实现这个功能。

首先调用 Modifier.pointerInput(),它是一个非常底层的函数,可以做最底层的触摸检测,拿到最基础的触摸事件,从而做最精细的触摸手势的识别与算法的定制。并且它内部也提供了常用的手势识别函数,比如与拖拽相关的有如下四种:

在这里插入图片描述

虽然看起来是 4 组 8 个函数,但实际上同名的指向是同一个函数,只不过调用方式不同。以 detectDragGestures() 为例:

/**
* 等待指针按下和任何方向上的触摸阈值,然后对每个拖动事件调用 onDrag 的手势检测器。它遵循
* awaitTouchSlopOrCancellation 的触摸阈值检测,但一旦触摸阈值被越过,它将自动消耗位置变化。
* 当通过最后已知的指针位置传递触摸阈值时,将调用 onDragStart。当所有指针都弹起时将调用 onDragEnd,
* 并且如果另一个手势消耗了指针输入,则将调用 onDragCancel,取消这个手势。
*/
suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

它的前三个参数都提供了默认值,而最后一个 onDrag 是唯一必填的参数。因此假如你只想指定 onDrag,那么就对应图中的第一种 lambda 的调用方式,选择后 AS 会自动将参数填好;如果还需要提供其他参数,就使用第二种调用有括号的方式。

我们重点来看 onDrag 这个回调函数的两个参数:

  • change:更底层的类,封装了触发这一次滑动事件背后的触摸事件的那根手指相关的信息
  • dragAmount:拖拽的偏移量,类型是 Offset 可以表示二维的偏移量

Compose 将 Android 原生的触摸事件封装成 Compose 的触摸事件,并且对这个触摸事件进行分析,分析后将其封装为滑动事件,但它底层还是 Compose 的触摸事件,所以才称为“滑动事件背后的触摸事件”。然后,Compose 也支持多点触控,只不过 Compose 的多点触控监控的是最先落下的手指,而 Android 原生多点触控监测的是最后落下的手指。造成的不同体验就是,原生的可以两个手指轮番滑动,而 Compose 只有在先落下的手指抬起后,才能由后落下的手指继续滑动。

但不论是 Compose 还是原生,它们都只监测正在滑动中的手指,而 change 就含有这个手指的信息,包括手指的 ID 以及位置信息。因此 dragAmount 可以视为一个便捷的冗余信息,它所表示的拖拽的偏移量是可以通过 change 内包含的信息计算出来的。

然后我们再说说这一组函数中的其他三个函数:

  • detectHorizontalDragGestures() 与 detectVerticalDragGestures() 是在水平与垂直方向上的一维滑动监测
  • detectDragGesturesAfterLongPress() 是监测在长按之后的二维滑动手势

最后再来说说 pointerInput() 配合 detectDragGestures() 与 draggable() 的区别。二者都是做滑动监测的,所以代码没有本质上的区别,甚至在最底层的代码上(如拖拽的判定代码)使用相同的函数,它们的主要区别在于定位不同:

  • draggable() 是较上层、更高级的函数,需要实现相同功能时,用 draggable() 写起来更方便
  • detectDragGestures() 是较底层、更基础的函数,能提供更多底层信息
### 关于 Android 嵌套滚动组件的实现使用 在 Android 开发中,`nested scrolling` 是一种常见的交互模式,允许子视图在其父容器内执行滚动操作的同时触发父容器的滚动行为。这种功能通常用于 `CoordinatorLayout` 和 `NestedScrollView` 的组合场景。 #### 实现嵌套滚动的关键类和方法 1. **LayoutManager 负责实际的滚动逻辑** RecyclerView 的布局管理器 (`LayoutManager`) 提供了创建平滑滚动的功能。如果需要自定义平滑滚动逻辑,则可以通过重写 `smoothScrollToPosition(RecyclerView, State, int)` 方法来实现[^1]。此方法接受目标位置作为参数,并通过状态对象提供额外的信息支持。 2. **启用嵌套滚动的支持** 如果希望某个 View 支持嵌套滚动,可以调用其 `startNestedScroll(int axes)` 方法。该方法接收一个整数类型的轴向标志位(如 `View.SCROLL_AXIS_VERTICAL` 或 `View.SCROLL_AXIS_HORIZONTAL`),表示当前视图参垂直或水平方向上的嵌套滚动。 3. **处理嵌套滚动事件** 当前视图接收到触摸事件时,会自动尝试分派这些事件给父级视图以完成嵌套滚动的行为。开发者也可以手动控制这一过程,例如通过覆盖 `onStartNestedScroll(View child, View target, int nestedScrollAxes)` 来决定是否拦截特定的嵌套滚动请求。 4. **Jetpack Compose 中的新方式** 随着 Jetpack Compose 的引入,传统的基于 XML 的 UI 构建逐渐被声明式的编程模型取代。尽管如此,在某些情况下仍然可能需要用到经典的嵌套滚动机制。对于现代应用开发而言,Compose 已经内置了许多高级别的手势处理器以及可组合项来简化复杂的交互设计[^2]。 5. **其他相关属性** 自 API Level 21 起新增了一个名为 `outlineProvider` 的常量字段,它主要用于定义视图轮廓并影响阴影效果等外观特性[^3]。虽然这并不直接涉及嵌套滚动本身,但在构建复杂界面时可能会间接关联到整体用户体验优化方面的工作。 ```java // 启动嵌套滚动的一个简单例子 if (!view.isNestedScrollingEnabled()) { view.setNestedScrollingEnabled(true); } view.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL); @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { super.onNestedPreScroll(target, dx, dy, consumed); // 处理预滚动阶段的数据消费情况 if(dy >0){ scrollDown(); consumed[1]=dy; } } @Override public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL)!=0; } ``` 上述代码片段展示了如何在一个自定义控件内部开启嵌套滚动能力,并且演示了基本的手势协调流程。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值