降Compose十八掌之『鱼跃于渊』| Gesture Handling

公众号「稀有猿诉」

UI是用户界面,一个最为基础的功能就是与用户进行交互,要具有可交互性。要想有可交互性就需要处理用户输入事件。手势是最为常见的一种用户输入,今天就来专门学习一下如何处理Jetpack Compose中最为常见的手势。

输入事件与手势概述

在开始学习之前有必要先澄清一些概念,以免混淆。与View系统不太一样的是,触摸事件在Jetpack Compose中称之为触点事件(Pointer event),对应的主体称之为触点(Pointer),一连串的触点事件就形成了手势(Gesture)。之所以叫触点,是因为并不总是由触摸屏幕触发事件,也可以是手写笔,(外接)鼠标或者(外接)触摸板,这些都是触控类的输入主体,它的最主要的特点是发生在屏幕上的一个坐标点。其具体的类型称之为触点类型(Pointer type)。

事件处理最主要的是也就是要识别各种不同的触点手势,然后做出响应,以让UI具体可交互性。

点击事件(Tap and Press)

点击事件是最为常见,也是最为基础的一种手势了,可以简单的看成按下事件(pointer down)和抬起事件(pointer up)组成,但其实也会有移动(pointer move),只不过移动的位移特别小而已,这里我们不过多的纠结。点击事件分为单击,双击和长按,幸运的是在Compose中都有封装好的回调函数可以直接使用,我们一一来看一下。

单击(Tap/Click)

单击是最为常见的事件处理了,在之前的教程已经见过了,通过Modifier的扩展函数Modifier.clickable就可以为任意一个Composable设置单击事件处理函数。

双击(Double tap/Double click)和长按(LongPress/Long click)

对于双击和长按,并不像clickable那样常用,因此需要用到另外一个扩展函数Modifier.combinedClickable,这个函数可以设置多个点击事件处理函数,单击双击和长按都可以通过它来设置:

Kotlin 复制代码
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Yellow)
            .combinedClickable(
                onClick = { gotoDetail() },
                onClickLabel = "Go to details",
                onLongClick = { showContextMneu() },
                onLongClickLabel = "Open context menu",
                onDoubleClick = { shareContent() }
            )
    )

滚动(Scroll)

滚动手势是指朝着某一固定的方向慢速的滑动,多用于查看屏幕之外的内容。像集合性布局设计的目的就是为了显示大量的同一类型的数据集合,天生就支持滚动。对于滚动手势需要处理的就是常规布局支持滚动,以及滚动的嵌套。

非集合性布局支持滚动

对于常规的非集合性布局(Box,Row和Column)正常情况下是不可滚动的,是没有办法查看超出其尺寸大小范围的内容的。想让这几个布局可滚动也不难,用Modifier的扩展函数verticalScrollhorizontalScroll就可以让不可滚动布局(Box,Row和Column)支持垂直方向滚动和水平方向滚动:

Kotlin 复制代码
@Composable
private fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

大部分情况下,如果只是想让布局可滚动就不需要处理ScrollState,但如果想要获取滚位置,或者改变滚动位置,比如说页面进入时(Initial composition)自动滚动到某一们位置,可以通过修改SrollState来实现:

Kotlin 复制代码
@Composable
private fun ScrollBoxesSmooth() {
    // 进入页面时就自动的平滑的滚动
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        // ...
    }
}

滚动手势处理

对于任意的Composable来文章,都可以通过Modifier的扩展函数scrollable来监听并处理滚动手势。需要注意的是,scrollable仅会告诉你有滚动手势发生和当前的滚动距离,但并不会直接修改布局,需要开发者去使用滑动距离进行布局的修改:

Kotlin 复制代码
@Composable
private fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

如果让滚动对布局产生影响,可以用计算得到offset去改变布局的offset属性offset(y = offset.dp)就可以了。

滚动嵌套

手势处理最大的一个麻烦就是手势的嵌套,而又以滚动的嵌套最为麻烦,最为典型的就是同一方向的列表中套着列表,开发者必须手动处理滑动冲突���滚动冲突处理的策略并不难,优先由子View消费滚动事件,当子View还可以滚动时,就把事件消费掉;如果子View已到达边界,无法滚动时,视为事件未消费,把事件再传递给父View,由父View消费,这时父View会进行滚动;当然如果滑动事件没有发生在子View上面,那肯定 是父View滚动。

策略虽然简单,但有魔鬼细节,传统的View必须要在onTouch和onInterceptTouch里面写上大坨大坨的逻辑,还要定义很多个全局变量。幸运的是,针对 于同方向的可滚动布局嵌套,Jetpack Compose已经帮我们处理了。对于使用verticalScroll,horizontalScroll,scrollable,集合性布局(LazyRow,LazyColumn和LazyGrid)和TextField实现的同方向滚动嵌套,不用再特殊处理,Compose已经按照前面说的策略处理好了,这就是自动嵌套滚动机制(Automatic nested scrolling)。来看一个例子:

Kotlin 复制代码
@Composable
private fun AutomaticNestedScroll() {
    val gradient = Brush.verticalGradient(0f to Color.Yellow, 1000f to Color.Red)
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .height(400.dp)
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        repeat(6) {
            Box(
                modifier = Modifier
                    .height(128.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                Text(
                    "$it 滑动试试!",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .border(12.dp, Color.DarkGray)
                        .background(brush = gradient)
                        .padding(24.dp)
                        .height(150.dp)
                )
            }
        }
    }
}

这个例子中外层Column支持垂直滚动,里面的每个Box也支持垂直滚动,当里面的Box自己消费滚动时,外层 是不会动的,而当里面的Box无法滚动时(overscrolled)事件就到了外层的Column,即Column会滚动。

注意: 滚动嵌套并不是一个好的交互设计,尽管有技术手段解决,但用起来仍旧是怪怪的,操作起来也并不方便,误操作的可能性很大。不同方向的滚动嵌套在一起是比较好的方案,比如横向的Tab页代表不同的分类,竖向的内容页是一个分类中的具体内容,内容是竖向的,内容中仍旧可以有一些横向滑动的扩展内容,如图片库,tag标签等。

拖拽(Drag)

拖拽是指按住屏幕慢速移动,被点击到的UI元素应该跟随手势移动并停留在触点离开屏幕的地方。通过扩展函数Modifier.draggable可以处理单一方向的拖拽手势。在draggable中我们可以用状态记录移动的距离,然后把距离应用到Composable的offset以生成拖拽后的效果:

Kotlin 复制代码
@Composable
private fun DraggableText() {
    var offsetX by remember { mutableStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .background(Color.LightGray)
            .padding(8.dp)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "降Compose十八掌!",
        style = MaterialTheme.typography.headlineLarge,
        color = MaterialTheme.colorScheme.primary
    )
}

滑动(Swipe/Fling)

滑动与拖拽的区别在于滑动是有速度的,滑动手势在触点离开屏幕后并不会立即停止,而且是会继续朝原方向减速直到速度变为0才停,最为常见的交互方式就是滑动删除(swipe-to-dismiss),以及像列表的Fling手势。

使用Modifier的扩展函数anchoredDraggable来处理滑动事件,定义一些锚点(DraggableAnchors),视为一个手势操作中的不同状态,比如像滑动开关,就是开和关,像滑动删除就是正常和已删除,再用一个AnchoredDraggableState来追踪滑动的状态,这里面可以定义初始锚点,锚点值,和终止状态的阈值(positionalThreshold超过一定位置就认为到达终点锚点,velocityThreshold速度小于这个时就认为到达终点锚点),以及手势过程中的动画(animationSpec)。然后,再把AnchoredDraggableState中的滑动距离offset设置到Composable中即可。

说的挺复杂,其实很直观,看一个例子就明了:

Kotlin 复制代码
enum class SwipeableSwitchState {
    SWITCH_ON, SWITCH_OFF
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SwipeableSample() {
    val width = 128.dp
    val squareSize = 64.dp

    val density = LocalDensity.current
    val sizePx = with(density) { squareSize.toPx() }
    val anchors = DraggableAnchors {
        SwipeableSwitchState.SWITCH_ON at sizePx
        SwipeableSwitchState.SWITCH_OFF at 0f
    }
    val swipeableState = remember {
        AnchoredDraggableState(
            initialValue = SwipeableSwitchState.SWITCH_OFF,
            anchors = anchors,
            positionalThreshold = { d: Float -> d * 0.4f },
            velocityThreshold = { with(density) { 100.dp.toPx() } },
            animationSpec = tween()
        )
    }
    Box(
        modifier = Modifier
            .width(width)
            .anchoredDraggable(
                state = swipeableState,
                orientation = Orientation.Horizontal,
                startDragImmediately = false
            )
            .background(Color.LightGray)
    ) {
        Box(
            Modifier
                .offset {
                    IntOffset(
                        if (swipeableState.offset.isNaN()) 0 else swipeableState.offset.roundToInt(),
                        0
                    )
                }
                .size(squareSize)
                .background(Color.DarkGray)
        )
    }
}

这个例子展示了一个滑动开关的手势处理,滑动距离超过整体长度0.4时,或者速度小于100时就认为到达另一锚点状态。可以明显的看出与拖拽的区别,滑动后手可以离开,但手势仍在继续直到达到终点锚点。

注意: 在Compose 1.6版本以前有另外一个扩展函数swipeable来处理滑动手势,但在1.6版本时已废弃,被anchoredDraggable取代,并且有一个替换的教程

未完待续

事件处理对于UI来说是极其重要的,本篇重点讲述了Jetpack Compose中的最为基础和最为常见的事件处理方式,足以满足绝大多数应用场景。事件处理也是极其复杂的,对于交互极其复杂的页面来说,还需要进一步的了解更为底层的事件处理方法,以达到复杂交互的目的,将在后面的文章中继续深入探讨事件处理。

References

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
大白要努力!2 小时前
Android opencv使用Core.hconcat 进行图像拼接
android·opencv
天空中的野鸟3 小时前
Android音频采集
android·音视频
小白也想学C4 小时前
Android 功耗分析(底层篇)
android·功耗
曙曙学编程4 小时前
初级数据结构——树
android·java·数据结构
闲暇部落6 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX8 小时前
Android 分区相关介绍
android
大白要努力!9 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee9 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood9 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-12 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记