公众号「稀有猿诉」
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的扩展函数verticalScroll和horizontalScroll就可以让不可滚动布局(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
- Tap and press
- Drag, swipe, and fling
- Scroll
- How to Implement Swipe-to-Action using AnchoredDraggable in Jetpack Compose
- Exploring Jetpack Compose Anchored Draggable Modifier
- Jetpack Compose: Anchored Draggable Item in MotionLayout Part 1
- Jetpack Compose: Anchored Draggable Item in MotionLayout Part 2
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!