Jetpack Compose(7)——觸控反饋

SharpCJ發表於2024-06-27

目錄
  • 一、點按手勢
    • 1.1 Modifier.clickable
    • 1.2 Modifier.combinedClickable
  • 二、滾動手勢
    • 2.1 滾動修飾符 Modifier.verticalScorll / Modifier.horizontalScorll
    • 2.2 可滾動修飾符 Modifier.scrollable
  • 三、 拖動手勢
    • 3.1 Modifier.draggable
    • 3.2 Modifier.draggable2D
  • 四、錨定拖動
    • 4.1 可拖動狀態 AnchoredDraggableState
    • 4.2 updateAnchors
    • 4.3 requireOffset
    • 4.4 使用示例介紹
  • 五、轉換手勢
    • 5.1 Modifier.transformer
  • 六、自定義觸控反饋
    • 6.1 Modifier.pointerInput
      • 6.1.1 點選型別的基礎 API
      • 6.1.2 拖動型別基礎 API
      • 6.1.3 轉換型別基礎 API
    • 6.2 awaitPointerEventScope
    • 6.2.1 原始時間 awaitPointerEvent
    • 6.3 awaitEachGesture
    • 6.3 多指事件
    • 6.4 事件分發
      • 6.4.1 事件排程
      • 6.4.2 事件消耗
      • 6.4.3 事件傳播
  • 七、巢狀滾動 Modifier.NestedScroll

本文介紹 Jetpack Compose 中的手勢處理。

官方文件的對 Compose 中的互動做了分類,比如指標輸入、鍵盤輸入等。本文主要是介紹指標輸入,類比傳統 View 體系中的事件分發。

說明:在 Compose 中,手勢處理是透過 Modifier 實現的。這裡,有人可能要反駁,Button 這個可組合項,就是專門用來響應點選事件的,莫慌,接著往下看。

一、點按手勢

1.1 Modifier.clickable

fun Modifier.clickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit
)

Clickable 修飾符用來監聽元件的點選操作,並且當點選事件發生時會為被點選的元件施加一個波紋漣漪效果動畫的蒙層。

Clickable 修飾符使用起來非常簡單,在絕大多數場景下我們只需要傳入 onClick 回撥即可,用於處理點選事件。當然你也可以為 enable 引數設定為一個可變狀態,透過狀態來動態控制啟用點選監聽。

@Composable
fun ClickDemo() {
  var enableState by remember {
    mutableStateOf<Boolean>(true)
  }
  Box(modifier = Modifier
      .size(200.dp)
      .background(Color.Green)
      .clickable(enabled = enableState) {
        Log.d(TAG, "發生單擊操作了~")
      }
  )
}

這裡可以回答上面的問題,關於 Button 可組合項,我們看下 Button 的原始碼:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
    val contentColor by colors.contentColor(enabled)
    Surface(
        onClick = onClick,
        modifier = modifier.semantics { role = Role.Button },
        enabled = enabled,
        shape = shape,
        color = colors.backgroundColor(enabled).value,
        contentColor = contentColor.copy(alpha = 1f),
        border = border,
        elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
        interactionSource = interactionSource,
    ) {
        // ... 省略其它程式碼
    }
}

實際是 surface 元件響應的 onClick 事件。

@ExperimentalMaterialApi
@Composable
fun Surface(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    shape: Shape = RectangleShape,
    color: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(color),
    border: BorderStroke? = null,
    elevation: Dp = 0.dp,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    content: @Composable () -> Unit
) {
    val absoluteElevation = LocalAbsoluteElevation.current + elevation
    CompositionLocalProvider(
        LocalContentColor provides contentColor,
        LocalAbsoluteElevation provides absoluteElevation
    ) {
        Box(
            modifier = modifier
                .minimumInteractiveComponentSize()
                .surface(
                    shape = shape,
                    backgroundColor = surfaceColorAtElevation(
                        color = color,
                        elevationOverlay = LocalElevationOverlay.current,
                        absoluteElevation = absoluteElevation
                    ),
                    border = border,
                    elevation = elevation
                )
                .clickable(
                    interactionSource = interactionSource,
                    indication = rememberRipple(),
                    enabled = enabled,
                    onClick = onClick
                ),
            propagateMinConstraints = true
        ) {
            content()
        }
    }
}

我們看到,surface 的實現又是基於 Box, 最終 Box 是透過 Modifier.clickable 響應點選事件的。

1.2 Modifier.combinedClickable

fun Modifier.combinedClickable(
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onLongClickLabel: String? = null,
  onLongClick: (() -> Unit)? = null,
  onDoubleClick: (() -> Unit)? = null,
  onClick: () -> Unit
)

除了點選事件,我們經常使用到的還有雙擊、長按等手勢需要響應,Compose 提供了 Modifier.combinedClickable 用來響應對於長按點選、雙擊等複合類點選手勢,與 Clickable 修飾符一樣,他同樣也可以監聽單擊手勢,並且也會為被點選的元件施加一個波紋漣漪效果動畫的蒙層。

@Composable
fun CombinedClickDemo() {
  var enableState by remember {
    mutableStateOf<Boolean>(true)
  }
  Box(modifier = Modifier
    .size(200.dp)
    .background(Color.Green)
    .combinedClickable(
      enabled = enableState,
      onLongClick = {
        Log.d(TAG, "發生長按點選操作了~")
      },
      onDoubleClick = {
        Log.d(TAG, "發生雙擊操作了~")
      },
      onClick = {
        Log.d(TAG, "發生單擊操作了~")
      }
    )
  )
}

二、滾動手勢

這裡所說的滾動,是指可組合項的內容發生滾動,如果想要顯示列表,請考慮使用 LazyXXX 系列元件。

2.1 滾動修飾符 Modifier.verticalScorll / Modifier.horizontalScorll

fun Modifier.verticalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
)
  • state 表示滾動狀態
  • enabled 表示是否啟用 / 禁用該滾動
  • flingBehavior 參數列示拖動結束之後的 fling 行為,預設為 null, 會使用 ScrollableDefaults. flingBehavior 策略。
  • reverseScrolling, false 表示 ScrollState 為 0 時對應最頂部 top, ture 表示 ScrollState 為 0 時對應底部 bottom。
    注意:這個反轉不是指滾動方向反轉,而是對 state 的反轉,當 state 為 0 時,即處於列表最底部

大多數場景,我們只需要傳入 state 即可。

@Composable
fun TestScrollBox() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .wrapContentWidth()
            .height(80.dp)
            .verticalScroll(
                state = rememberScrollState()
            )
    ) {
        repeat(20) {
            Text("item --> $it")
        }
    }
}

藉助 ScrollState,您可以更改滾動位置或獲取其當前狀態。比如滾動到初始位置,則可以呼叫

state.scrollTo(0)

對於 Modifier.horizontalScorll,從命名可以看出,Modifier.verticalScorll 用來實現垂直方向滾動,而 Modifier.horizontalScorll 用來實現水平方向的滾動。這裡不再贅述了。

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

2.2 可滾動修飾符 Modifier.scrollable

scrollable 修飾符與滾動修飾符不同,scrollable 會檢測滾動手勢並捕獲增量,但不會自動偏移其內容。而是透過 ScrollableState 委派給使用者,而這是此修飾符正常執行所必需的。

構建 ScrollableState 時,您必須提供一個 consumeScrollDelta 函式,該函式將在每個滾動步驟(透過手勢輸入、平滑滾動或快速滑動)呼叫,並且增量以畫素為單位。此函式必須返回使用的滾動距離量,以確保在存在具有 scrollable 修飾符的巢狀元素時正確傳播事件。

注意:scrollable 修飾符不會影響應用該修飾符的元素的佈局。這意味著,對元素佈局或其子項所做的任何更改都必須透過 ScrollableState 提供的增量進行處理。另外還需要注意的是,scrollable 並不考慮子項的佈局,這意味著它不需要測量子項即可傳播滾動增量。

@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
    val lambdaState = rememberUpdatedState(consumeScrollDelta)
    return remember { ScrollableState { lambdaState.value.invoke(it) } }
}

看個具體例子:

@Composable
fun TestScrollableBox() {
    var offsetY by remember {
        mutableFloatStateOf(0f)
    }
    Column(modifier = Modifier
        .background(Color.LightGray)
        .wrapContentWidth()
        .offset(
            y = with(LocalDensity.current) {
                offsetY.toDp()
            }
        )
        .scrollable(
            orientation = Orientation.Vertical,
            state = rememberScrollableState { consumeScrollDelta ->
                offsetY += consumeScrollDelta
                consumeScrollDelta
            }
        )) {
        repeat(20) {
            Text("item --> $it")
        }
    }
}

執行效果如下:

其實很好理解,rememberScrollableState 提供了滾動的偏移量,需要自己對偏移量進行處理,並且需要指定消費。

除了自己實現 rememberScrollableState 之外,也可以用前面的 rememberScrollState,它提供了一個預設的實現,將滾動資料儲存在 ScrollState 的 value 中,並消費掉所有的滾動距離。但是 ScrollState 的值的範圍是大於 0 的,無法出現負數。

@Stable
class ScrollState(initial: Int) : ScrollableState {

}

可以看到 ScrollState 實現了 ScrollableState 這個介面。

另外,Modifier.scrollable 可滾動修飾符需要指定滾動方向垂直或者水平。相對而言,該修飾符處於更低階別,靈活性更強,而上一小節講到的滾動修飾符則是基於 Modifier.scrollable 實現的。理解好兩者的區別,才能在實際開發中選擇合適的 API。

三、 拖動手勢

3.1 Modifier.draggable

Modifier.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 需要可以記錄拖動狀態,獲取到拖動手勢的偏移量。orientation 指定監聽拖動的方向。
使用示例:

@Composable
fun DraggableBox() {
    var offsetX by remember {
        mutableFloatStateOf(0f)
    }
    Box(modifier = Modifier
        .offset {
            IntOffset(x = offsetX.roundToInt(), y = 0)
        }
        .background(Color.LightGray)
        .size(80.dp)
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { onDelta ->
                offsetX += onDelta
            }
        ))
}

注意:拖動手勢本身不會讓 UI 發生變化。透過 rememberDraggableState 構造一個 DraggableState,獲取拖動偏移量,然後把這個偏移量累加到某個狀態變數上,利用這個狀態來改變 UI 介面。
比如這裡使用了 offset 去改變元件的偏移量。

注意:由於Modifer鏈式執行,此時offset必需在draggable與background前面。

3.2 Modifier.draggable2D

Modifier.draggable 透過 orientation 引數指定方向,只能水平或者垂直方向拖動。而 Modifier.draggable2D 則是可以同時沿著水平或者垂直方向拖動。用法如下:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Draggable2DBox() {
    var offset by remember {
        mutableStateOf(Offset.Zero)
    }
    Box(modifier = Modifier
        .offset {
            IntOffset(x = offset.x.roundToInt(), y = offset.y.roundToInt())
        }
        .background(Color.LightGray)
        .size(80.dp)
        .draggable2D(
            state = rememberDraggable2DState { onDelta ->
                offset += onDelta
            }
        ))
}

Modifier.draggable 相比,Modifier.draggable2D 修飾符沒有了 orientation 引數,無需指定方向,同時,state 型別是 Draggable2DState。構造該 State 的 lambda 表示式的引數,delta 型別也變成了 Offset 型別。這樣就實現了在 2D 平面上的任意方向拖動。

四、錨定拖動

Modifier.anchoredDraggable 是 Jetpack Compose 1.6.0 引入的一個新的修飾符,替代了 Swipeable, 用來實現錨定拖動。

fun <T> Modifier.anchoredDraggable(
    state: AnchoredDraggableState<T>,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    interactionSource: MutableInteractionSource? = null
)

此修飾符引數:

  • state 一個 DraggableState 的例項。
  • orientation 我們要將內容拖入的方向,水平或者垂直。
  • enabled 用於啟用/禁用拖動手勢。
  • reverseDirection 是否反轉拖動方向。
  • interactionSource 用於拖動手勢的可選互動源。

錨定拖動物件有 2 個主要部分,一個是應用於要拖動的內容的修飾符,另一個是其狀態 AnchoredDraggableState,它指定拖動的操作方式。

除了建構函式之外,為了使用 anchoredDraggable 修飾符,我們還需要熟悉其他幾個 API,它們是 updateAnchors 和 requireOffset。

4.1 可拖動狀態 AnchoredDraggableState

class AnchoredDraggableState<T>(
    initialValue: T,
    internal val positionalThreshold: (totalDistance: Float) -> Float,
    internal val velocityThreshold: () -> Float,
    val animationSpec: AnimationSpec<Float>,
    internal val confirmValueChange: (newValue: T) -> Boolean = { true }
)

在這個建構函式中,我們有

  • initialValue,一個引數化引數,用於在首次呈現時捕捉可拖動的內容。
  • positionalThreshold 一個 lambda 表示式,用於根據錨點之間的距離確定內容是以動畫形式呈現到下一個錨點還是返回到原始錨點。
  • velocityTheshold 一個 lambda 表示式,它返回一個速度,用於確定我們是否應該對下一個錨點進行動畫處理,而不考慮位置Theshold。如果拖動速度超過此閾值,那麼我們將對下一個錨點進行動畫處理,否則使用 positionalThreshold。
  • animationSpec,用於確定如何對可拖動內容進行動畫處理。
  • confirmValueChange 一個lambda 表示式,可選引數,可用於否決對可拖動內容的更改。

值得注意的是,目前沒有可用的 rememberDraggableState 工廠方法,因此我們需要透過 remember 手動定義可組合檔案中的狀態。

4.2 updateAnchors

fun updateAnchors(
    newAnchors: DraggableAnchors<T>,
    newTarget: T = if (!offset.isNaN()) {
        newAnchors.closestAnchor(offset) ?: targetValue
    } else targetValue
)

我們使用 updateAnchors 方法指定內容將捕捉到的拖動區域上的停止點。我們至少需要指定 2 個錨點,以便可以在這 2 個錨點之間拖動內容,但我們可以根據需要新增任意數量的錨點。

4.3 requireOffset

此方法僅返回可拖動內容的偏移量,以便我們可以將其應用於內容。同樣,anchoredDraggable 修飾符本身不會在拖動時移動內容,它只是計算使用者在螢幕上拖動時的偏移量,我們需要自己根據 requireOffset 提供的偏移量更新內容。

4.4 使用示例介紹

// 1. 定義錨點
enum class DragAnchors {
    Start,
    End,
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DragAnchorDemo() {
    val density = LocalDensity.current

    // 2. 使用 remember 宣告 AnchoredDraggableState,確保重組過程中能夠快取結果
    val state = remember {
        AnchoredDraggableState(
            // 3. 設定 AnchoredDraggableState 的初始錨點值
            initialValue = DragAnchors.Start,

            // 4. 根據行進的距離確定我們是否對下一個錨點進行動畫處理。在這裡,我們指定確定我們是否移動到下一個錨點的閾值是到下一個錨點距離的一半——如果我們移動了兩個錨點之間的半點,我們將對下一個錨點進行動畫處理,否則我們將返回到原點錨點。
            positionalThreshold = { totalDistance ->
                totalDistance * 0.5f
            },
            
            // 5.確定將觸發拖動內容以動畫形式移動到下一個錨點的最小速度,而不管是否 已達到 positionalThreshold 指定的閾值。
            velocityThreshold = {
                with(density) {
                    100.dp.toPx()
                }
            },

            // 6. 指定了在釋放拖動手勢時如何對下一個錨點進行動畫處理;這裡我們使用 一個補間動畫,它預設為 FastOutSlowIn 插值器
            animationSpec = tween(),

            confirmValueChange = { newValue ->
                true
            }
        ).apply {

            // 7. 使用前面介紹的 updateAnchors 方法定義內容的錨點 
            updateAnchors(

                // 8. 使用 DraggableAnchors 幫助程式方法指定要使用的錨點 。我們在這裡所做的是建立 DragAnchors 到內容的實際偏移位置的對映。在這種情況下,當狀態為“開始”時,內容將偏移量為 0 畫素,當狀態為“結束”時,內容偏移量將為 400 畫素。
                DraggableAnchors {
                    DragAnchors.Start at 0f
                    DragAnchors.End at 800f
                }
            )
        }
    }

    Box {
        Image(
            painter = painterResource(id = R.mipmap.ic_test), contentDescription = null,
            modifier = Modifier
                .offset {
                    IntOffset(x = 0, y = state.requireOffset().roundToInt())
                }
                .clip(CircleShape)
                .size(80.dp)
                // 使用前面定義的狀態
                .anchoredDraggable(
                    state = state,
                    orientation = Orientation.Vertical
                )
        )
    }
}

注意: offset 要先於 anchoredDraggable 呼叫
看看效果:

拖動超多一半的距離,或者速度超過閾值,就會以動畫形式跳到下一個錨點。

五、轉換手勢

5.1 Modifier.transformer

Modifier.transformer 修飾符允許開發者監聽 UI 元件的雙指拖動、縮放或旋轉手勢,透過所提供的資訊來實現 UI 動畫效果。

@ExperimentalFoundationApi
fun Modifier.transformable(
    state: TransformableState,
    canPan: (Offset) -> Boolean,
    lockRotationOnZoomPan: Boolean = false,
    enabled: Boolean = true
)
  • transformableState 必傳引數,可以使用 rememberTransformableState 建立一個 transformableState, 透過 rememberTransformableState 的尾部 lambda 可以獲取當前雙指拖動、縮放或旋轉手勢資訊。

  • lockRotationOnZoomPan 可選引數,當主動設定為 true 時,當UI元件已發生雙指拖動或縮放時,將獲取不到旋轉角度偏移量資訊。

使用示例:

@Composable
fun TransformBox() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    var rotationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }

    Box(modifier = Modifier
        .size(80.dp)
        .rotate(rotationAngle) // 需要注意 offset 與 rotate 的呼叫先後順序
        .offset {
            IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
        }
        .scale(scale)
        .background(Color.LightGray)
        .transformable(
            state = rememberTransformableState { zoomChange: Float, panChange: Offset, rotationChange: Float ->
                scale *= zoomChange
                offset += panChange
                rotationAngle += rotationChange
            }
        )
    )
}

注意:由於 Modifer 鏈式執行,此時需要注意 offset 與 rotate 呼叫的先後順序
⚠️示例( offset 在 rotate 前面): 一般情況下我們都需要元件在旋轉後,當出現雙指拖動時元件會跟隨手指發生偏移。若 offset 在 rotate 之前呼叫,則會出現元件旋轉後,當雙指拖動時元件會以當前旋轉角度為基本座標軸進行偏移。這是由於當你先進行 offset 說明已經發生了偏移,而 rotate 時會改變當前UI元件整個座標軸,所以出現與預期不符的情況出現。

效果如下:

六、自定義觸控反饋

6.1 Modifier.pointerInput

前面已經介紹完常用的手勢處理了,都非常簡單。但是有時候我們需要自定義觸控反饋。這時候可以就需要使用到 Modifier.PointerInput 修飾符了。該修飾符提供了更加底層細粒度的手勢檢測,前面講到的高階別修飾符實際上最終都是用底層低階別 API 來實現的。

fun Modifier.pointerInput(
    vararg keys: Any?,
    block: suspend PointerInputScope.() -> Unit
)

看下引數:

  • keys 當 Composable 元件發生重組時,如果傳入的 keys 發生了變化,則手勢事件處理過程會被中斷。
  • block 在這個 PointerInputScope 型別作用域程式碼塊中我們便可以宣告手勢事件處理邏輯了。透過 suspend 關鍵字可知這是個協程體,這意味著在 Compose 中手勢處理最終都發生在協程中。

在 PointerInputScope 作用域內,可以使用更加底層的手勢檢測的基礎API。

6.1.1 點選型別的基礎 API

API名稱 作用
detectTapGestures 監聽點選手勢
suspend fun PointerInputScope.detectTapGestures(
  onDoubleTap: ((Offset) -> Unit)? = null,
  onLongPress: ((Offset) -> Unit)? = null,
  onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
  onTap: ((Offset) -> Unit)? = null
)

看一下這幾個方法名,就能知道方法的作用。使用起來與前面講解高階的修飾符差不多。在 PointerInputScope 中使用 detectTapGestures,不會帶有漣波紋效果,方便我們根據需要進行定製。

  • onDoubleTap (可選):雙擊時回撥
  • onLongPress (可選):長按時回撥
  • onPress (可選):按下時回撥
  • onTap (可選):輕觸時回撥

這幾種點選事件回撥存在著先後次序的,並不是每次只會執行其中一個。onPress 是最普通的 ACTION_DOWN 事件,你的手指一旦按下便會回撥。如果你連著按了兩下,則會在執行兩次 onPress 後執行 onDoubleTap。如果你的手指按下後不抬起,當達到長按的判定閾值 (400ms) 會執行 onLongPress。如果你的手指按下後快速抬起,在輕觸的判定閾值內(100ms)會執行 onTap 回撥。

總的來說, onDoubleTap 回撥前必定會先回撥 2 次 Press,而 onLongPress 與 onTap 回撥前必定會回撥 1 次 Press

使用如下:

@Composable
fun PointerInputDemo() {
    Box(modifier = Modifier.background(Color.LightGray).size(100.dp)
        .pointerInput(Unit) {
            detectTapGestures(
                onDoubleTap = {
                    Log.i("sharpcj", "onDoubleTap --> $it")
                },
                onLongPress = {
                    Log.i("sharpcj", "onLongPress --> $it")
                },
                onPress = {
                    Log.i("sharpcj", "onPress --> $it")
                },
                onTap = {
                    Log.i("sharpcj", "onTap --> $it")
                }
            )
        }
    )
}
    

6.1.2 拖動型別基礎 API

API名稱 作用
detectDragGestures 監聽拖動手勢
detectDragGesturesAfterLongPress 監聽長按後的拖動手勢
detectHorizontalDragGestures 監聽水平拖動手勢
detectVerticalDragGestures 監聽垂直拖動手勢

detectDragGesturesAfterLongPress 為例:

@Composable
fun PointerInputDemo() {
    Box(modifier = Modifier.background(Color.LightGray).size(100.dp)
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDragStart = {

                },
                onDrag = { change: PointerInputChange, dragAmount: Offset ->

                },
                onDragEnd = {

                },
                onDragCancel = {

                }
            )
        }
    )
}

該 API 會檢測長按後的拖動,提供了四個回撥時機,onDragStart 會在拖動開始時回撥,onDragEnd 會在拖動結束時回撥,onDragCancel 會在拖動取消時回撥,而 onDrag 則會在拖動真正發生時回撥。

注意:

  1. onDragCancel 觸發時機多發生於滑動衝突的場景,子元件可能最開始是可以獲取到拖動事件的,當拖動手勢事件達到莫個指定條件時可能會被父元件劫持消費,這種場景下便會執行 onDragCancel 回撥。所以 onDragCancel 回撥主要依賴於實際業務邏輯。
  2. 上述 API 會檢測長按後的拖動,但是其本身並沒有提供長按時的回撥方法。如果要同時監聽長按,可以配合 detectTapGestures 一起使用。

由於這些檢測器是頂級檢測器,因此無法在一個 pointerInput 修飾符中新增多個檢測器。以下程式碼段只會檢測點按操作,而不會檢測拖動操作。

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

在內部,detectTapGestures 方法會阻塞協程,並且永遠不會到達第二個檢測器。如果需要向可組合項新增多個手勢監聽器,可以改用單獨的 pointerInput 修飾符例項:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

6.1.3 轉換型別基礎 API

API名稱 作用
detectTransformGestures 監聽拖動、縮放與旋轉手勢
@Composable
fun PointerInputDemo() {
    Box(modifier = Modifier.background(Color.LightGray).size(100.dp)
        .pointerInput(Unit) {
                detectTransformGestures(
                    panZoomLock = false,
                    onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->

                    }
                )
            }
        )
}

Modifier.transfomer 修飾符不同的是,透過這個 API 可以監聽單指的拖動手勢,和拖動型別基礎API所提供的功能一樣,除此之外還支援監聽雙指縮放與旋轉手勢。反觀 Modifier.transfomer 修飾符只能監聽到雙指拖動手勢。

  • panZoomLock(可選): 當拖動或縮放手勢發生時是否支援旋轉
  • onGesture(必須):當拖動、縮放或旋轉手勢發生時回撥

6.2 awaitPointerEventScope

前面介紹的 GestureDetector 系列 API 本質上仍然是一種封裝,既然手勢處理是在協程中完成的,所以手勢監聽必然是透過協程的掛起恢復實現的,以取代傳統的回撥監聽方式。

PointerInputScope 中我們使用 awaitPointerEventScope 方法獲得 AwaitPointerEventScope 作用域,在 AwaitPointerEventScope 作用域中我們可以使用 Compose 中所有低階別的手勢處理掛起方法。當 awaitPointerEventScope 內所有手勢事件都處理完成後 awaitPointerEventScope 便會恢復執行將 Lambda 中最後一行表示式的數值作為返回值返回。

suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R

AwaitPointerEventScope 中提供了一些基礎手勢方法:

API名稱 作用
awaitPointerEvent 手勢事件
awaitFirstDown 第一根手指的按下事件
drag 拖動事件
horizontalDrag 水平拖動事件
verticalDrag 垂直拖動事件
awaitDragOrCancellation 單次拖動事件
awaitHorizontalDragOrCancellation 單次水平拖動事件
awaitVerticalDragOrCancellation 單次垂直拖動事件
awaitTouchSlopOrCancellation 有效拖動事件
awaitHorizontalTouchSlopOrCancellation 有效水平拖動事件
awaitVerticalTouchSlopOrCancellation 有效垂直拖動事件

6.2.1 原始時間 awaitPointerEvent

上層所有手勢監聽 API 都是基於這個 API 實現的,他的作用類似於傳統 View 中的 onTouchEvent() 。無論使用者是按下、移動或抬起都將視作一次手勢事件,當手勢事件發生時 awaitPointerEvent 便會恢復返回監聽到的螢幕上所有手指的互動資訊。

以下程式碼可以用來監聽原始的指標事件。

@Composable
fun PointerEventDemo() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(100.dp)
        .pointerInput(Unit) {
            awaitPointerEventScope {
                while (true) {
                    val event = awaitPointerEvent()
                    Log.d(
                        "sharpcj",
                        "event --> type: ${event.type} - x: ${event.changes[0].position.x} - y: ${event.changes[0].position.y}"
                    )
                }
            }
        })
}

我們點選,看到日誌如下:

D  event --> type: Press - x: 188.0 - y: 124.0
D  event --> type: Release - x: 188.0 - y: 124.0

我們可以看到事件的 type 為 PressRelease

點選移動,日誌如下:

D  event --> type: Press - x: 178.0 - y: 178.0
D  event --> type: Move - x: 181.93164 - y: 175.06836
D  event --> type: Move - x: 183.99316 - y: 174.0
D  event --> type: Move - x: 185.5 - y: 171.0
D  event --> type: Move - x: 191.0 - y: 164.0
D  event --> type: Release - x: 191.0 - y: 164.0

注意事件的 type 為 PressMoveRelease

  • awaitPointerEventScope 建立可用於等待指標事件的協程作用域
  • awaitPointerEvent 會掛起協程,直到發生下一個指標事件

以上監聽原始輸入事件非常強大,類似於傳統 View 中完全實現 onTouchEvent 方法。但是也很複雜。實際場景中幾乎不會使用,而是直接使用前面講到的手勢檢測 GestureDetect API。

6.3 awaitEachGesture

Compose 手勢操作實際上是在協程中監聽處理的,當協程處理完一輪手勢互動後便會結束,當進行第二次手勢互動時由於負責手勢監聽的協程已經結束,手勢事件便會被丟棄掉。為了讓手勢監聽協程能夠不斷地處理每一輪的手勢互動,很容易想到可以在外層巢狀一個 while(true) 進行實現,然而這麼做並不優雅,且也存在著一些問題。更好的處理方式是使用 awaitEachGesture, awaitEachGesture 方法保證了每一輪手勢處理邏輯的一致性。實際上前面所介紹的 GestureDetect 系列 API,其內部實現都使用了 forEachGesture。

suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    awaitPointerEventScope {
        while (currentContext.isActive) {
            try {
                block()

                // Wait for all pointers to be up. Gestures start when a finger goes down.
                awaitAllPointersUp()
            } catch (e: CancellationException) {
                if (currentContext.isActive) {
                    // The current gesture was canceled. Wait for all fingers to be "up" before
                    // looping again.
                    awaitAllPointersUp()
                } else {
                    // detectGesture was cancelled externally. Rethrow the cancellation exception to
                    // propagate it upwards.
                    throw e
                }
            }
        }
    }
}

在 awaitEachGesture 中使用特定的手勢事件

@Composable
fun PointerEventDemo() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(100.dp)
        .pointerInput(Unit) {
            awaitEachGesture {
                awaitFirstDown().also { 
                    it.consume()
                    Log.d("sharpcj", "down")
                }
                val up = waitForUpOrCancellation()
                if (up != null) {
                    up.consume()
                    Log.d("sharpcj", "up")
                }
            }
        }
    )
}

6.3 多指事件

awaitPointerEvent 返回一個 PointerEvent, PointerEvent 中包含一個集合val changes: List<PointerInputChange> 這裡麵包含了所有手指的事件資訊,我們看看 PointerInputChange

@Immutable
class PointerInputChange(
    val id: PointerId,
    val uptimeMillis: Long,
    val position: Offset,
    val pressed: Boolean,
    val pressure: Float,
    val previousUptimeMillis: Long,
    val previousPosition: Offset,
    val previousPressed: Boolean,
    isInitiallyConsumed: Boolean,
    val type: PointerType = PointerType.Touch,
    val scrollDelta: Offset = Offset.Zero
) 

PointerInputChange 包含某個手指的事件具體資訊。
比如前面列印日誌的時候,使用了 event.changes[0].position 獲取座標資訊。

6.4 事件分發

6.4.1 事件排程

並非所有指標事件都會傳送到每個 pointerInput 修飾符。事件分派的工作原理如下:

  • 系統會將指標事件分派給可組合層次結構。新指標觸發其第一個指標事件時,系統會開始對“符合條件”的可組合項進行命中測試。如果可組合項具有指標輸入處理功能,則會被視為符合條件。命中測試從介面樹頂部流向底部。當指標事件發生在可組合項的邊界內時,即被視為“命中”。此過程會產生一個“命中測試正例”的可組合項鍊。
  • 預設情況下,當樹的同一級別上有多個符合條件的可組合項時,只有 Z-index 最高的可組合項才是“hit”。例如,當您向 Box 新增兩個重疊的 Button 可組合項時,只有頂部繪製的可組合項才會收到任何指標事件。從理論上講,您可以透過建立自己的 PointerInputModifierNode 實現並將 sharePointerInputWithSiblings 設為 true 來替換此行為。
  • 系統會將同一指標的其他事件分派到同一可組合項鍊,並根據事件傳播邏輯流動。系統不再對此指標執行命中測試。這意味著鏈中的每個可組合項都會接收該指標的所有事件,即使這些事件發生在該可組合項的邊界之外時。不在鏈中的可組合項永遠不會收到指標事件,即使指標位於其邊界內也是如此。
    由滑鼠或觸控筆懸停時觸發的懸停事件不屬於此處定義的規則。懸停事件會傳送給使用者點選的任意可組合項。因此,當使用者將指標從一個可組合項的邊界懸停在下一個可組合項的邊界上時,事件會傳送到新的可組合項,而不是將事件傳送到第一個可組合項。

官方文件的描述比較清楚,為了更加直觀,還是自己寫示例說明一下:

@Composable
fun EventConsumeDemo() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(300.dp)
        .pointerInput(Unit) {
            awaitEachGesture {
                while (true) {
                    val event = awaitPointerEvent()
                    Log.d("sharpcj", "outer box --> ${event.type}")
                }
            }
        }) {
        Box(modifier = Modifier
            .background(Color.Yellow)
            .size(200.dp)
            .pointerInput(Unit) {
                awaitEachGesture {
                    while (true) {
                        val event = awaitPointerEvent()
                        Log.d("sharpcj", "inner box --> ${event.type}")
                    }
                }
            })
    }
}

如上程式碼,我們在 inner Box 中輕畫一下。日誌如下:

D  inner box --> Press
D  out box --> Press
D  inner box --> Move
D  out box --> Move
D  inner box --> Move
D  out box --> Move
D  inner box --> Release
D  out box --> Release

解釋:

  1. inner Box 和 outer Box 都會收到事件, 因為點選的位置同時處在 inner Box 和 outer Box 之中,
  2. 由於 inner Box 的 Z-index 更高,所以先收到事件。

6.4.2 事件消耗

如果為多個可組合項分配了手勢處理程式,這些處理程式不應衝突。例如,我們來看看以下介面:

當使用者點按書籤按鈕時,該按鈕的 onClick lambda 會處理該手勢。當使用者點按列表項的任何其他部分時,ListItem 會處理該手勢並轉到文章。就指標輸入而言,Button 必須“消費”此事件,以便其父級知道不會再對其做出響應。開箱元件中包含的手勢和常見的手勢修飾符就包含這種使用行為,但如果您要編寫自己的自定義手勢,則必須手動使用事件。可以使用 PointerInputChange.consume 方法執行此操作:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

使用事件不會阻止事件傳播到其他可組合項。可組合項需要明確忽略已使用的事件。編寫自定義手勢時,您應檢查某個事件是否已被其他元素使用:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

看實際場景,當有兩個元件疊加起來的時候,我們更多時候只是希望外層的元件響應事件。怎麼處理,還是看上面的例子,我們只希望 inner Box 處理事件。修改程式碼如下:

@Composable
fun EventConsumeDemo() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(300.dp)
        .pointerInput(Unit) {
            awaitEachGesture {
                while (true) {
                    val event = awaitPointerEvent()
                    if (event.changes.any{ it.isConsumed }) {
                        Log.d("sharpcj", "A pointer is consumed by another gesture handler")
                    } else {
                        Log.d("sharpcj", "out box --> ${event.type}")
                    }
                }
            }
        }) {
        Box(modifier = Modifier
            .background(Color.Yellow)
            .size(200.dp)
            .pointerInput(Unit) {
                awaitEachGesture {
                    while (true) {
                        val event = awaitPointerEvent()
                        Log.d("sharpcj", "inner box --> ${event.type}")
                        event.changes.forEach{
                            it.consume()
                        }
                    }
                }
            })
    }
}

結果:

D  inner box --> Press
D  A pointer is consumed by another gesture handler
D  inner box --> Move
D  A pointer is consumed by another gesture handler
D  inner box --> Move
D  A pointer is consumed by another gesture handler
D  inner box --> Move
D  A pointer is consumed by another gesture handler
D  inner box --> Release
D  A pointer is consumed by another gesture handler

解釋:

  1. 我們在 inner Box 先收到事件並且處理之後,呼叫 event.changes.forEach { it.consume() } 將所有的事件都消費掉。
  2. inner Box 將事件消費掉,並不能阻止 outer Box 收到事件。
  3. 需要在 outer Box 中透過判斷事件是否被消費,來編寫正確的邏輯處理。

再修改下程式碼,我們使用上層的 GestureDetect API ,再次測試:

@Composable
fun EventConsumeDemo2() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(300.dp)
        .pointerInput(Unit) {
            detectTapGestures(
                onTap = {
                    Log.d("sharpcj", "outer Box onTap")
                }
            )
        }
    ) {
        Box(modifier = Modifier
            .background(Color.Yellow)
            .size(200.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = {
                        Log.d("sharpcj", "inner Box onTap")
                    }
                )
            })
    }
}

結果如下:

D  inner Box onTap
D  inner Box onTap
D  inner Box onTap
D  inner Box onTap

解釋:
Jetpack Compose 提供的開箱元件中包含的手勢和常見的手勢修飾符預設就做了上述判斷,事件只能被 Z-Index 最高的元件處理。

6.4.3 事件傳播

如前所述,指標事件會傳遞到其命中的每個可組合項。當有多個可組合項“疊”在一起的時候,事件會按什麼順序傳播呢?
實際上,事件會有三次流經可組合項:

  • Initial 在初始傳遞中,事件從介面樹頂部流向底部。此流程允許父項在子項使用事件之前攔截事件。
  • Main 在主傳遞中,事件從介面樹的葉節點一直流向介面樹的根。此階段是您通常使用手勢的位置,也是監聽事件時的預設傳遞。處理此傳遞中的手勢意味著葉節點優先於其父節點,這是大多數手勢最符合邏輯的行為。在此示例中,Button 會在 ListItem 之前收到事件。
  • Final 在“最終透過”中,事件會再一次從介面樹頂部流向葉節點。此流程允許堆疊中較高位置的元素響應其父項的事件消耗。例如,當按下按鈕變為可滾動父項的拖動時,按鈕會移除其漣漪指示。

實際上在諸如 awaitPointerEvent 的方法中,有一個引數 PointerEventPass,用來控制事件傳播的。

suspend fun awaitPointerEvent(
    pass: PointerEventPass = PointerEventPass.Main
)

分發順序:

PointerEventPass.Initial -> PointerEventPass.Main -> PointerEventPass.Final

對應了上面的描述。
看示例:

@Composable
fun EventConsumeDemo() {
    Box(modifier = Modifier
        .background(Color.LightGray)
        .size(300.dp)
        .pointerInput(Unit) {
            awaitEachGesture {
                while (true) {
                    val event = awaitPointerEvent(PointerEventPass.Main)
                    Log.d("sharpcj", "box1 --> ${event.type}")
                }
            }
        }) {
        Box(modifier = Modifier
            .background(Color.Yellow)
            .size(250.dp)
            .pointerInput(Unit) {
                awaitEachGesture {
                    while (true) {
                        val event = awaitPointerEvent(PointerEventPass.Initial)
                        Log.d("sharpcj", "box2 --> ${event.type}")
                    }
                }
            }) {
            Box(modifier = Modifier
                .background(Color.Blue)
                .size(200.dp)
                .pointerInput(Unit) {
                    awaitEachGesture {
                        while (true) {
                            val event = awaitPointerEvent(PointerEventPass.Final)
                            Log.d("sharpcj", "box3 --> ${event.type}")
                        }
                    }
                }) {
                Box(modifier = Modifier
                    .background(Color.Red)
                    .size(150.dp)
                    .pointerInput(Unit) {
                        awaitEachGesture {
                            while (true) {
                                val event = awaitPointerEvent()
                                Log.d("sharpcj", "box4 --> ${event.type}")
                            }
                        }
                    })
            }
        }
    }
}

執行結果:

D  box2 --> Press
D  box4 --> Press
D  box1 --> Press
D  box3 --> Press
D  box2 --> Move
D  box4 --> Move
D  box1 --> Move
D  box3 --> Move
D  box2 --> Move
D  box4 --> Move
D  box1 --> Move
D  box3 --> Move
D  box2 --> Release
D  box4 --> Release
D  box1 --> Release
D  box3 --> Release

解釋:

  1. Initial 傳遞由根節點到葉子結點依次傳遞,其中 Box2 攔截了。所有 Box2 優先處理事件。
  2. Main 傳遞由葉子節點傳遞到父節點, Box1 顯示宣告瞭 PointerEventPass.Main 和 Box4 沒有宣告,但是預設引數也是 PointerEventPass.Main, 由於是從葉子結點向根節點傳播,所以 Box4 先收到事件,然後是 Box1 收到事件。
  3. Final 事件再次從根節點傳遞到葉子結點,這裡只有 Box3 引數是 PointerEventPass.Final,所以 Box3 最後收到事件。

以上是事件傳播的分析,關於消費,同理,如果先收到事件的可組合項把事件消費了,後收到事件的元件根據需要判斷事件是否被消費即可。

七、巢狀滾動 Modifier.NestedScroll

關於巢狀滾動,相對複雜一點。不過在 Compose 中,使用 Modifier.NestedScroll 修飾符來實現,也不難學。
下一篇文章單獨來介紹。

相關文章