Compose 中手势处理在 Modifier 中设置,一种是 xxxable 类似 View 中的 setOnXxxListener,一种是 pointInput 类似View 中的 setOnTouchListener。后者相比前者 Api 级别低,众所周知越高级的 Api 用着越方便,但是限制性强。低级 Api 灵活,但用起来麻烦,使用哪种类型的 Api 就需要使用者自己去选择了。 官方文档
“able” Api
点击
clickable 设置点击监听,但仅可以设置点击监听。想要监听双击和长按的话需要使用 combinedClickable ,它同样也可以监听点击事件。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableTest() {
Column {
Box(modifier = Modifier.fillMaxWidth().height(80.dp).background(Color.Magenta)
.clickable {
Log.e(TAG, "ClickableTest: onClick")
}
) {}
Box(modifier = Modifier.fillMaxWidth().height(80.dp).background(Color.Cyan)
.combinedClickable(
onClick = {
Log.e(TAG, "ClickableTest: combinedClickable onClick", )
},
onDoubleClick = {
Log.e(TAG, "ClickableTest: combinedClickable onDoubleClick", )
},
onLongClick = {
Log.e(TAG, "ClickableTest: combinedClickable onLongClick", )
},
)
) {}
}
}
拖拽
监听拖拽手势,但只能监听水平或垂直中的一个方。每次拖动会调用 DraggableState 中的 onDelta 回调,并传入差值。该监听只能监听拖拽的差值并不能改变组件显示的位置,改变位置需要配合 Modifier.offset 一起完成。
@Composable
fun DraggableTest() {
var xOffset by remember { mutableStateOf(0f) }
val draggableState = rememberDraggableState(onDelta = {
Log.e(TAG, "DraggableTest: onDelta:$it", )
xOffset += it
})
Box(modifier = Modifier.fillMaxSize().background(Color.Gray)) {
Text(
modifier = Modifier
.align(Alignment.Center)
.offset { IntOffset(xOffset.roundToInt(),0) }
.background(Color.Magenta)
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
onDragStarted = { Log.e(TAG, "DraggableTest: onDragStarted",) },
onDragStopped = { Log.e(TAG, "DraggableTest: onDragStopped",) }
)
,
text = "<<<- or ->>>"
)
}
}
这里又出现的 Modifie 有序的例子, 如果把 offset 放到 background 之后,会出现文字位置改变,但背景位置不变,且拖拽触发的位置在背景上而不再文字上。
滚动
组件内部内容位置的变化由滚动手势来完成,其内部也是由拖拽手势来实现的,区别在于对手势差量的使用上,一个控制内部内容的位置,一个控制控件在其父控件中的位置。
verticalScroll/horizontalScroll 内部使用 scrollable 实现,scrollable 内部又是使用 draggable 实现的。
verticalScroll/horizontalScroll 使用 rememberScrollState 创建 ScrollState ,就可以实现滚动功能。
scrollable 使用 rememberScrollableState 创建 ScrollableState 并指定滚动方向,但它只能实现监听差值,并没有实现滚动功能。
consumeScrollDelta 的返回值,代表处理滚动消耗了多少差值,例如已经滚动到顶/底部时返回 0 表示并没有消耗这个差值,在中间部位时返回 delta 表示差值已经全部被消耗掉。在嵌套滚动时利用这个返回值就可以知道外部需要滚动的距离,嵌套滚动功能 Compose 是默认支持的。
@Composable
fun ScrollingTest(){
var deltaY by remember { mutableStateOf(0f) }
val scrollableState = rememberScrollableState(consumeScrollDelta = { delta ->
deltaY = delta
0f
})
Column {
Text(
modifier = Modifier.width(50.dp).height(200.dp).padding(0.dp, 16.dp)
.align(Alignment.CenterHorizontally).verticalScroll(rememberScrollState()),
text = "↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓"
)
Text(
modifier = Modifier.width(100.dp).align(Alignment.CenterHorizontally)
.horizontalScroll(rememberScrollState()),
text = "←←←←←←←←←←←←←←←←←←←←←←←→→→→→→→→→→→→→→→→→→→→→→→→→→→"
)
Text(
modifier = Modifier.padding(0.dp, 16.dp).align(Alignment.CenterHorizontally)
.scrollable(state = scrollableState, orientation = Orientation.Vertical),
text = "Scrollable Delta:$deltaY"
)
}
}
滑动
swipeable 处理滑动。如果你现在用的是 Material 3 恭喜你不用学了 L('ω')┘三└('ω’)」 ,官方说在 M3 中还存在问题所以 Api 没有开放。不急就等等 o( ̄︶ ̄)o 。
swipeable 在使用时可以设置一个锚点和一个阈值,当滑动到达阈值时即便松手就会有一个自动停靠的看效果。
将依赖切换成 Material 使用 swipeable 制作一个简单的滑动删除 item
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableTest() {
val deleteWidth = 80.dp;
val deleteWidthPx = with(LocalDensity.current) { deleteWidth.toPx() }
val swipeableState = rememberSwipeableState(initialValue = 0)
//以组件左顶点为参照, 滑动开始的坐标 to 0 , 滑动结束的位置 to 1
val anchors = mapOf( 0f to 0 , -deleteWidthPx to 1)
Box(modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 4.dp)
.background(Color.Red)
.swipeable(
state = swipeableState,
orientation = Orientation.Horizontal,
anchors = anchors,
//自动停靠到 anchors 锚点的阈值
thresholds = { _, _ -> FractionalThreshold(0.3f) }
)
) {
Button(
modifier = Modifier.align(Alignment.CenterEnd).width(deleteWidth).height(50.dp),
shape = RectangleShape, onClick = {},
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent),
elevation = null
){ Text(text = "删除", color = Color.White) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.offset {
//根据滑动的值来限制只能向左滑动
val offsetX = swipeableState.offset.value.roundToInt()
IntOffset(0, 0).takeIf { offsetX >= 0 }
?: IntOffset(swipeableState.offset.value.roundToInt(), 0)
}
.background(Color.Magenta)
)
}
}
形变
用于处理平移、旋转、缩放这三种多点触控手势,仅能监测变化的值需要配合 graphicsLayer 来将变化应用到组件上。
@Composable
fun TransformableTest() {
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState {
zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
pointerInput
Modifier 中低级的手势处理 Api。
Modifier.pointerInput(key1: Any?,block: suspend PointerInputScope.() -> Unit)
Compose 中很多方法都会有这种 key + 代码块 参数的形式, 代码块内部用到外部变量时需要注意 key 的使用,key 变化代码块引用才会更新,如果代码块内部不需要更新可以直接使用常量 key 。
言归正传 PointerInputScope 中 提供了一些现成的手势检测方法拓展方法
- detectDragGestures
- detectDragGesturesAfterLongPress
- detectHorizontalDragGestures
- detectVerticalDragGestures
- detectTapGestures
- detectTransformGestures
看这名字都知道咋回事了吧,要说的只有两点
- 同一个 PointerInputScope 中只有第一个 detect 方法会生效
@Composable
fun PointerInputTest() {
var offset by remember { mutableStateOf(IntOffset.Zero) }
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.size(50.dp).offset { offset }.background(Color.Magenta).align(Alignment.Center)
.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
offset += IntOffset(dragAmount.x.roundToInt(), dragAmount.y.roundToInt())
}
//无效 监听不到 onTap onLongPress 手势
detectTapGestures(
onTap = {
Log.e(TAG, "PointerInputTest: onTap" )
},
onLongPress = {
Log.e(TAG, "PointerInputTest: onLongPress" )
}
)
}
)
}
}
但是我们想全都要啊 ,那就只能在组件外包个容器了,一个组件负责检测一个手势。
@Composable
fun PointerInputTest() {
var offset by remember { mutableStateOf(IntOffset.Zero) }
Box(modifier = Modifier.fillMaxSize()
.pointerInput(Unit){
detectTapGestures(
onTap = {
Log.e(TAG, "PointerInputTest: onTap" )
},
onLongPress = {
Log.e(TAG, "PointerInputTest: onLongPress" )
}
)
}
) {
Box(modifier = Modifier.size(50.dp).offset { offset }.background(Color.Magenta).align(Alignment.Center)
.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
offset += IntOffset(dragAmount.x.roundToInt(), dragAmount.y.roundToInt())
}
}
)
}
}
这个时候就出现了第二点
- 如果 onLongPress 不是 null ,长按后再拖动,事件会被长按事件消耗从而无法检测到拖动。
基于这个问题官方贴心的提供了 detectDragGesturesAfterLongPress 。
不用这些 detectXXX api 怎么在 Compose 中处理手势呢?我们下一章单独研究。
Interactions
用来监听交互的状态,从 Interaction 继承结构可以看出 Compose 提供了 拖拽、聚焦、悬停、按下四种交互处理,又分别为每种交互定义了不同的状态。
如果细心的话会发现 “ableApi” 的重载方法中有个 interactionSource 参数,这个参数就是提供给我们监听手势状态使用的。
以 clickable 为例
@Composable
fun InteractionTest() {
Box(modifier = Modifier.fillMaxSize()) {
val interactionSource = remember { MutableInteractionSource() }
//1 直接使用 api 来监听状态变化
val isPressed by interactionSource.collectIsPressedAsState()
//2 监听 interactions Flow 变化
var isPressed2 by remember { mutableStateOf(false) }
LaunchedEffect(interactionSource){
interactionSource.interactions.collect{
isPressed2 = when(it){
is PressInteraction.Press -> true
else -> false
}
}
}
Text(modifier = Modifier
.align(Alignment.Center)
// 设置 interactionSource
.clickable(interactionSource = interactionSource, indication = null) {},
text = "isPressed = $isPressed, isPressed2 = $isPressed2"
)
}
}
监听一种交互的状态通常用一种方法,除了 collectIsPressedAsState() 之外 Compose 还提供了 collectIsFocusedAsState()、 collectIsDraggedAsState()、 collectIsHoveredAsState()。
同时监听两种以上交互就要使用第二种方法了。
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
interactions.add(interaction)
}
is PressInteraction.Release -> {
interactions.remove(interaction.press)
}
is PressInteraction.Cancel -> {
interactions.remove(interaction.press)
}
is DragInteraction.Start -> {
interactions.add(interaction)
}
is DragInteraction.Stop -> {
interactions.remove(interaction.start)
}
is DragInteraction.Cancel -> {
interactions.remove(interaction.start)
}
}
}
}
val isPressedOrDragged = interactions.isNotEmpty()
交互状态的监听是因为在 Compose 底层处理的时候在响应的处理中发送了状态。自定义手势处理如果希望对外提供交互状态监听是需要自己实现的。
Clickable.kt
val pressInteraction = PressInteraction.Press(pressPoint)
interactionSource.emit(pressInteraction)
完善 Banner
Banner 自动轮播需要解决两个问题:
- 周期性延时滑动到下一页 ✔️
- 处理手势,有手势交互的时候取消自动翻页,手势交互结束重新开始计时
手势处理需要监听两种交互状态:
- Pager 滚动手势开始时取消自动轮播,结束时开启自动轮播 ✔️(PagerState.interactionSource)
- Banner 点击手势开始时取消自动轮播,结束时开启自动轮播
原以为给 clickable 添加 InteractionSource 再监听其状态就完事了,问题又出现了
先触发滚动开始后触发点结束,isAutoLoop 此时已经为 true 滚动结束后再设置 isAutoLoop true ,由于 isAutoLoop 值没有改变不会触发重组也不会运行自动轮播 Effect。
只能从点击事件本身入手了,点击事件用到了外部 item 变量这时候要注意 pointerInput key 的选择,还有长按后抬起不应该触发点击事件,最后代码奉上:
@OptIn(ExperimentalPagerApi::class)
@Composable
fun <T> Banner(
modifier: Modifier = Modifier,
items: List<T>,
onItemClick: ((T) -> Unit)? = null,
autoLoop:Boolean = true,
loopInterval:Long = 3000L,
activeIndicatorColor: Color = Color.Magenta,
inactiveIndicatorColor: Color = Color.Gray,
itemContent: @Composable (T) -> Unit
) {
val pagerState = rememberPagerState()
val scope = rememberCoroutineScope()
var isAutoLoop by remember { mutableStateOf(autoLoop) }
//手动滚动时取消自动翻页
LaunchedEffect(pagerState.interactionSource) {
pagerState.interactionSource.interactions.collect {
isAutoLoop = when (it) {
is DragInteraction.Start -> false
else -> true
}
}
}
if(items.isNotEmpty()){
// 重组时 pagerState.currentPage 发生变化就会重新执行
LaunchedEffect(pagerState.currentPage,isAutoLoop){
if (isAutoLoop){
delay(loopInterval)
val nextPageIndex = (pagerState.currentPage + 1) % items.size
scope.launch {
// animateScrollToPage
pagerState.animateScrollToPage(nextPageIndex)
}
}
}
}
HorizontalPager(
state = pagerState,
count = items.size,
modifier = Modifier
//!!!! 初始组合时 lambda 中 items 是其默认值是 empty,所以要以 item 为 key
.pointerInput(items) {
detectTapGestures(
onPress = {
isAutoLoop = false
val pressStartTime = System.currentTimeMillis()
//只监听 release
if (tryAwaitRelease()) {
isAutoLoop = true
val pressDuration = System.currentTimeMillis() - pressStartTime
//长按后 release 不触发 onClick 事件
if (pressDuration < viewConfiguration.longPressTimeoutMillis) { //400 ms
onItemClick?.invoke(items[pagerState.currentPage])
}
}
},
)
}
.then(modifier)
) { pageIndex ->
val itemData = items[pageIndex]
Box(modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()) {
//内容
itemContent(itemData)
//指示器
HorizontalPagerIndicator(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(8.dp),
pagerState = pagerState,
activeColor = activeIndicatorColor,
inactiveColor = inactiveIndicatorColor
)
}
}
}