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() 是瞬间跳到指定 Itemval 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.*/@Composablefun 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@ExperimentalFoundationApifun 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.ydispather.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.rhkb.cn/news/37587.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Linux】Makefile秘籍

> &#x1f343; 本系列为Linux的内容&#xff0c;如果感兴趣&#xff0c;欢迎订阅&#x1f6a9; > &#x1f38a;个人主页:【小编的个人主页】 >小编将在这里分享学习Linux的心路历程✨和知识分享&#x1f50d; >如果本篇文章有问题&#xff0c;还请多多包涵&a…

LDAP从入门到实战:环境部署与配置指南(上)

#作者&#xff1a;朱雷 文章目录 一、LDAP 简介1.1. 什么是目录服务1.2. 什么是 LDAP1.3. LDAP的基本模型 二、Ldap环境部署2.1.下载软件包2.2.安装软件2.3.编辑配置文件2.4.启动服务 一、LDAP 简介 1.1. 什么是目录服务 目录是专门为搜索和浏览而设计的专用数据库&#xff…

《C++智能指针:建议使用 make_shared 代替 shared_ptr》

《C 智能指针&#xff1a;长达数十年的血泪史&#xff0c;一步步征服内存泄漏》-CSDN博客 shared_ptr<int> sp1(new int(10)); 这句代码实际存在两个内存开辟&#xff0c;一是开辟我们要托管的内存资源 &#xff0c;二是开辟引用计数的资源&#xff0c;引用技术也是new出…

代码随想录刷题day50|(回溯算法篇)131.分割回文串▲

目录 一、回溯算法基础知识 二、分割回文串思路 2.1 如何切割 2.2 判断回文 2.3 回溯三部曲 2.4 其他问题 三、相关算法题目 四、总结 一、回溯算法基础知识 详见&#xff1a;代码随想录刷题day46|&#xff08;回溯算法篇&#xff09;77.组合-CSDN博客 二、分割回文…

vivo 湖仓架构的性能提升之旅

作者&#xff1a;郭小龙 vivo互联网 大数据高级研发工程师 导读&#xff1a;本文整理自 vivo互联网 大数据高级研发工程师 郭小龙 在 StarRocks 年度峰会上的分享&#xff0c;聚焦 vivo 大数据多维分析面临的挑战、StarRocks 落地方案及应用收益。 在 即席分析 场景&#xff0c…

Springboot的jak安装与配置教程

目录 Windows系统 macOS系统 Linux系统 Windows系统 下载JDK&#xff1a; 访问Oracle官网或其他JDK提供商网站&#xff0c;下载适合Windows系统的JDK版本。网站地址&#xff1a;Oracle 甲骨文中国 | 云应用和云平台点击进入下滑&#xff0c;点击进入下载根据自己的系统选择&…

力扣算法Hot100——128. 最长连续序列

题目要求时间复杂度为O(n)&#xff0c;因此不能使用两次循环匹配。 首先使用 HashSet 去重&#xff0c;并且 HashSet 查找一个数的复杂度为O(1)外循环还是遍历set集合&#xff0c;里面一重循环需要添加判断&#xff0c;这样才不会达到O( n 2 n^2 n2)判断是否进入最长序列查找循…

BlockChain.java

BlockChain 区块链&#xff0c;举个栗子 注意啦&#xff0c;列子里面的hashcode相等&#xff0c;但是字符串是不一样的哦&#xff0c;之前有记录这个问题 String.hashCode()-CSDN博客

visual studio 中导入 benchmark

法一 1.visual studio 中导入 benchmark.lib Shlwapi.lib这两个库 2.预处理宏 BENCHMARK_STATIC_DEFINE vs导入参考 错误提示 没有加入 BENCHMARK STATIC_DEFINE error LNK2001: 无法解析的外部符号 “__declspec(dllimport) int __cdecl benchmark::internal::InitializeS…

java基础之windows电脑基础命令

windows电脑基础命令 windows电脑基础命令快捷键和功能键键盘功能键B:键盘快捷键 DOS命令行的进入方式xp下如何打开DOS控制台&#xff1f;win7下如何打开DOS控制台&#xff1f;win8下如何打开DOS控制台 DOS命令讲解 黑窗口编译文件使用黑窗口运行java程序 windows电脑基础命令 …

Java 第十一章 GUI编程(3)

目录 内部类 内部类定义 内部类的特点 匿名内部类 格式&#xff1a; 内部类的意义 实例 内部类 ● 把类定义在另一个类的内部&#xff0c;该类就被称为内部类。 ● 如果在类 Outer 的内部再定义一个类 Inner&#xff0c;此时类 Inner 就称为内部类 &#xff08;或称为嵌…

uniapp 实现的下拉菜单组件

采用 uniapp 实现, 是一款具备丝滑折叠、展开动画的下拉菜单&#xff0c;支持 vue2、vue3&#xff1b;适配 web、H5、微信小程序&#xff08;其他平台小程序未测试过&#xff0c;可自行尝试&#xff09; 可到插件市场下载尝试&#xff1a; https://ext.dcloud.net.cn/plugin?i…

【一维前缀和与二维前缀和(简单版dp)】

1.前缀和模板 一维前缀和模板 1.暴力解法 要求哪段区间&#xff0c;我就直接遍历那段区间求和。 时间复杂度O(n*q) 2.前缀和 ------ 快速求出数组中某一个连续区间的和。 1&#xff09;预处理一个前缀和数组 这个前缀和数组设定为dp&#xff0c;dp[i]表示&#xff1a;表示…

ubuntu部署运行xinference全精度对话deepseek本地部署图文教程

前置环境搭建劳请移步往期 source activate 自己环境名启动python3.12环境安装xinference&#xff0c; 按教程敲命令&#xff0c;wheel包与wsl的通用&#xff0c;pip install 包名。 vllm引擎&#xff0c;transform引擎也会顺带自动装上了。 后续操作请参照往期教程。本地部署模…

Python 面向对象三大特性深度解析

一、封装&#xff08;Encapsulation&#xff09; 1. 私有化实现 class BankAccount:def __init__(self, account_holder, balance0):self.__holder account_holder # 双下划线私有属性self.__balance balance# 公有方法访问私有属性def deposit(self, amount):if amount &…

星越L_陡坡缓降使用讲解

目录 1.陡坡缓降 1.陡坡缓降 中控屏下滑-点击陡坡缓降功能 35km/h以下时生效。35km/h-60km/h该功能暂停 60km/h以上该功能关闭

多路FM调频广播解调器:多路电台FM广播信号一体化解调处理方案

多路FM调频广播解调器&#xff1a;多路电台FM广播信号一体化解调处理方案 支持OEM型号开放式协议支持二次开发设计 北京海特伟业科技有限公司任洪卓发布于2025年3月21日 在信息传播领域&#xff0c;FM调频广播媒体以其独特的优势持续发挥着重要作用。为了应对日益增长的多路…

报错 - redis - Unit redis.service could not be found.

报错&#xff1a; Unit redis.service could not be found.Could not connect to Redis at 127.0.0.1:6379: Connection refused解决方法&#xff1a; 检查状态、有必要的话 重新安装 Linux 上查看状态 systemctl status redis显示以下内容&#xff0c;代表正常服务 出现下面…

Guava:Google开源的Java工具库,太强大了

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家&#xff0c;历代文学网&#xff08;PC端可以访问&#xff1a;https://literature.sinhy.com/#/?__c1000&#xff0c;移动端可微信小程序搜索“历代文学”&#xff09;总架构师&#xff0c;15年工作经验&#xff0c;精通Java编…

Pytorch中layernorm实现详解

平时我们在编写神经网络时&#xff0c;经常会用到layernorm这个函数来加快网络的收敛速度。那layernorm到底在哪个维度上进行归一化的呢&#xff1f; 一、问题描述 首先借用知乎上的一张图&#xff0c;原文写的也非常好&#xff0c;大家有空可以去阅读一下&#xff0c;链接放…