《Jetpack Compose系列学习》-22 Compose中的手势

1,177 阅读7分钟

手势在智能手机中运用的非常广泛,如点按、拖动、滑动、缩放等等。使用一些简单的手势就可以完成较为复杂的操作。Compose为我们提供了多种手势API,可以帮助我们检测交互生成的手势。下面来看看Compose中具体的手势。

点击事件

前面我们学习Modifier的时候接触到了点击事件的设置,这里再回顾下它的使用方法:

val count = remember { mutableStateOf(0) }
Text("${count.value}", modifier = Modifier.size(20.dp).background(Color.Blue).clickable {
    count.value += 2
}, fontSize = 40.sp)

代码很简单,通过remember记录State的值,当点击Text的时候对State的值进行+2,State的值发生变化导致可组合项发生重组以更新Text的值。效果如下:

8.gif

当需要更复杂的手势操作的时候,Android View中可以重写onTouch方法来更加具体地控制点击事件,而在Compose中我们可以通过修饰符的扩展函数pointerInput来使用点按手势检测器。先看看pointerInput的方法定义:

fun Modifier.pointerInput(
    key1: Any?,
    block: suspend PointerInputScope.() -> Unit
)

可以看到pointerInput方法有两个参数,一个是key1,一个是block,其类型是PointerInputScope。PointerInputScope也是一个接口,和之前的LazyColumn一样,这里也可以通过DSL的方法设置参数。看下如何使用它:

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = {/* 手势开始时调用 */ },
        onDoubleTap = { /* 双击调用 */ },
        onLongPress = { /* 长按调用 */ },
        onTap = { /* 单击调用 */ }
    )
}

之前在Android View中想要实现双击事件比较麻烦,Compose已经为我们封装好了一些常用的点击事件,包括单击、双击和长按事件,可以直接使用。

滚动事件

前面的学习我们知道列表控件LazyColumn和LazyRow当数据多的时候是可以滚动的,我们就可以用verticalScroll和horizontalScroll修饰符,从而让用户在元素内容边界最大尺寸约束时滚动元素:

Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState())
) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(300.dp)
            .background(Color.Blue)
    )
    Spacer(modifier = Modifier.height(50.dp))
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(300.dp)
            .background(Color.Red)
    )
    Spacer(modifier = Modifier.height(50.dp))
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(300.dp)
            .background(Color.Yellow)
    )
    Spacer(modifier = Modifier.height(50.dp))
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(300.dp)
            .background(Color.Green)
    )
}

9.gif

如上,Column里面放了四个Box,高度都是300dp。如果想要显示下面的内容,就需要使用verticalScroll修饰符了:

Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState())
) 

在使用verticalScroll修饰符的时候,我们传入了ScrollState参数。借助ScrollState,我们可以更改滚动位置或获取当前滚动状态。如需使用默认参数创建ScrollState,就可以使用rememberScrollState。添加verticalScroll修饰符之后,在页面显示不全的情况下就可以继续滑动来显示下面的内容了。

同Column一样,横向线性布局Row在一行显示不完的情况下就可以使用horizontalScroll修饰符。

嵌套滚动

嵌套滚动也很常见,典型的就是一个滚动列表中嵌套另外一个滚动列表。Compose中的嵌套滚动比较简单,无须我们执行任何操作,启动滚动操作的手势会自动从子控件传递到父控件,这样一来,当子控件滚动到底部的时候,手势就会由父控件进行处理。来看看Compose中怎么使用嵌套滚动:

@Composable
fun ScrollTest() {
    val gradient = Brush.verticalGradient(
        0f to Color.Gray,
        1000f to Color.White
    )
    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(16.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth()) {
            repeat(8) {
                Box(
                    modifier = Modifier
                        .background(brush = gradient)
                        .height(128.dp)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here", modifier =
                        Modifier.padding(24.dp).height(200.dp)
                    )
                }
            }
        }
    }
}

创建了一个Box并使用了verticalScroll修饰符,子控件为Column。Column中有两个子控件:一个是Box,高度128dp,也使用了verticalScroll修饰符;另外一个子控件是Text,高度为200dp,Box的高度不足以放下Text。可以看到,由于Box放不下Text,所以滑动的时候Text先滑动到底部,然后外面的Box再继续跟随手势滚动:

10.gif

拖动事件

同样,在Compose中拖动事件也有修饰符:draggable。draggable修饰符是向单一方向拖动的手势的高级入口点,并且会报告拖动距离(以像素为单位)。需要注意的是,此修饰符仅检测手势,我们需要自己保存状态并在屏幕上表示,可以通过Offset修饰符移动元素。看个例子:

@Composable
fun ScrollTest() {
    Box(modifier = Modifier.fillMaxSize()) {
        val offsetY = remember { mutableStateOf(0f) }
        Text(
            modifier = Modifier
                .offset { IntOffset(0, offsetY.value.roundToInt()) }
                .draggable(
                    orientation = Orientation.Vertical,
                    state = rememberDraggableState { delta ->
                        offsetY.value += delta
                    }
                ),
            text = "拖动",
            fontSize = 30.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

上面代码我们创建了一个remember保存当前拖动的Y坐标点,然后创建了一个Text,使用draggable来为Text创建拖动事件,拖动方向设置为纵向竖直拖动,拖动的时候同时修改存储Y坐标点的State,最后可组合项进行重组,Text通过Offset设置拖动后的坐标。效果如下:

11.gif

同样,如果想要横向滑动,就修改orientation为横向的Orientation.Horizontal。然后控制好控件的坐标点即可。但想要控制整个拖动手势,就需要通过pointerInput修饰符来进行控制,这同点击事件一样,只是使用方法不同,这里使用的是detectDragGestures。看下detectDragGestures的方法定义:

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) 

可以看到detectDragGestures也是PointerInputScope的扩展方法,四个参数分别是:onDragStart、onDragEnd、onDragCancel和onDrag。onDragStart为开始拖动的回调,onDrag为拖动结束的回调,onDragCancel为拖动取消的回调,onDrag为拖动过程中的回调。都是可选参数,看看怎么使用:

@Composable
fun ScrollTest() {
    Box(modifier = Modifier.fillMaxSize()) {
        val offsetX = remember { mutableStateOf(0f) }
        val offsetY = remember { mutableStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consumeAllChanges()
                        offsetX.value += dragAmount.x
                        offsetY.value += dragAmount.y
                    }
                }
        )
    }
}

12.gif

我们先定义了remember用来记住坐标点的横纵坐标,使用detectDragGestures时只设置了最后一个参数onDrag,所以可以通过尾调函数的方法来进行调用,拖动过程中修改State中的横纵坐标值,触发可组合项重组之后通过offset将Box放在拖动后的位置,效果如上。

此时,可以不受横纵坐标的限制在页面任何地方进行拖动了。在实际开发中,也可以监听开始拖动、拖动结束和取消拖动事件进一步做一些特殊处理。

滑动事件

在Compose中滑动事件使用修饰符swipeable来实现,通过该修饰符我们可以拖动控件,松手之后控件通常朝一个方向定义的两个或多个锚点呈现动画效果。这里需要注意的是,swipeable同样不会移动控件的位置,因此我们也需要保存坐标来移动控件。看下swipeable的方法定义:

@ExperimentalMaterialApi
fun <T> Modifier.swipeable(
    state: SwipeableState<T>,
    anchors: Map<Float, T>,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    interactionSource: MutableInteractionSource? = null,
    thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
    resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
    velocityThreshold: Dp = VelocityThreshold
)

可以看到,swipeable也是Modifier的扩展方法,参数比较多,一共有9个,其中有3个参数必须传;第一个是state,state为手滑的状态,默认可以通过rememberSwipeableState创建和记住,此状态还提供了一组有用的方法,用于为锚点添加动画效果,同时为属性添加动画效果,以观察拖动进度;第二个必填的参数是anchors,用于将锚点映射到状态;第三个必填参数是orientation,用于定义滑动事件的方向。可以将滑动手势配置为具有不同的阈值类型,可以配置滑动越过边界时的resistance,还可以配置velocityThreshold。即使尚未达到位置thresholds,velocityThreshold仍将以动画方式向下一个状态滑动。看个例子:

@ExperimentalMaterialApi
@Composable
fun SwipeableSample() {
    val squareSize = 48.dp

    val swipeableState = rememberSwipeableState(0)
    val sizePx = with(LocalDensity.current) { squareSize.toPx() }
    val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states

    Box(
        modifier = Modifier
            .width(96.dp)
            .height(squareSize)
            .swipeable(
                state = swipeableState,
                anchors = anchors,
                thresholds = { from, to -> FractionalThreshold(0.3f) },
                orientation = Orientation.Horizontal
            )
            .background(Color.Red)
    ) {
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                .size(squareSize)
                .background(Color.DarkGray)
        )
    }
}

上面代码首先定义了子Box的size,创建了swipeableState,将子Box的size转换为像素值,然后定义了一个Map用来存储锚点,之后创建了一个Box,通过修饰符将swipeable设置进去,最后为子Box设置之前定义好的size。看下效果:

14.gif

此时松开滑动的灰色子Box会有相应的动画,滑动到锚点之后会继续滑动到最后,反之回到初始位置。

好了,今天学习到这,代码已上传:github.com/Licarey/com…