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

公众号「稀有猿诉」        原文链接 降Compose十八掌之『鱼跃于渊』| Gesture Handling

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

banner

输入事件与手势概述

在开始学习之前有必要先澄清一些概念,以免混淆。与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,这个函数可以设置多个点击事件处理函数,单击双击和长按都可以通过它来设置:

    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)支持垂直方向滚动和水平方向滚动:

@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))
        }
    }
}

gestures-simplescroll.gif

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

@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仅会告诉你有滚动手势发生和当前的滚动距离,但并不会直接修改布局,需要开发者去使用滑动距离进行布局的修改:

@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())
    }
}

handle_scrollable

如果让滚动对布局产生影响,可以用计算得到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)。来看一个例子:

@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)
                )
            }
        }
    }
}

ascroll.gif

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

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

拖拽(Drag)

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

@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
    )
}

drag.gif

滑动(Swipe/Fling)

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

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

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

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)
        )
    }
}

swipe.gif

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

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

未完待续

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

References

subscription

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

保护原创,请勿转载!

  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值