当前位置: 首页 > article >正文

Compose 实践与探索十五 —— 自定义触摸

1、自定义触摸与一维滑动监测

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

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

这里对以上内容就不再赘述了,直接去讲解更复杂的 Modifier 实现更复杂的触摸反馈效果。

在传统的 View 体系中,在自定义触摸反馈的内容时,对于 View 我们通常都是重写它的 onTouchEvent(),对于 ViewGroup 可能还需要重写 onInterceptTouchEvent(),极少数时候会更深入地去重写 dispatchTouchEvent()。当然,原生也提供了较为上层的 API 来简化手势检测,比如 GestureDetectorCompat 与 ScaleGestureDetectorCompat。

而在 Compose 中,情况也是类似的。在 pointerInput() 内调用 awaitEachGesture(),在其内部通过 awaitPointerEvent() 可以获得触摸事件:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        // 循环调用 awaitPointerEvent() 可获得每一个触摸事件
        val event = awaitPointerEvent()
    }
}

这种用法偏底层,Compose 在上层提供了一些类似于 GestureDetectorCompat 的非常完备的 API,比如上面提到的 clickable() 与 combinedClickable() 就是点击相关的 API,下面我们逐步介绍滑动手势相关的 API。

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

1.1 draggable()

先看 draggable():

/**
* 为单个方向的 UI 元素配置触摸拖动。将拖动距离报告给 DraggableState,允许用户根据拖动增量做出反应
* 并更新它们的状态。这个组件的常见用例是当您需要能够在屏幕上的组件内拖动某物并通过一个浮点值表示该
* 状态时。如果您需要控制整个拖动流程,请考虑使用 pointerInput,配合像 detectDragGestures 这样的
* 辅助函数。如果您正在实现滚动/快速滑动行为,请考虑使用 scrollable。
* 参数:
* state - DraggableState 可拖动对象的状态。定义了用户端逻辑如何解释拖动事件
* orientation - 拖动的方向
* enabled - 是否启用拖动
* 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。在 Compose 中,所有可操作的组件或 Modifier 都会接收一个 state 参数用于手动操作界面。因为 Compose 是一个严格的声明式 UI 框架,开发者是拿不到那些实际的 UI 对象的,更无法直接操作它们。但操作不了 UI 对象本身,不意味着也操作不了界面。我们可以通过操作 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)
)

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

enabled 控制 draggable() 这个 Modifier 是否生效,是一个条件性临时的开关,符合某些条件时就生效,否则就失效。

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 是两个挂起回调函数,用于响应在开始拖拽与结束拖拽时的额外需求,比如开始拖动时震动一下。

reverseDirection 将手势反向。

写一个简单例子,在一个方向上拖动文字。因为拿不到 Text 组件本身,因此要通过修改它的位移实现:

@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() 支持的嵌套滑动就是通过 ScrollableState 实现的。在指定这个参数时,可以通过 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 {

    /**
     * Create and remember default [FlingBehavior] that will represent natural fling curve.
     */
    @Composable
    fun flingBehavior(): FlingBehavior {
        val flingSpec = rememberSplineBasedDecay<Float>()
        return remember(flingSpec) {
            DefaultFlingBehavior(flingSpec)
        }
    }

    /**
     * Create and remember default [OverscrollEffect] that will be used for showing over scroll
     * effects.
     */
    @Composable
    @ExperimentalFoundationApi
    fun overscrollEffect(): OverscrollEffect {
        return rememberOverscrollEffect()
    }
}

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

1.3 swipeable()

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

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

2、嵌套滑动与 nestedScroll()

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

并且,Compose 对于很多常见的嵌套滑动需求都提供了实现。比如 Scaffold 配合 LargeTopAppBar 可以实现顶部 AppBar 与页面内容的嵌套滑动。但应用的需求千变万化,总会遇到 Compose 没有提供现成实现的嵌套滑动的需求,这就是本节课要学习嵌套滑动的目的。

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

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

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

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

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

每次调用 nestedScroll(),都会向 Compose 的 UI 树内插入一个嵌套滑动的节点,而 nestedScroll() 的两个参数,就是为该节点提供的信息。

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

  1. 作为嵌套滑动的子组件,在滑动前和滑动后都去调用一下嵌套滑动父组件的相应的回调函数(由 NestedScrollDispatcher 实现)
  2. 作为嵌套滑动的父组件,在嵌套滑动子组件滑动前调用父组件的回调函数时,做出正确的处理:
    • 再向上,回调自身的嵌套滑动的父组件的回调函数
    • 如果父组件不消费或者没有完全消费,则触发自身的滑动逻辑(由 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 滑动时,我们是优先让子组件滑动,子组件滑动之后如果有未消费完的距离进行二次询问时,我们作为父组件才进行消费,因此在实现 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
    }

available 表示子组件传过来的本次可以滑动的偏移总量,在这个嵌套滑动链上的所有祖先本次预滑动的偏移量不能超过这个值。在例子中,这个参数传的是 Offset(0f, it),表示把垂直方向上本次可以滑动的所有增量都给了父组件。

source 表示滑动事件的来源,常见的来源有 Drag 滑动、Fling 惯性滑动两种。

dispatchPreScroll() 的返回值就是父组件消费了多少距离,由于我们的例子中没有让 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(),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,那么就对应图中的第一种 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() 是较底层、更基础的函数,能提供更多底层信息

4、多指手势

多指手势可以分为两类:

  1. 自定义的多指手势识别:自己分析触摸到屏幕上的每一根手指的滑动轨迹,然后识别对应的手势
  2. 利用 API 处理预设好的手势

本节讲解第 2 种,下节介绍第 1 种。

Compose 提供了三种多指手势的识别:移动、放缩与旋转,它们都存在于 detectTransformGestures 函数中,该函数也需要在 pointerInput() 内使用。我们先来看该函数的参数:

/**
* 一个用于旋转、平移和缩放的手势检测器。一旦达到触摸阈值,用户可以使用旋转、平移和缩放手势。
* 当发生旋转、缩放或平移中的任何一种手势时,将调用 onGesture,传递旋转角度(以度为单位)、
* 缩放比例因子和像素偏移量。每个改变都是前一次调用和当前手势之间的差异。在触摸阈值之后,这将
* 消耗所有位置变化。onGesture 还将提供所有已按下指针的中心点。
*
* 如果 panZoomLock 设置为 true,则只有在检测到旋转的触摸阈值之前才允许旋转,然后才能进行平移
* 或缩放动作。否则,将检测到平移和缩放手势,但不会检测到旋转手势。如果 panZoomLock 设置为 false,
* 则一旦触摸阈值被触发,将检测到所有三种手势。
*/
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)

第一个参数 panZoomLock 是一个开关,分为两种情况:

  • 当它为 false 时,三种手势可以同时识别
  • 当它为 true 时,如果先识别到旋转操作,那么就不会再监测滑动和缩放;如果先监测到滑动或缩放,那么就不会再监测旋转。相当于是把滑动和缩放放在一组,旋转单独放在另外一组,只监测先触发的那组操作

onGesture 参数是一个回调函数,它的参数含义如下:

  • centroid:所有按下手指的中心点。这是一个辅助参数,需要配合后面三个参数使用
  • pan:位移参数,表示中心点 centroid 在这一时刻与上一时刻的位置偏移量
  • zoom:这一时刻与上一时刻相比的放缩倍数
  • rotation:这一时刻与上一时刻相比的旋转角度

http://www.kler.cn/a/594952.html

相关文章:

  • Prometheus Exporter系列-Postgres_Exporter一键部署
  • Java 大视界 -- Java 大数据分布式计算中的通信优化与网络拓扑设计(145)
  • Python 单例模式的 5 种实现方式:深入解析与最佳实践
  • 如何给商品一键换色?图生生AI,告别繁琐修图
  • 【Dify平台】Function Call 模式模式和ReAct模型有什么不同?
  • Compose 实践与探索十六 —— 与传统的 View 系统混用
  • Q2 电商订单数据分析优化
  • QT Quick(C++)跨平台应用程序项目实战教程 3 — 项目基本设置(窗体尺寸、中文标题、窗体图标、可执行程序图标)
  • uniapp整合SQLite(Android)
  • 集成学习(下):Stacking集成方法
  • MANISKILL3:GPU 并行机器人模拟和渲染,用于通用的具身AI
  • 贪心算法(10)(java)跳跃游戏
  • hive的基础函数>>集合函数, 条件函数, 类型转换函数
  • GEO:在AI时代抢占DeepSeekC位?
  • 深度学习项目--基于ResNet和DenseNet混合架构网络论文的复现(pytorch实现)
  • 【大模型理论篇】CogVLM:多模态预训练语言模型
  • python3.13.2安装详细步骤(附安装包)
  • 【设计模式】C++ 单例模式总结与最佳实践
  • OO_Unit1
  • .net core集成MQTT服务端