12.3 Compose 手势处理(一)及完善 Banner 自动轮播

964 阅读6分钟

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 之后,会出现文字位置改变,但背景位置不变,且拖拽触发的位置在背景上而不再文字上。

25CDC756-98FE-4D9B-B940-5BBADA956571.png

滚动

组件内部内容位置的变化由滚动手势来完成,其内部也是由拖拽手势来实现的,区别在于对手势差量的使用上,一个控制内部内容的位置,一个控制控件在其父控件中的位置。

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 中 提供了一些现成的手势检测方法拓展方法

看这名字都知道咋回事了吧,要说的只有两点

  1. 同一个 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())
                }
            }
        )
    }
}

这个时候就出现了第二点

  1. 如果 onLongPress 不是 null ,长按后再拖动,事件会被长按事件消耗从而无法检测到拖动。

基于这个问题官方贴心的提供了 detectDragGesturesAfterLongPress 。

不用这些 detectXXX api 怎么在 Compose 中处理手势呢?我们下一章单独研究。

Interactions

用来监听交互的状态,从 Interaction 继承结构可以看出 Compose 提供了 拖拽、聚焦、悬停、按下四种交互处理,又分别为每种交互定义了不同的状态。

E4A08D8E-B7A0-4E32-AB45-6158E9CDB726.png

如果细心的话会发现 “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 自动轮播需要解决两个问题:

  1. 周期性延时滑动到下一页 ✔️
  2. 处理手势,有手势交互的时候取消自动翻页,手势交互结束重新开始计时

手势处理需要监听两种交互状态:

  1. Pager 滚动手势开始时取消自动轮播,结束时开启自动轮播 ✔️(PagerState.interactionSource)
  2. Banner 点击手势开始时取消自动轮播,结束时开启自动轮播

原以为给 clickable 添加 InteractionSource 再监听其状态就完事了,问题又出现了

B77B5818-0DDF-4AC5-8D53-9EA4D249E4B9.png

先触发滚动开始后触发点结束,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
            )
        }
    }
}

Git 地址