Jetpack Compose中的手势操作

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose中的手势操作相关的知识,希望对你有一定的参考价值。

点击事件

监听点击事件非常简单,使用 clickablecombinedClickable 修饰符即可满足需求:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableExample() 
    Column
        Box(Modifier
            .clickable  println("clickable") 
            .size(30.dp)
            .background(Color.Red))
        Box(Modifier
            .size(50.dp)
            .background(Color.Blue)
            .combinedClickable(
                onLongClick =  println("combinedClickable --> onLongClick") ,
                onDoubleClick =  println("combinedClickable --> onDoubleClick") ,
                onClick =  println("combinedClickable --> onClick") 
            ))
    

当点击事件发生时会为被点击的组件施加一个水波纹效果动画的蒙层,这是Material Design中的默认效果,如果不希望点击时有这个效果,可以使用低级别的Api detectTapGestures

另外, clickablecombinedClickable 可以传入一个 enable 参数作为一个可变状态,可以通过该状态来动态控制是否启用点击监听。

Draggable拖动

Draggable可以监听拖动手势偏移量,然后可以根据偏移量定制UI拖动交换效果。但是值得注意的是,Draggable修饰符只支持监听水平方向或垂直方向的偏移,如希望监听任意方向,则可以使用detectDragGestures方法。

使用Draggable至少需要传入2个参数draggableStateorientation

  • draggableState: 通过它可以获取到拖动手势的偏移量,并且也允许我们动态控制发生偏移行为
  • orientation:监听拖动的方向,只能是水平或垂直

如下代码实现一个简单的滑块拖动效果

@Composable
fun DraggableExample() 
    var offsetX by remember  mutableStateOf(0f) 
    val boxSlideSize = 50.dp
    val maxLengthPx = with(LocalContext.current) 
        resources.displayMetrics.widthPixels - boxSlideSize.toPx()
    
    // 创建并获取一个DraggableState实例
    val draggableState = rememberDraggableState 
        // 使用回调方法回传的参数对状态偏移量进行累加,并限制范围
        offsetX = (offsetX + it).coerceIn(0f, maxLengthPx)
    
    Box(
        Modifier
            .fillMaxWidth()
            .height(boxSlideSize)
            .background(Color.LightGray)
    ) 
        Box(
            Modifier
                .size(boxSlideSize)
                .offset  IntOffset(offsetX.roundToInt(), 0) 
                .draggable(
                    orientation = Orientation.Horizontal,
                    state = draggableState
                )
                .background(Color.Red)
        )
    

由于Modifier是链式执行的,因此这里offset修饰符应该放在draggable和background之前。

运行效果:

错误示例1(draggable在offset前面):第二次拖动时UI控件拖动只能拖动初始位置才生效,不会跟随UI控件而移动监听,原因是每次拖动时draggable都监听的都是初始位置,不是偏移后位置。
错误示例2(background在offset前面):UI控件不会跟手,原因在于每次绘制时background都在初始位置绘制,不是偏移后位置。

Swipeable滑动

使用方式跟Draggable差不多,但是Swipeable可以通过锚点设置吸附效果。

使用Swipeable至少需要传入4个参数:

  • State: 手势状态,通过它可以实时获取当前手势的偏移信息
  • Anchors: 锚点,用于记录不同状态对应数值的映射关系
  • Orientation: 手势方向,只支持水平或垂直
  • thresholds: 不同锚点之间吸附效果的临界阈值,常用的阈值有FixedThreshold(Dp)和FractionalThreshold(Float)两种

以下代码使用Swipeable创建一个简单的开关效果:

enum class Status CLOSE, OPEN  // 定义两个枚举项表示开关状态

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableDemo() 
    val blockSize = 48.dp
    val blockSizePx = blockSize.toPx()
    // 创建并获取一个SwipeableState实例
    val swipeableState = rememberSwipeableState(initialValue = Status.CLOSE)
    // 定义锚点,锚点以Pair表示,每个状态对应一个锚点
    val anchors = mapOf(
        0f to Status.CLOSE,
        blockSizePx*2 to Status.OPEN
    )
    Box(
        Modifier
            .size(height = blockSize, width = blockSize * 3)
            .clip(RoundedCornerShape(50))
            .background(Color.Gray)
    ) 
        Box(
            Modifier
                .offset  IntOffset(swipeableState.offset.value.toInt(), 0) 
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    thresholds =  from, to ->
                    	// 从关闭到开启状态时,滑块移动超过30%距离自动吸附到开启状态
                        if (from == Status.CLOSE)  
                            FractionalThreshold(0.3f)
                         else  // 从开启状态到关闭状态时,滑块移动超过50%才会自动吸附到关闭状态
                            FractionalThreshold(0.5f)
                        
                    ,
                    orientation = Orientation.Horizontal
                )
                .size(blockSize)
                .clip(RoundedCornerShape(50))
                .background(Color.Red)
        )
    

由于Modifier是链式执行的,因此这里swipeable修饰符应该放在draggable和background之前。

运行效果:

transformable多点触控

transformable修饰符可以监听双指拖动、缩放或旋转手势

@Composable
fun TransformableExample() 
    val boxSize = 200.dp
    var offset by remember  mutableStateOf(Offset.Zero) 
    var rotationAngle by remember  mutableStateOf(0f) 
    var scale by remember  mutableStateOf(1f) 
    // 创建并获取一个TransformableState实例
    val transformableState = rememberTransformableState  
    	zoomChange: Float, panChange: Offset, rotationChange: Float ->
        scale *= zoomChange
        offset += panChange
        rotationAngle += rotationChange
    
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) 
        Image(
            painter = painterResource(id = R.drawable.ic_sky),
            contentScale = ContentScale.Crop,
            contentDescription = null,
            modifier = Modifier
                .size(boxSize)
                .rotate(rotationAngle) // 注意rotate的顺序应该先于offset
                .offset  IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) 
                .scale(scale)
                .transformable(
                    state = transformableState,
                    // 该值为true时,发生双指拖动或缩放时,不会同时监听旋转手势信息
                    lockRotationOnZoomPan = true 
                )
        )
    

这里注意rotate的顺序应该先于offset,如果先调用了offset再调用rotate, 则组件会先偏移再旋转,这会导致组件最终位置不可预期。

运行效果:

Scrollable滚动

主要用于列表场景,结合LazyColumn和LazyRow来使用

horizontalScroll水平滚动

horizontalScroll主要结合Row组件来使用,使其支持水平滚动,horizontalScroll只需要传入一个scrollState即可。我们可以使用 rememberScrollState 快速创建一个 scrollState 实例并传入即可。

@Composable
fun HorizontalScrollExample() 
    val scrollState = rememberScrollState()
    Row(
        Modifier
            .padding(10.dp)
            .border(BorderStroke(1.dp, Color.Blue))
            .height(50.dp)
            .horizontalScroll(scrollState)
    ) 
        repeat(50) 
            Text("item $it", Modifier.padding(10.dp))
            Divider(Modifier.width(1.dp).fillMaxHeight())
        
    

verticalScroll与horizontalScroll使用类似主要结合Column组件使用。

@Composable
fun VerticalScrollExample() 
    val scrollState = rememberScrollState()
    Column(
        Modifier
            .height(300.dp)
            .verticalScroll(scrollState)
    ) 
        repeat(50) 
            Text("item $it", Modifier.padding(10.dp))
            Divider()
        
    

低级别scrollable修饰符

horizontalScroll和verticalScroll都是基于scrollable实现的, scrollable修饰符除了传入一个scrollState外,还需要传入Orientation(水平或垂直)

以下代码通过 scrollable 修饰符的滚动监听能力,自己来定制实现类似 horizontalScroll 修饰符的功能:

@Composable
fun ScrollableExample1() 
    Column(Modifier.padding(10.dp)) 
        val scrollState = rememberScrollState()
        Row(
            Modifier
                .border(BorderStroke(1.dp, Color.Blue))
                .height(50.dp)
                .offset(x = -scrollState.value.toDp()) // 滚动位置增大时应该向左偏移,所以这里设为负数
                .scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
        ) 
            repeat(50) 
                Text("item $it", Modifier.padding(10.dp))
                Divider(Modifier.width(1.dp).fillMaxHeight())
            
        
        Text(text = "scrollState.value: $scrollState.value")
    

注意: scrollable的滚动位置范围为0~MAX_VALUE, 默认当手指在组件上向右滑动时,滚动位置会增大,向左滑动时,滚动位置会较小,直到较少到0。 由于滚动位置默认初始值为0,所以默认我们只能向右滑来增大滚动位置。如果将reverseDirection参数设置为true时,那么此时手指 向左滑滚动位置会增大,向右滑滚动位置会减小。

因此这里将reverseDirection设为true允许我们从初始位置向左滑以查看Row组件右侧超出屏幕的内容部分。

补充提示: 在使用 rememberScrollState 创建 ScrollState 实例时我们是可以通过 initial 参数来指定组件初始滚动位置的

class ScrollState(initial: Int) : ScrollableState 
  var value: Int by mutableStateOf(initial, structuralEqualityPolicy())
  private set

  suspend fun animateScrollTo(...)
  suspend fun scrollTo(...)
  ...

上面的代码运行后我们会发现,当进行左滑时,原本位于屏幕外的内容进入屏幕时右边出现一片空白,这是因为Row组件的默认测量策略导致超出屏幕的子组件宽度测量结果为零。

此时需要使用layout修饰符来自定义布局,我们需要创建一个新的约束,用于测量组件的真实宽度,主动设置组件应有的宽高尺寸,并根据组件的滚动偏移量来摆放组件内容。

@Composable
fun ScrollableExample2() 
    Column(Modifier.padding(10.dp)) 
        val scrollState = rememberScrollState()
        Row(
            Modifier
                .border(BorderStroke(1.dp, Color.Blue))
                .height(50.dp)
                .clipScrollableContainer(Orientation.Horizontal) // 留出父组件设置的padding空间
                .scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
                .layout  measurable, constraints ->
                    println("constraints: $constraints")
                    // 约束中默认最大宽度为父组件所允许的最大宽度,此处为屏幕宽度
                    // 将最大宽度设置为无限大
                    val childConstraints = constraints.copy(
                        maxWidth = Constraints.Infinity
                    )
                    println("childConstraints: $childConstraints")
                    val placeable = measurable.measure(childConstraints) // 使用新的约束进行组件测量
                    // 计算 当前组件宽度 与 父组件所允许的最大宽度 中取一个最小值
                    // 如果组件超出屏幕,此时width为屏幕宽度。如果没有超出,则为组件本身宽度
                    val width = placeable.width.coerceAtMost(constraints.maxWidth)
                    // 计算 当前组件高度 与 父组件所允许的最大高度 中取一个最小值
                    val height = placeable.height.coerceAtMost(constraints.maxHeight)
                    val scrollDistance = placeable.width - width // 计算可滚动的最大距离
                    layout(width, height)  // 主动设置组件的宽高
                        // 根据可滚动的最大距离来计算当前的滚动位置
                        val scroll = scrollState.value.coerceIn(0, scrollDistance)
                        val offsetX = -scroll // 根据滚动位置向左偏移
                        placeable.placeRelativeWithLayer(offsetX, 0) // 摆放组件内容
                    

                
        ) 
            repeat(50) 
                Text("item $it", Modifier.padding(10.dp))
                Divider(Modifier.width(1.dp).fillMaxHeight())
            
        
        Text(text = "scrollState.value: $scrollState.value")
    

运行效果:

nestedScroll 嵌套滑动

nestedScroll 修饰符对标android传统原生View体系中的NestedScrollView组件,主要用于处理嵌套滑动的场景,为父布局劫持消费子布局滑动手势提供了可能。

使用 nestedScroll 参数列表中有一个必选参数 connection 和一个可选参数 dispatcher

  • connection:嵌套滑动手势处理的核心逻辑,内部回调可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
  • dispatcher:调度器,内部包含用于父布局的 NestedScrollConnection , 可以调用 dispatch* 系列方法来通知父布局发生滑动
fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
)

NestedScrollConnection提供了四个回调方法。

方法说明参数返回值
onPreScroll可以预先劫持滑动事件,消费后再交由子布局available:当前可用的滑动事件偏移量
source:滑动事件的类型
当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
onPostScroll可以获取子布局处理后剩下的滑动事件consumed:之前被消费的所有滑动事件偏移量
available:当前剩下还可用的滑动事件偏移量
source:滑动事件的类型
当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
onPreFling获取 Fling 动作开始时的速度available:Fling 开始时的速度当前组件消费的速度,如果不想消费可返回 Velocity.Zero
onPostFling获取 Fling 动作结束时的速度consumed:之前消费的所有速度
available:当前剩下还可用的速度
当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理

Fling含义:当我们手指在滑动列表时,如果是快速滑动并抬起,则列表会根据惯性继续飘一段距离后停下,这个行为就是 Fling 惯性滑动,onPreFling 在你手指刚抬起时便会回调,而 onPostFling 会在飘一段距离停下后回调。

使用nestedScroll实现下拉刷新

效果:

在这个示例中存在着加载动画和列表数据。当我们手指向下滑时,此时如果列表顶部没有数据则会逐渐出现加载动画。与之相反,当我们手指向上滑时,此时如果加载动画还在,则加载动画逐渐向上消失,直到加载动画完全消失后,列表才会被向上滑动。

为实现这个滑动刷新的需求,我们可以设计如下方案。我们首先需要将加载动画和列表数据放到一个父布局中统一管理。

  1. 当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。父布局可以消费列表消费剩下的滑动手势事件(增大加载指示器的偏移量)。
  2. 当我们手指向上滑时,我们希望滑动手势首先被父布局消费(为加载动画减小偏移),如果加载动画本身仍未出现时,则不进行消费。然后将剩下的滑动手势交给子布局列表进行消费。

使用 nestedScroll 修饰符最重要的就是根据自己的业务场景来定制 NestedScrollConnection 的实现,接下来我们就逐个分析 NestedScrollConnection 中的每个接口该如何进行实现。

实现 onPostScroll

当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。 onPostScroll 回调时机正好符合当前的需求。

首先需要判断该滑动事件是不是拖动事件,通过 available.y > 0 判断是否是下滑手势,如果都没问题时,通知加载动画增加偏移量。返回值 Offset(x = 0f, y = available.y) 意味着将剩下的所有偏移量全部消费调,不再向外层父布局继续传播了。

override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset 
    if (source == NestedScrollSource.Drag && available.y > 0) 
        state.updateOffsetDelta(available.y)
        return Offset(x = 0f, y = available.y)
     else 
        return Offset.Zero
    

实现 onPreScroll

与上面相反,此时我们希望上滑收回加载动画,当我们手指向上滑时,我们希望滑动手势首先被父布局消费(减小加载指示器的偏移量),如果加载指示器还未出现,则不需要进行消费。剩余的滑动手势事件会交给子布局列表继续进行消费。onPreScroll 回调时机正好符合这个需求。

首先仍需要判断该滑动事件是不是拖动事件,通过 available.y < 0 判断是否是上滑手势。此时可能加载指示器还未出现,所以需要额外进行判断。如果未出现,则返回 Offset.Zero 不消费,如果出现了则返回 Offset(x = 0f, y = available.y) 进行消费。

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset 
    if (source == NestedScrollSource.Drag && available.y < 0) 
        state.updateOffsetDelta(available.y)
        return if (state.isSwipeInProgress) Offset(x = 0f, y = available.y) else Offset.Zero
     else 
        return Offset.Zero
    

实现 onPreFling

接下来,我们需要一个松手时的吸附效果。如果加载指示器已经被拖动并超过一半,则应该吸附到加载状态,否则就收缩回初始状态。onPreFling 会在松手时发生惯性滑动前回调,符合我们当前这个的场景需求。

注意:即使你松手时速度很慢或静止,onPreFling 与 onPostFling都会回调,只是速度数值很小。

这里我们只需要吸引效果,并不希望消费速度,所以返回 Velocity.Zero 即可

override suspend fun onPreFling(available: Velocity): Velocity 
    if (state.indicatorOffset > height / 2) 
        state.animateToOffset(height)
        state.isRefreshing = true
     else 
        state.animateToOffset(0.dp)
    
    return Velocity.Zero

完整源码:

import android.util.Log
import androidx.compose.animation.core.Animatable
以上是关于Jetpack Compose中的手势操作的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose实践:完成自定义手势处理

使用Jetpack Compose完成自定义手势处理

Jetpack Compose 从入门到入门

Jetpack Compose 从入门到入门

Jetpack Compose 从入门到入门

Jetpack Compose 从入门到入门