Jetpack Compose(7)——觸摸反饋

来源:https://www.cnblogs.com/joy99/p/18272143
-Advertisement-
Play Games

目錄一、點按手勢1.1 Modifier.clickable1.2 Modifier.combinedClickable二、滾動手勢2.1 滾動修飾符 Modifier.verticalScorll / Modifier.horizontalScorll2.2 可滾動修飾符 Modifier.scr ...


目錄

本文介紹 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 修飾符來實現,也不難學。
下一篇文章單獨來介紹。

作者:SharpCJ     作者博客:http://joy99.cnblogs.com/     本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接。
您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 華為雲記憶體加速的“全自動主動緩存方案”,可通過界面可視化配置,支持用戶將MySQL表數據映射為GeminiDB中的Hash等結構,同時還支持數據過濾及過期等功能,配置完成後即可實現自動同步,不僅簡單易用還能提升效率。 ...
  • Percona Toolkit 神器全攻略(配置類) Percona Toolkit 神器全攻略系列共八篇,前文回顧: 前文回顧 Percona Toolkit 神器全攻略 Percona Toolkit 神器全攻略(實用類) 全文約定:$為命令提示符、greatsql>為GreatSQL資料庫提示 ...
  • 使用Percona Toolkit的pt-duplicate-key-checker工具時,偶爾會遇到"Error checking xxx: Wide character in print at /usr/bin/pt-duplicate-key-checker line 5248."這類錯誤。如 ...
  • 1.前言 因為小程式是由js代碼編寫的,我js學得不是特別的好,所以,剛開始以為js跟java一行,一行一行的執行,後面才發現,完全不是,所以有時候,我們在獲取用戶信息和openId的時候,要向後臺發送請求,所以有時有可能請求還沒有返回數據,小程式這邊已經賦值了,只能得到一個undifine,很桑心 ...
  • UINavigationController 是 iOS 中用於管理視圖控制器層次結構的一個重要組件,通常用於實現基於堆棧的導航。它提供了一種用戶界面,允許用戶在視圖控制器之間進行層次化的導航,例如從列表視圖到詳細視圖。 UINavigationController 的主要功能 管理視圖控制器堆棧: ...
  • UITabBarController 是 iOS 中用於管理和顯示選項卡界面的一個視圖控制器。它允許用戶在多個視圖控制器之間進行切換,每個視圖控制器對應一個選項卡。 主要功能 管理多個視圖控制器: UITabBarController 管理一個視圖控制器數組,每個視圖控制器對應一個選項卡。 顯示選項 ...
  • 在MVC模型中,V指view,負責用戶界面的顯示、處理用戶輸入,並將輸入傳遞給控制器。C是指ViewController,充當模型和視圖之間的中介。控制器接收用戶輸入,處理用戶請求,並將結果傳遞給視圖以更新顯示。本文詳細介紹在iOS開發中UIView與UIViewController的生命周期。 U ...
  • 目錄前言一、Jetpack Compose 中處理嵌套滾動的思想二、Modifier.nestedScroll2.1 NestedScrollConnection2.2 NestedScrollDispatcher三、實操講解3.1 父組件消費子組件給過來的事件——NestedScrollConne ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...