在應用程式中處理手勢時,請務必瞭解幾個重要術語和概念。本頁面說明指標、指標事件和手勢等詞彙,並介紹手勢的不同抽象層級。此外,也會深入探討事件的使用和傳播。
定義
如要瞭解本頁的各種概念,您必須瞭解一些使用的術語:
- 指標:可用來與應用程式互動的實體物件。在行動裝置上,最常見的指標是與觸控螢幕互動的手指。或者,您也可以使用觸控筆代替手指。在大螢幕上,您可以使用滑鼠或觸控板與螢幕間接互動。輸入裝置必須能夠「指向」座標,才能視為指標,因此鍵盤等裝置無法視為指標。在 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 修飾符,為任意可組合項新增自訂手勢
並非所有手勢都可透過現成的手勢修飾符實作。舉例來說,您無法使用修飾符來回應長按後的拖曳、按住 Control 鍵點選或三指輕觸。您可以自行編寫手勢處理常式,辨識這些自訂手勢。您可以使用 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 索引最高的可組合項。舉例來說,當您將兩個重疊的
Button可組合項新增至Box時,只有繪製在頂端的項目會收到指標事件。理論上,您可以建立自己的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之前收到事件。 - 在「主要傳遞」中,事件會從 UI 樹狀結構的葉節點向上流向 UI 樹狀結構的根節點。這個階段通常是您使用手勢的階段,也是監聽事件時的預設階段。在這個階段處理手勢,表示葉節點的優先順序高於父項,這對大多數手勢來說是最合理的行為。在我們的範例中,
Button會在ListItem之前收到事件。 - 在「Final pass」中,事件會再次從 UI 樹狀結構頂端流向葉節點。這個流程可讓堆疊中較高的元素回應父項的事件消耗。舉例來說,當按下動作變成拖曳可捲動的父項時,按鈕會移除波紋指示。
事件流程的示意圖如下:
輸入變更一經使用,就會從該點開始在流程中傳遞:
您可以在程式碼中指定感興趣的票證:
Modifier.pointerInput(Unit) { awaitPointerEventScope { val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial) val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final) } }
在這個程式碼片段中,每個 await 方法呼叫都會傳回相同的事件,但消耗資料可能已變更。
測試手勢
在測試方法中,您可以使用 performTouchInput 方法手動傳送指標事件。這樣一來,您就能執行更高層級的完整手勢 (例如雙指撥動或長按),或低層級手勢 (例如將游標移動特定像素):
composeTestRule.onNodeWithTag("MyList").performTouchInput { swipeUp() swipeDown() click() }
如需更多範例,請參閱 performTouchInput 說明文件。
瞭解詳情
如要進一步瞭解 Jetpack Compose 中的手勢,請參閱下列資源:
為您推薦
- 注意:系統會在 JavaScript 關閉時顯示連結文字
- Compose 中的無障礙功能
- 捲動
- 輕觸並按住