Jetpack Compose 手势

5,860 阅读11分钟

这篇文章中,我们介绍Compose的手势,Compose 提供了很多直接使用的API,能够让我们去很方便的检测到用户的手势。下面我们会一个个介绍。

一:Modifier.clickable 点击

由于clickable我们一直在使用,直接上官网的例子,一个计数的Text。点击数字+1,代码如下:

@Composable
fun ClickableSample() {
    val count = remember { mutableStateOf(0) }
    // content that you want to make clickable
    Text(
        text = count.value.toString(),
        modifier = Modifier.clickable { count.value += 1 }
    )
}

效果如下:

Screenshot_drawlinetest.jpg

二:Modifier.verticalScroll 跟 Modifier.horizontalScroll (执行滚动)

verticalScroll(竖直滚动) 和 horizontalScroll(水平滚动) 当控件的内容大于控件本身的尺寸的时候。使用这两个修饰符可进行滚动。这两个方法的代码如下:

fun Modifier.verticalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
) = {...}

fun Modifier.horizontalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
) {...}
  • state 是滚动的状态
  • enabled 是否可用
  • flingBehavior
  • reverseScrolling 是否反着滚动 举例如下:当列表滚动到顶部的时候,我们点击滚动按钮,列表会往下滚动,而当列表滚动到底部的时候,我们点击按钮会往上滚动。
@Preview
@Composable
private fun ScrollBoxesSmooth() {

    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    val scope = rememberCoroutineScope()
    val isScrollBottom = remember {
        mutableStateOf(false)
    }

    Row() {
        Column(
            modifier = Modifier
                .background(Color.LightGray)
                .size(100.dp)
                .padding(horizontal = 8.dp)
                .verticalScroll(state)
        ) {
            repeat(10) {
                Text("Item $it", modifier = Modifier.padding(2.dp))
            }
        }

        Button(modifier = Modifier.padding(10.dp),onClick = {
            scope.launch {
                // 当滚动到顶部的时候,点击就往下滚
                if(state.value<=0){
                    isScrollBottom.value = false
                }else if(state.value>=state.maxValue){
                    // 当滚动到底部的时候,点击就往上滚动
                    isScrollBottom.value = true
                }
                state.animateScrollBy(if(isScrollBottom.value) -50f else 50f)
            }
        }) {
            Text(text = "滚动")
        }
    }
}

三:Modifier.scrollable (监听滚动)

scrollable修饰符跟上面verticalScroll和horizontalScroll的区别在于,verticalScroll和horizontalScroll会去执行滚动,滚动内容。而scrollable只是检测监听的作用,监听到用户滚动了。但scrollable并没有任何实际的偏移效果。来看看scrollable的代码:

fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
){...}
  • state 滚动的状态 ScrollableState 具体实现通过 rememberScrollableState
  • orientation 滚动的方向 Orientation.Vertical是竖直的方向,Orientation.Horizontal是水平的方向
  • enabled 是否可用
  • reverseDirection 是否反着滚动
  • flingBehavior
  • interactionSource 可以获取用户的状态,比如是否是按下,是否获取焦点。以前在介绍Button的时候讲过。Button的讲解 举例如下:我们用一个Text去显示出我们手势滚动的数值,代码如下:
@Preview
@Composable
fun scrollableTest(){
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                state = rememberScrollableState {
                    offset+=it
                    it
                },
                orientation = Orientation.Vertical
            )
            .background(Color.LightGray),contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

效果如下:

Screenshot_drawlinetest.jpg

四:Modifier.nestedScroll 嵌套滑动

嵌套滑动一种是比较简单的,Compose会自动的实现嵌套滑动,类似原生view的NestedScrollView。一种是需要自己自定义子控件和父控件的滚动逻辑(类似View系统的NestedScrollingChild跟NestedScrollingParent)。

4.1 自动嵌套滑动

@Preview
@Composable
fun nestedScrollTest(){
    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp)
    ) {
        Column {
            repeat(6) {
                Box(
                    modifier = Modifier
                        .height(128.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here",
                        modifier = Modifier
                            .border(12.dp, Color.DarkGray)
                            .background(brush = gradient)
                            .padding(24.dp)
                            .height(150.dp)
                    )
                }
            }
        }
    }
}

效果如下:

Screenshot_drawlinetest.jpg

4.2 自定义嵌套滑动

先来看看nestedScroll的代码

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
){...}
  • connection NestedScrollConnection 当滚动可滚动子View时该类可以接收事件,并提供一些时机供我们使用。我们来具体看看这个类。NestedScrollConnection有如下几个方法。
    • fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero 子控件调用dispatchPreScroll会回调到该方法,允许父控件提前消耗一部分拖动事件。参数:available-可用于预滚动的增量,source—滚动事件的源. 返回值是:消耗的量。
    • fun onPostScroll( consumed: Offset,available: Offset, source: NestedScrollSource): Offset = Offset.Zero 子控件消耗完了自己的滚动,调用dispatchPostScroll,告知父控件子控件已经没法滚动了。父控件可以决定要不要接着处理滚动。consumed—层次结构下所有嵌套滚动节点消耗的量,available—此连接可使用的增量,source—卷轴的来源。返回值是:消耗的量
    • suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero 当快速滚动的时候,子控件调用dispatchPreFling,会回调到onPreFling,允许父控件提前拦截消耗一部分的快速滑动。
    • suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {return Velocity.Zero} 当子控件完成了快速滑动的消耗,会回调到该方法,父控件可以选择是否继续消耗快速滑动。consumed:子View消耗的速度量。available:父对象在子对象之后抛下的速度。返回:该次快速滚动操作消耗的速度量
  • dispatcher NestedScrollDispatcher是嵌套滚动的分发类。主要也是有几个方法
    • dispatchPreScroll 通知父控件是否提前消耗一些事件。当子控件调用了该方法后,NestedScrollConnection回接收到onPreScroll的回调。代码如下:
      fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
          return parent?.onPreScroll(available, source) ?: Offset.Zero
      }
      
    • dispatchPostScroll 通知父控件,我子控件消耗完滑动了。当子控件调用了该方法后,NestedScrollConnection会接收到onPostScroll回调。代码如下:
      fun dispatchPostScroll(consumed: Offset,available: Offset, source: NestedScrollSource): Offset {
          return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
      }
      
    • dispatchPreFling 通知父控件是否提前消耗一些快速滑动事件。当子控件调用了该方法后,NestedScrollConnection会接收到dispatchPreFling回调。代码如下:
      suspend fun dispatchPreFling(available: Velocity): Velocity {
          return parent?.onPreFling(available) ?: Velocity.Zero
      }
      
    • dispatchPostFling 通知父控件,我子控件快速滑动消耗完了。当子控件调用了该方法后,NestedScrollConnection会接收到onPostFling回调。代码如下:
      suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
          return parent?.onPostFling(consumed, available) ?: Velocity.Zero
      }
      

关于嵌套滑动,我们可以来看看下面两张流程图,能够帮助我们更直观的理解它们。
一张是Android。View系统的嵌套滑动的。感谢傅晨明作者的图

Screenshot_drawlinetest.jpg 另一张是Compose的。

举个实例:有个标题栏,中间有个图片跟切换tab栏。底部是个列表。滑动的时候,图片跟tab栏往上顶。tab栏置顶效果。代码如下:

@Preview
@Composable
fun nestedScrollTest(){
    val imageHeight = 150.dp
    val headerHeight = 200.dp
    val topBarHeight = 48.dp
    val state = rememberLazyListState()
    val headerOffsetHeightPx = remember {
        mutableStateOf(0f)
    }
    val headerHeightPx = with(LocalDensity.current){
        imageHeight.roundToPx().toFloat()
    }
    val nestedScrollConnection = remember {
        object :NestedScrollConnection{
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // 说明是向下滚,需要判断list是否已经滚动完了,滚动完了才去滚动
                if(available.y>0){
                    if(state.firstVisibleItemIndex<=2){
                        val delta = available.y
                        val newOffset = headerOffsetHeightPx.value + delta
                        headerOffsetHeightPx.value = newOffset.coerceIn(-headerHeightPx, 0f)
                    }
                }else{
                    val delta = available.y
                    val newOffset = headerOffsetHeightPx.value + delta
                    headerOffsetHeightPx.value = newOffset.coerceIn(-headerHeightPx, 0f)
                }
                return Offset.Zero
            }
        }
    }
    val nestedScrollDispatcher = remember { NestedScrollDispatcher() }

    Box(modifier = Modifier.nestedScroll(nestedScrollConnection,nestedScrollDispatcher)){
        LazyColumn(state=state,contentPadding = PaddingValues(top = headerHeight+topBarHeight)) {
            items(100) { index ->
                Text("I'm item $index", modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp))
            }
        }
        headerView(headerOffsetHeightPx.value.roundToInt())
        TopAppBar(modifier = Modifier
            .height(48.dp)
            .background(Color.Blue),contentPadding = PaddingValues(start = 20.dp)) {
                Text(text = "标题",fontSize = 17.sp)
        }
    }
}

@Composable
fun headerView(headerOffsetY:Int){
    Column(modifier = Modifier.padding(top = 48.dp).offset {
        IntOffset(x = 0, y = headerOffsetY)
    }) {
        // 一张图片。高度是150dp
        Image(modifier = Modifier
            .fillMaxWidth()
            .size(150.dp),bitmap = ImageBitmap.imageResource(id = R.drawable.icon_head), contentDescription = "图片",contentScale = ContentScale.FillBounds)
        // 一个TabRow,高度是50dp
        tabRowView()
    }
}

@Composable
fun tabRowView(){
    val tabIndex = remember {
        mutableStateOf(0)
    }
    val tabDatas = ArrayList<String>().apply {
        add("语文")
        add("数学")
        add("英语")
    }
    TabRow(
        selectedTabIndex = tabIndex.value,
        modifier = Modifier
            .fillMaxWidth()
            .height(50.dp),
        backgroundColor = Color.Green,
        contentColor = Color.Black,
        divider = {
            TabRowDefaults.Divider()
        },
        indicator = {
            TabRowDefaults.Indicator(
                Modifier.tabIndicatorOffset(it[tabIndex.value]),
                color = Color.Blue,
                height = 2.dp
            )
        }
    ) {
        tabDatas.forEachIndexed{
                index, s ->
            tabView(index,s,tabIndex)
        }
    }
}

@Composable
fun tabView(index:Int,text:String,tabIndex:MutableState<Int>){
    val interactionSource = remember {
        MutableInteractionSource()
    }
    val isPress = interactionSource.collectIsPressedAsState().value
    Tab(
        selected = index == tabIndex.value,
        onClick = {
            tabIndex.value = index
        },
        modifier = Modifier
            .wrapContentWidth()
            .fillMaxHeight(),
        enabled =true,
        interactionSource = interactionSource,
        selectedContentColor = Color.Red,
        unselectedContentColor = Color.Black
    ) {
        Text(text = text,color = if(isPress || index == tabIndex.value) Color.Red else Color.Black)
    }
}

效果图如下:

五:Modifier.draggable 拖动

draggable是监听拖动事件,跟scrollable一样,都只是检测监听的作用。没有实际的去修改偏移值。先来具体看看draggable的代码:

fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
    reverseDirection: Boolean = false
){...}
  • state 拖动状态 DraggableState 通过rememberDraggableState创建
  • orientation 方向 Orientation.Vertical 竖直,Orientation.Horizontal 水平
  • enabled 是否可用
  • interactionSource 可以获取用户的状态,比如是否是按下,是否获取焦点。以前在介绍Button的时候讲过。[Button的讲解]
  • startDragImmediately 设置为true时,DragTable将立即开始拖动,并防止其他手势检测器对“向下”事件作出反应(以阻止合成的基于按键的手势)
  • onDragStarted 开始拖动的回调
  • onDragStopped 停止拖动的回调
  • reverseDirection 是否反方向 举例:Text 拖动,并通过监听拖动的距离从而来改变Text的水平方向上的Offset,从而来达到拖动的效果。代码如下:
@Preview
@Composable
fun draggableTest(){
    var offsetX by remember { mutableStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                },
                onDragStarted = {
                    Log.e("ccm","startDrag")
                },
                onDragStopped = {
                    Log.e("ccm","endDrag")
                }
            ),
        text = "Drag me!"
    )
}

六:Modifier.swipeable 滑动

使用 swipeable 修饰符,您可以拖动元素,释放后,这些元素通常朝一个方向定义的两个或多个锚点呈现动画效果。其常见用途是实现“滑动关闭”模式。 请务必注意,此修饰符不会移动元素,而只检测手势。您需要保存状态并在屏幕上表示,例如通过 offset 修饰符移动元素。先来看看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
){...}
  • state 滑动状态 SwipeableState 通过rememberSwipeableState()获取,可以获得当前的偏移量,以及当前值,还可以调用animateTo,或者snapTo去执行滑动到指定的value位置等。
  • anchors 锚点。
  • orientation 方向
  • enabled 是否可用
  • reverseDirection 是否反方向
  • interactionSource 可以获取用户的状态,比如是否是按下,是否获取焦点。以前在介绍Button的时候讲过。[Button的讲解]
  • thresholds 指定状态之间的阈值的位置。比如到这个临界值是从0-1的时候是0.3。那么当我们滑动从开始位置滑动不到0.3的时候放开,那么会自动滑回开始位置。如果超过0.3手放开那么就会自动滑到1的位置。
  • resistance 阻力-控制刷过边界时施加的阻力大小
  • velocityThreshold 即使未达到位置阈值,为使动画进入下一个状态,结束速度必须超过的阈值(以每秒dp为单位)。 举例,我们使用swipeable去实验开关的控件。开关可以通过滑动开启关闭,也可以通过点击开启关闭。代码如下:
@ExperimentalMaterialApi
@Preview
@Composable
fun SwipeableSample() {
    val width = 96.dp
    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
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .width(width)
            .swipeable(
                state = swipeableState,
                anchors = anchors,
                thresholds = { from, to ->
                    if(from==0){
                        FractionalThreshold(0.3f)
                    }else{
                        FractionalThreshold(0.7f)
                    }},
                orientation = Orientation.Horizontal
            )
            .background(Color.LightGray)
    ) {
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                .size(squareSize)
                .background(Color.DarkGray)
                .clickable {
                    scope.launch {
                        if(swipeableState.currentValue==0){
                            swipeableState.animateTo(1)
                        }else{
                            swipeableState.animateTo(0)
                        }
                    }
                }
        )
    }
}

效果如下:

七:Modifier.transformable 多点触控:平移、缩放、旋转

如需检测用于平移、缩放和旋转的多点触控手势,您可以使用 transformable 修饰符。此修饰符本身不会转换元素,只会检测手势。来看看Modifier.transformable的代码

fun Modifier.transformable(
    state: TransformableState,
    lockRotationOnZoomPan: Boolean = false,
    enabled: Boolean = true
){...}
  • state TransformableState状态 获取方式通过rememberTransformableState获取,rememberTransformableState有三个入参,1缩放改变多少,2平移改变多少,3旋转改变多少。state可以通过调用animatePanBy,animateZoomBy,animateRotateBy。panBy,zoomBy,rotateBy手动去进行平移,缩放,旋转。带animate开头是有动画的。
  • lockRotationOnZoomPan 为true时候是当处于平移缩放的时候,禁止旋转。
  • enabled 是否可用 举例:双指缩放,平移,旋转一个蓝色的Box。代码如下:
@Preview
@Composable
fun transformableTest(){
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state,lockRotationOnZoomPan = false)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

效果如下:

八:Modifier.pointerInput 手势检测器

Modifier.pointerInput 是手势检测器,先来看看Modifier.pointerInput的代码

fun Modifier.pointerInput(
    block: suspend PointerInputScope.() -> Unit
){...}
  • block 是PointerInputScope。PointerInputScope主要有如下几个扩展方法跟一个内部方法awaitPointerEventScope:
    • detectTapGestures 可以监听长按,点击,双击,按下
    • detectDragGestures 可以监听拖动。
    • detectHorizontalDragGestures 可以监听水平方向时候的拖动
    • detectVerticalDragGestures 可以监听竖直方向时候的拖动
    • detectDragGesturesAfterLongPress 可以监听长按之后的拖动
    • detectTransformGestures 检测平移,缩放,旋转的
    • forEachGesture 遍历每组事件。
    • awaitPointerEventScope 我们一个个来讲

8.1 detectTapGestures 可以监听长按,点击,按下,双击

先来看看detectTapGestures的代码

suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((Offset) -> Unit)? = null,
    onLongPress: ((Offset) -> Unit)? = null,
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null
){...}
  • onDoubleTap 双击的回调
  • onLongPress 长按的回调
  • onPress 按下
  • onTap 点击 举例:点击的时候Box变成黑色,长按时候变成蓝色,双击变成红色,按下时候变成黄色,代码如下:
@Preview
@Composable
fun detectTapGesturesTest(){
    val color = remember {
        mutableStateOf(Color.Gray)
    }
    Box(modifier = Modifier
        .pointerInput(Unit) {
            detectTapGestures(
                onDoubleTap = {
                    Log.e("ccm", "=onDoubleTap==")
                    // 双击变成红色
                    color.value = Color.Red
                },
                onLongPress = {
                    Log.e("ccm", "==onLongPress==")
                    // 长按变成蓝色
                    color.value = Color.Blue
                },
                onPress = {
                    Log.e("ccm", "==onPress==")
                    // 按下变成黄色
                    color.value = Color.Yellow
                },
                onTap = {
                    Log.e("ccm", "==onTap==")
                    // 点击时候变成黑色
                    color.value = Color.Black
                }
            )
        }
        .size(200.dp)
        .background(color.value)){
    }
}

8.2 detectDragGestures,detectHorizontalDragGestures,detectVerticalDragGestures,detectDragGesturesAfterLongPress。 拖动监听

detectDragGestures 是拖动的监听,detectHorizontalDragGestures是水平方向上的拖动的监听,detectVerticalDragGestures是竖直方向上拖动的监听,detectDragGesturesAfterLongPress是长按之后拖动的监听。来具体看看他们的代码

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

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

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

suspend fun PointerInputScope.detectDragGesturesAfterLongPress(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
){...}
  • onDragStart 开始拖动
  • onDragEnd 结束拖动
  • onDragCancel 取消拖动
  • onDrag 拖动中
  • onVerticalDrag 竖直方向拖动中
  • onHorizontalDrag 水平方向拖动中 举例:Box随着手指的拖动移动,代码如下:
@Preview
@Composable
fun detectDragGesturesTest(){
    Box(modifier = Modifier.fillMaxSize()) {

        var offsetX by remember { mutableStateOf(0f) }
        var offsetY by remember { mutableStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = {
                            Log.e("ccm","开始拖动===")
                        },
                        onDragEnd = {
                            Log.e("ccm","结束===")
                        },
                        onDragCancel = {
                            Log.e("ccm","取消===")
                        },
                        onDrag = { change, dragAmount ->
                            Log.e("ccm","拖动中===")
                            change.consumeAllChanges()
                            offsetX += dragAmount.x
                            offsetY += dragAmount.y
                        }
                    )
                }
        )
    }
}

效果如下:

8.3 detectTransformGestures 检测平移,缩放,旋转的

detectTransformGestures 是用来检测平移,缩放,旋转的。我们来具体看看它的代码:

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
){...}
  • panZoomLock 为true是缩放平移的时候禁止旋转。
  • onGesture 是平移,缩放,旋转的手势回调监听,centroid是中心点的坐标。pan是平移,zoom是缩放,rotation是旋转 举例:支持单点移动,多点缩放,平移,旋转的例子。代码如下:
@Preview
@Composable
fun detectTransformGesturesTest(){
    var scale by remember { mutableStateOf(1f) }
    var m_rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(
        Modifier
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = m_rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            .pointerInput(Unit) {
                detectTransformGestures(
                    panZoomLock = false,
                    onGesture = {
                        center,pan,zoom,rotation->
                        scale *= zoom
                        m_rotation += rotation
                        offset += pan
                    }
                )
            }
            .background(Color.Blue)
            .fillMaxSize()
    )
}

效果图如下:

8.4 awaitPointerEventScope(监听每个事件)

awaitPointerEventScope 是用于监听一个事件。先来看看awaitPointerEventScope的代码

suspend fun <R> awaitPointerEventScope(
        block: suspend AwaitPointerEventScope.() -> R
    ): R
  • block 是一个AwaitPointerEventScope,而AwaitPointerEventScope主要讲如下几个方法:
    • awaitFirstDown() Down事件的监听。
    • awaitDragOrCancellation() //拖动取消的回调
    • awaitHorizontalDragOrCancellation() //水平拖动取消的回调
    • awaitVerticalDragOrCancellation() //竖直拖动取消的回调
    • drag() //拖动的监听
    • horizontalDrag() //水平拖动的监听
    • verticalDrag() //竖直拖动的监听
    • awaitTouchSlopOrCancellation() 用于判断是否超过最小滑动距离。
    • awaitVerticalTouchSlopOrCancellation() 用于判断竖直方向上是否超过最小滑动距离。
    • awaitHorizontalTouchSlopOrCancellation() 用于判断水平方向上是否超过最小滑动距离
8.4.1 awaitFirstDown (Down事件的监听)

awaitFirstDown是Down事件的监听,来看看代码:

suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed: Boolean = true
): PointerInputChange {
    var event: PointerEvent
    do {
        event = awaitPointerEvent()
    } while (
        !event.changes.fastAll {
            if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
        }
    )
    return event.changes[0]
}
  • requireUnconsumed,如果requireUnconsumed为true,并且在PointerEventPass.Main过程中使用第一个down,则忽略该手势。 返回值是一个PointerInputChange。来看看PointerInputChange的代码:
@Immutable
class PointerInputChange(
    val id: PointerId,
    val uptimeMillis: Long,
    val position: Offset,
    val pressed: Boolean,
    val previousUptimeMillis: Long,
    val previousPosition: Offset,
    val previousPressed: Boolean,
    val consumed: ConsumedData,
    val type: PointerType = PointerType.Touch
){...}

可以看到PointerInputChange包含该事件的id,position位置等信息。接下来我们来举个例子,比如我们在屏幕上画个圆点。我们手指按在哪里,圆点就动画移动过度到我们按下的点。代码如下:

@Preview
@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

@Composable
fun Circle(modifier: Modifier){
    Canvas(modifier = modifier) {
        drawCircle(color = Color.Red,radius = 20f,center=Offset(10f,10f))
    }
}
private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())
8.4.2 drag(),horizontalDrag(),verticalDrag(),awaitDragOrCancellation(),awaitHorizontalDragOrCancellation(),awaitVerticalDragOrCancellation()

drag是Move事件的监听,horizontalDrag是水平方向的Move事件的监听,verticalDrag竖直方向Move事件的监听。awaitDragOrCancellation是drag取消的监听。awaitHorizontalDragOrCancellation是横向拖动取消的监听。awaitVerticalDragOrCancellation是竖直拖动取消的监听。来看看他们的代码:

suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
){...}

suspend fun AwaitPointerEventScope.horizontalDrag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
){...}

suspend fun AwaitPointerEventScope.verticalDrag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
){...}

suspend fun AwaitPointerEventScope.awaitDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {...}

suspend fun AwaitPointerEventScope.awaitVerticalDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {...}

suspend fun AwaitPointerEventScope.awaitHorizontalDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {...}

  • pointerId 是移动的那个事件的id
  • onDrag 是拖动的变化监听。有个参数change是变化后的PointerInputChange值 当awaitDragOrCancellation,awaitVerticalDragOrCancellation,awaitHorizontalDragOrCancellation返回null说明对应的跟踪的id的事件已经抬起。 下面我们来举个例子:比如还是一个Box随着手指去拖动。
@Preview
@Composable
fun dragTest(){
    val cacheOffset = remember() {
        mutableStateOf(Offset.Zero)
    }
    val offsetAnimatable = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        Modifier
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // down事件
                        val downPointerInputChange = awaitPointerEventScope {
                            awaitFirstDown()
                        }
                        offsetAnimatable.stop()
                        // 如果位置不在手指按下的位置,先动画的形式过度到手指按下的位置
                        if (cacheOffset.value.x != downPointerInputChange.position.x
                            && cacheOffset.value.y != downPointerInputChange.position.y
                        ) {
                            launch {
                                offsetAnimatable.animateTo(downPointerInputChange.position)
                                cacheOffset.value = downPointerInputChange.position
                            }
                        }

                        // touch Move事件
                        // 滑动的时候,box随着手指的移动去移动
                        awaitPointerEventScope {
                            drag(downPointerInputChange.id, onDrag = {
                                launch {
                                    offsetAnimatable.snapTo(it.position)
                                }
                                cacheOffset.value = it.position
                            })
                        }

                        // 在手指弹起的时候,才通过动画的形式,回到原点的位置
                        val dragUpOrCancelPointerInputChange = awaitPointerEventScope {
                            awaitDragOrCancellation(downPointerInputChange.id)
                        }
                        // 等于空,说明已经抬起
                        if(dragUpOrCancelPointerInputChange==null){
                            launch {
                                val result = offsetAnimatable.animateTo(Offset.Zero)
                                cacheOffset.value = Offset.Zero
                            }
                        }
                    }
                }
            }
            .fillMaxSize()
    ){
        Box(modifier = Modifier.offset{ IntOffset(offsetAnimatable.value.x.roundToInt(), offsetAnimatable.value.y.roundToInt()) }.size(50.dp).background(Color.Blue))
    }
}

初始效果如下: 下面再举例一个horizontalDrag的,横向滑动Box的例子。代码如下:

@Preview
@Composable
fun swipeToDismissTest(){
    Column() {
        Box(modifier=Modifier.swipeToDismiss(onDismissed={
            Log.e("ccm","===onDismissed==")
        }).background(Color.Blue).fillMaxWidth().height(100.dp)){
        }
    }
}

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        val decay = splineBasedDecay<Float>(this)
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Intercept an ongoing animation (if there's one).
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

初始效果如下:

8.4.3 awaitTouchSlopOrCancellation(),awaitVerticalTouchSlopOrCancellation(),awaitHorizontalTouchSlopOrCancellation()

awaitTouchSlopOrCancellation()是用于判断是否达到了最小滑动距离,awaitVerticalTouchSlopOrCancellation()用于判断是否达到了竖直方向上的最小滑动距离,awaitHorizontalTouchSlopOrCancellation() 用于判断水平方向上是否达到了最小滑动距离。 我们来看看他们的具体应用场景。记得前面讲过一个PointerInputScope.detectDragGestures的方法。具体来看看该方法的源码。

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
    forEachGesture {
        awaitPointerEventScope {
            val down = awaitFirstDown(requireUnconsumed = false)
            var drag: PointerInputChange?
            do {
                drag = awaitTouchSlopOrCancellation(down.id, onDrag)
            } while (drag != null && !drag.positionChangeConsumed())
            if (drag != null) {
                onDragStart.invoke(drag.position)
                if (
                    !drag(drag.id) {
                        onDrag(it, it.positionChange())
                    }
                ) {
                    onDragCancel()
                } else {
                    onDragEnd()
                }
            }
        }
    }
}

我们来分析一下,forEachGesture是反复的处理手势,如果没有添加forEachGesture那么手指检测之后走一遍。后面我们会讲到。val down = awaitFirstDown(requireUnconsumed = false) 这句代码我们可以看到拿到down的事件。drag = awaitTouchSlopOrCancellation(down.id, onDrag) 这边会去拿到这个是否达到最小滑动的事件,如果达到了,那么drag会不为空,如果没达到,drag会为空。所以这里while (drag != null && !drag.positionChangeConsumed())会循环的判断。直到不为空,并且该事件没有被消耗。才去调用onDragStart.invoke(drag.position)开始滑动的回调,以及后面的onDrag,onDragCancel,onDragEnd

8.5 forEachGesture(反复的处理手势)

forEachGesture是反复的处理手势。接下来,我们先来看看forEachGesture的代码

suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {...}
  • block 是一个PointerInputScope。也就是说forEachGesture里面可以使用PointerInputScope的所有的方法。 forEachGesture是反复的处理手势是什么意思呢? 举个例子:
@Preview
@Composable
fun test(){
    Column(modifier = Modifier.pointerInput(Unit) {
        
            awaitPointerEventScope {
                val id = awaitFirstDown().id
                Log.e("ccm","==awaitFirstDown==id===${id}===")

                drag(id,onDrag = {
                    Log.e("ccm==onDrag=","====id===${it.id}===position===${it.position}===changedToUp===${it.changedToUp()}==changeToDown==${it.changedToUp()}")
                })
            }
        
    }.fillMaxSize().background(Color.Red))
}

上面的代码,当我们在Column上滑动的时候,会打出来awaitFirstDown以及onDrag的log。但是当我们抬起手指之后再重新按下去对Column进行滑动,发现不打log了。也就是说这时候的手势监听只有一次。如果我们想要去反复的监听该手势。我们就可以添加forEachGesture。代码修改如下:

@Preview
@Composable
fun forEachGestureTest(){
    Column(modifier = Modifier.pointerInput(Unit) {
        forEachGesture {
            awaitPointerEventScope {
                val id = awaitFirstDown().id
                Log.e("ccm","==awaitFirstDown==id===${id}===")

                drag(id,onDrag = {
                    Log.e("ccm==onDrag=","====id===${it.id}===position===${it.position}===")
                })
            }
        }
    }.fillMaxSize().background(Color.Red))
}

这时候我们再去按下Column并且滑动,会发现打出来log。抬起手指,重新按下滑动还是会打出log。这个就是forEachGesture的作用。