瞭解手勢

在應用程式中處理手勢處理時,請務必瞭解幾個詞彙和概念。本頁說明指標指標、指標事件和手勢,並介紹不同的手勢抽象層級。並深入探討事件的消費和傳播。

定義

如要瞭解本頁的各種概念,您需要瞭解所用術語:

  • 指標:可用來與應用程式互動的實體物件。在行動裝置上,最常見的指標是手指與觸控螢幕互動。或者,你也可以使用觸控筆取代手指。 如果是大螢幕,可以使用滑鼠或觸控板間接與螢幕互動。輸入裝置必須能夠將座標的「點」視為指標,因此無法將鍵盤視為指標。在 Compose 中,指標類型會透過 PointerType 納入指標變更中。
  • 指標事件:說明在特定時間內與應用程式一或多個指標的低階互動。任何指標互動 (例如將手指放在螢幕上或拖曳滑鼠) 都會觸發事件。在 Compose 中,這類事件的所有相關資訊都包含在 PointerEvent 類別中。
  • 手勢:可解讀為單一動作的一系列指標事件。舉例來說,輕觸手勢可視為一連串向下事件和向上事件。許多應用程式都使用常見的手勢 (例如輕觸、拖曳或轉換),您也可以視需要建立自訂手勢。

不同抽象層

Jetpack Compose 提供不同等級的抽象化處理手勢。頂層是元件支援Button 等可組合項會自動納入手勢支援。如要為自訂元件新增手勢支援功能,您可以在任意可組合項中新增手勢修飾符 (例如 clickable)。最後,如果您需要自訂手勢,可以使用 pointerInput 修飾符。

建議您以最高抽象層級為基礎,提供所需的功能。如此一來,您就可以享有圖層包含的最佳做法。例如,Button 包含的語意資訊比 clickable 更豐富,易於存取;前者包含比原始 pointerInput 實作更多的資訊。

元件支援

Compose 中有許多立即可用的元件都包含一些內部手勢處理。舉例來說,LazyColumn 會透過捲動內容回應拖曳手勢,按下 Button 後會顯示漣漪效果,而 SwipeToDismiss 元件則包含關閉元素的滑動邏輯。這類手勢處理程序會自動運作。

在內部手勢處理旁邊,許多元件也需要呼叫端處理手勢。舉例來說,Button 會自動偵測輕觸事件並觸發點擊事件。您將 onClick lambda 傳遞至 Button 以回應手勢。同樣地,您可以在 Slider 中新增 onValueChange lambda,回應使用者拖曳滑桿控點時做出回應。

適用於您的用途時,建議元件中包含手勢,因為這類手勢提供了立即可用的聚焦和無障礙功能,而且都經過充分測試。例如,Button 是以特殊方式標示,以便無障礙服務正確將其描述為按鈕,而不是任何可點擊的元素:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

如要進一步瞭解 Compose 中的無障礙功能,請參閱「Compose 中的無障礙功能」。

使用修飾符為任意可組合項新增特定手勢

您可以將手勢修飾符套用至任何任意可組合項,讓可組合項監聽手勢。舉例來說,您可以將一般 Box 設為 clickable 以處理輕觸手勢,也可以套用 verticalScrollColumn 處理垂直捲動。

有許多修飾符可處理不同類型的手勢:

建議採用立即可用的手勢修飾符,而非自訂手勢處理功能。在純指標事件處理作業上方,修飾符會新增更多功能。舉例來說,clickable 修飾符不僅會新增按下和輕觸的偵測功能,還會新增語意資訊、有關互動、懸停、聚焦和鍵盤支援的視覺指標。您可以查閱 clickable 的原始碼,瞭解如何新增這項功能。

使用 pointerInput 修飾符為任意可組合項新增自訂手勢

並非所有手勢都會透過立即可用的手勢修飾符實作。舉例來說,您無法使用修飾符對長按、按住 Ctrl 鍵或三指輕觸的拖曳動作做出回應。但您可以改為編寫自己的手勢處理常式來識別這些自訂手勢。您可以利用 pointerInput 修飾符建立手勢處理常式,進而存取原始指標事件。

下列程式碼會監聽原始指標事件:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

如果分割這個程式碼片段,核心元件會是:

  • pointerInput 修飾符。您傳送一或多個金鑰。當其中一個鍵的值變更時,系統會重新執行修飾符內容 lambda。這個範例會將選用的篩選器傳遞至可組合項。如果篩選器的值改變,則應重新執行指標事件處理常式,確保記錄正確的事件。
  • awaitPointerEventScope 會建立協同程式範圍,可用於等待指標事件。
  • awaitPointerEvent 會暫停協同程式,直到下一個指標事件發生為止。

雖然監聽原始輸入事件的功能強大,但根據這些原始資料撰寫自訂手勢也相當複雜。為簡化自訂手勢的建立過程,我們提供許多公用程式方法。

偵測完整手勢

您可以監聽發生的特定手勢並適當回應,而無需處理原始指標事件。AwaitPointerEventScope 提供監聽的方法:

這些是頂層偵測工具,因此您無法在單一 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" }
            }
    )
}

處理每個手勢的事件

根據定義,手勢會從指標向下事件開始。您可以使用 awaitEachGesture 輔助方法,而非通過每個原始事件的 while(true) 迴圈。所有指標都解除 (表示手勢已完成) 後,awaitEachGesture 方法會重新啟動包含的區塊:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

實際上,除非您要回應指標事件,但未識別手勢,否則您幾乎會想要使用 awaitEachGesture。比如,hoverable 不回應指標向下或向上事件,而是需要知道指標何時進入或離開邊界。

等待特定事件或次要手勢

以下提供一組方法可協助識別手勢的常見部分:

套用多點觸控事件的計算結果

當使用者使用多個指標執行多點觸控手勢時,根據原始值瞭解所需的轉換會相當複雜。如果 transformable 修飾符或 detectTransformGestures 方法無法為您的用途提供足夠精細的控管功能,您可以監聽原始事件,並針對這些事件套用計算。這些輔助方法為 calculateCentroidcalculateCentroidSizecalculatePancalculateRotationcalculateZoom

事件調度與點擊測試

並非所有指標事件都會傳送至每個 pointerInput 修飾符。事件調度的運作方式如下:

  • 指標事件會分派到可組合階層。新指標觸發其第一個指標事件時,系統會開始命中,測試「符合資格」的可組合項。如果可組合項具有指標輸入處理功能,就視為符合資格。命中測試流程會從 UI 樹狀結構的頂端到底部。指標事件發生在可組合項的邊界內時,可組合項即為「命中」。這項程序會產生可組合元件鏈結,並加以正面測試。
  • 根據預設,如果樹狀結構的同一層級有多個符合資格的可組合項,則只有 Z-index 值最高的可組合項為「hit」。舉例來說,在 Box 中新增兩個重疊的 Button 可組合項時,只有上方繪製的可組合項會收到任何指標事件。理論上,您可以建立自己的 PointerInputModifierNode 實作並將 sharePointerInputWithSiblings 設為 true,藉此覆寫這項行為。
  • 相同指標的後續事件會分派至同一個可組合項鏈,並根據事件傳播邏輯將資料流分配至該鏈結。系統不會針對這個指標執行其他命中測試。也就是說,鏈結中的每個可組合項都會收到該指標的所有事件,即使這些事件發生在該可組合項的邊界外也一樣。不在鏈結中的可組合項絕不會接收指標事件,即使指標位於其邊界內。

滑鼠或觸控筆懸停觸發的懸停事件則不是由此處定義的規則例外。懸停事件會傳送至其命中的任何可組合項。因此,當使用者將指標從某個可組合項的邊界移到下一個可組合項時,而不是將事件傳送至第一個可組合項,系統就會將事件傳送至新的可組合項。

事件用量

當多個可組合項獲派手勢處理常式時,這些處理常式不應發生衝突。例如,請查看這個 UI:

含有圖片的清單項目、含有兩段文字的欄,以及一個按鈕。

使用者輕觸書籤按鈕時,按鈕的 onClick lambda 會處理該手勢。使用者輕觸清單項目的其他部分時,ListItem 會處理該手勢並前往文章。在指標輸入方面,按鈕必須「消耗」此事件,讓其父項知道不能再回應該事件。現成元件和常見的手勢修飾符包含這個操作行為,但如果您是自行編寫自訂手勢,就必須手動取用事件。您可以使用 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
            }
        }
    }
}

活動傳播

如前文所述,指標變更會傳遞至每個命中的每個可組合項。但是,如果有多個這類可組合項,事件的傳播順序為何?如果您從上一節取得範例,此 UI 會轉譯為下列 UI 樹狀結構,其中只有 ListItemButton 會回應指標事件:

樹狀結構。頂層為 ListItem,第二層有「圖片」、「欄」和「按鈕」,而「欄」則會分割為兩組文字。醒目顯示清單項目和按鈕。

指標事件會在三次「傳遞」期間,通過這些可組合項三次:

  • 在「初始傳遞」中,事件會從 UI 樹狀結構的頂端流向底部。此流程可讓父項先攔截事件,再讓子項使用。舉例來說,工具提示必須攔截長按,而不是將其傳送至子項。在我們的範例中,ListItem 會在 Button 之前接收事件。
  • 在「Main Pass」中,事件會從 UI 樹狀結構的分葉節點往到 UI 樹狀結構的根層級。這個階段是您平常使用手勢的位置,也是監聽事件時的預設傳遞。處理此傳遞中的手勢時,分葉節點的優先順序高於父項,這是多數手勢的最邏輯行為。在我們的範例中,Button 會在 ListItem 之前接收事件。
  • 在「最終傳遞」中,事件會從 UI 樹狀結構的頂端再流向分葉節點。這個流程可讓堆疊中位置較高的元素回應父項的事件消耗量。舉例來說,當按下按鈕轉變成可捲動的父項時,按鈕會移除漣漪效果指示。

事件流程以視覺方式呈現,如下所示:

取用輸入變更後,這項資訊會從流程中的該點開始傳遞:

您可以在程式碼中指定感興趣的票證:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

在這個程式碼片段中,每個等待方法呼叫都會傳回相同的事件,但用量相關資料可能會有所變更。

測試手勢

在測試方法中,您可以使用 performTouchInput 方法手動傳送指標事件。這可讓您執行較高層級的完整手勢 (例如雙指撥動或長按) 或低階手勢 (例如將遊標移動特定像素數):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

如需更多範例,請參閱 performTouchInput 說明文件。

瞭解詳情

歡迎參考下列資源,進一步瞭解 Jetpack Compose 中的手勢: