在應用程式中處理手勢處理時,請務必瞭解幾個詞彙和概念。本頁說明指標指標、指標事件和手勢,並介紹不同的手勢抽象層級。並深入探討事件的消費和傳播。
定義
如要瞭解本頁的各種概念,您需要瞭解所用術語:
- 指標:可用來與應用程式互動的實體物件。在行動裝置上,最常見的指標是手指與觸控螢幕互動。或者,你也可以使用觸控筆取代手指。
如果是大螢幕,可以使用滑鼠或觸控板間接與螢幕互動。輸入裝置必須能夠將座標的「點」視為指標,因此無法將鍵盤視為指標。在 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
以處理輕觸手勢,也可以套用 verticalScroll
讓 Column
處理垂直捲動。
有許多修飾符可處理不同類型的手勢:
- 使用
clickable
、combinedClickable
、selectable
、toggleable
和triStateToggleable
修飾符處理輕觸和按下動作。 - 使用
horizontalScroll
、verticalScroll
和一般的scrollable
修飾詞處理捲動。 - 使用
draggable
和swipeable
修飾詞處理拖曳作業。 - 使用
transformable
修飾符處理多點觸控手勢,例如平移、旋轉和縮放。
建議採用立即可用的手勢修飾符,而非自訂手勢處理功能。在純指標事件處理作業上方,修飾符會新增更多功能。舉例來說,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
提供監聽的方法:
- 按下、輕觸、輕觸兩下及長按:
detectTapGestures
- 拖曳:
detectHorizontalDragGestures
、detectVerticalDragGestures
、detectDragGestures
和detectDragGesturesAfterLongPress
- 轉換:
detectTransformGestures
這些是頂層偵測工具,因此您無法在單一 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
不回應指標向下或向上事件,而是需要知道指標何時進入或離開邊界。
等待特定事件或次要手勢
以下提供一組方法可協助識別手勢的常見部分:
- 暫停,直到指標按下
awaitFirstDown
為止,或等待所有指標上移waitForUpOrCancellation
。 - 使用
awaitTouchSlopOrCancellation
和awaitDragOrCancellation
建立低階拖曳事件監聽器。手勢處理常式會先暫停,直到指標到達觸控偏移並暫停,直到第一個拖曳事件完成為止。如果只想沿著單一軸拖曳,請改用awaitHorizontalTouchSlopOrCancellation
和awaitHorizontalDragOrCancellation
,或awaitVerticalTouchSlopOrCancellation
加awaitVerticalDragOrCancellation
。 - 暫停使用
awaitLongPressOrCancellation
,直到長按為止。 - 使用
drag
方法可持續監聽拖曳事件,或使用horizontalDrag
或verticalDrag
監聽單一軸上的拖曳事件。
套用多點觸控事件的計算結果
當使用者使用多個指標執行多點觸控手勢時,根據原始值瞭解所需的轉換會相當複雜。如果 transformable
修飾符或 detectTransformGestures
方法無法為您的用途提供足夠精細的控管功能,您可以監聽原始事件,並針對這些事件套用計算。這些輔助方法為 calculateCentroid
、calculateCentroidSize
、calculatePan
、calculateRotation
和 calculateZoom
。
事件調度與點擊測試
並非所有指標事件都會傳送至每個 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 樹狀結構,其中只有 ListItem
和 Button
會回應指標事件:
指標事件會在三次「傳遞」期間,通過這些可組合項三次:
- 在「初始傳遞」中,事件會從 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 中的手勢:
為您推薦
- 注意:系統會在 JavaScript 關閉時顯示連結文字
- Compose 中的無障礙功能
- 捲動
- 輕觸並按下