1、自定義觸摸與一維滑動監測
之前我們在講 Modifier 時講過如下與手勢檢測相關的 Modifier:
Modifier.clickable { }
Modifier.combinedClickable { }
Modifier.pointerInput {detectTapGestures { }
}
這里對以上內容就不再贅述了,直接去講解更復雜的 Modifier 實現更復雜的觸摸反饋效果。
在傳統的 View 體系中,在自定義觸摸反饋的內容時,對于 View 我們通常都是重寫它的 onTouchEvent(),對于 ViewGroup 可能還需要重寫 onInterceptTouchEvent(),極少數時候會更深入地去重寫 dispatchTouchEvent()。當然,原生也提供了較為上層的 API 來簡化手勢檢測,比如 GestureDetectorCompat 與 ScaleGestureDetectorCompat。
而在 Compose 中,情況也是類似的。在 pointerInput() 內調用 awaitEachGesture(),在其內部通過 awaitPointerEvent() 可以獲得觸摸事件:
Modifier.pointerInput(Unit) {awaitEachGesture {// 循環調用 awaitPointerEvent() 可獲得每一個觸摸事件val event = awaitPointerEvent()}
}
這種用法偏底層,Compose 在上層提供了一些類似于 GestureDetectorCompat 的非常完備的 API,比如上面提到的 clickable() 與 combinedClickable() 就是點擊相關的 API,下面我們逐步介紹滑動手勢相關的 API。
滑動手勢有兩個常用的 API scrollable() 與 draggable(),后者是前者的底層支撐。
1.1 draggable()
先看 draggable():
/**
* 為單個方向的 UI 元素配置觸摸拖動。將拖動距離報告給 DraggableState,允許用戶根據拖動增量做出反應
* 并更新它們的狀態。這個組件的常見用例是當您需要能夠在屏幕上的組件內拖動某物并通過一個浮點值表示該
* 狀態時。如果您需要控制整個拖動流程,請考慮使用 pointerInput,配合像 detectDragGestures 這樣的
* 輔助函數。如果您正在實現滾動/快速滑動行為,請考慮使用 scrollable。
* 參數:
* state - DraggableState 可拖動對象的狀態。定義了用戶端邏輯如何解釋拖動事件
* orientation - 拖動的方向
* enabled - 是否啟用拖動
* interactionSource - MutableInteractionSource,用于在拖動時發出 DragInteraction.Start
* startDragImmediately - 當設置為 true 時,可拖動對象將立即開始拖動,并阻止其他手勢檢測器對
* “按下”事件做出反應(以阻止組合的基于按壓的手勢)。這旨在允許最終用戶通過按壓在動畫小部件上“捕捉”它。
* 當您拖動的值正在穩定/動畫化時,設置此選項非常有用
* onDragStarted - 當拖動即將在起始位置開始時將調用的回調,允許用戶暫停并準備拖動,如果需要的話。
* 此掛起函數與可拖動范圍一起調用,允許進行異步處理,如果需要的話
* onDragStopped - 當拖動完成時將調用的回調,允許用戶根據速度做出反應并處理。此掛起函數與可拖動范圍
* 一起調用,允許進行異步處理,如果需要的話
* reverseDirection - 反轉滾動的方向,因此從頂部到底部的滾動將表現得像從底部到頂部,從左到右的滾動將
* 表現得像從右到左
*/
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
): Modifier
draggable() 有兩個必填的參數 state 和 orientation。在 Compose 中,所有可操作的組件或 Modifier 都會接收一個 state 參數用于手動操作界面。因為 Compose 是一個嚴格的聲明式 UI 框架,開發者是拿不到那些實際的 UI 對象的,更無法直接操作它們。但操作不了 UI 對象本身,不意味著也操作不了界面。我們可以通過操作 UI 對象依賴的狀態對象來實現 UI 的改變。比如說對于 LazyColumn 而言:
@Composable
fun LazyColumn(modifier: Modifier = Modifier,state: LazyListState = rememberLazyListState(),contentPadding: PaddingValues = PaddingValues(0.dp),reverseLayout: Boolean = false,verticalArrangement: Arrangement.Vertical =if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,horizontalAlignment: Alignment.Horizontal = Alignment.Start,flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),userScrollEnabled: Boolean = true,content: LazyListScope.() -> Unit
)
我們可以通過修改它的 state 參數改變 UI 界面。比如:
@Composable
fun LazyColumnSample() {val listState = rememberLazyListState()// animateScrollToItem() 是掛起函數,需要協程。scrollToItem() 是瞬間跳到指定 Itemval scope = rememberCoroutineScope()Column {LazyColumn(Modifier.weight(1f), listState) {items(List(50) { it + 1 }) {Text("Number $it", Modifier.padding(5.dp))}}Button(onClick = { scope.launch { listState.animateScrollToItem(20) } },Modifier.height(40.dp)) {Text("修改 LazyColumn 狀態")}}
}
點擊按鈕時操作 state 以動畫方式讓列表滾動到第 21 個列表項:
因此,修改組件依賴的 state 就是外界控制 UI 變化的一種手段。對于 draggable() 來說,它依賴的 state 類型為 DraggableState,我們可以通過 rememberDraggableState() 來提供 DraggableState 對象:
@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {val onDeltaState = rememberUpdatedState(onDelta)return remember { DraggableState { onDeltaState.value.invoke(it) } }
}
該函數的參數 onDelta 是一個回調函數,Float 參數就是這一次拖動在指定方向上產生的位移量,指定方向可以是水平或垂直方向,在 draggable() 的第二個參數上指定:
Box(Modifier.size(50.dp).background(Color.Red).draggable(rememberDraggableState {println("本次拖動距離為:$it")}, Orientation.Horizontal)
)
向右滑動時輸出正數,向左滑動時輸出負數。
enabled 控制 draggable() 這個 Modifier 是否生效,是一個條件性臨時的開關,符合某些條件時就生效,否則就失效。
interactionSource 是交互源,對 draggable() 修飾的范圍進行觸摸相關的狀態監控的,比如說:
setContent {// 創建一個 InteractionSource 對象val interactionSource = remember { MutableInteractionSource() }// 監聽 InteractionSource 所在的組件的拖拽狀態val isDragged by interactionSource.collectIsDraggedAsState()Column {Box(Modifier.size(50.dp).background(Color.Red).draggable(rememberDraggableState {println("本次拖動距離為:$it")},Orientation.Horizontal,interactionSource = interactionSource))// 根據 Box 的拖拽狀態顯示不同的文字Text(if (isDragged) "拖動中" else "靜止")}
}
InteractionSource 可以監聽所在組件的交互狀態,有四個函數可用:
分別監聽組件的拖拽、聚焦、懸空、按壓狀態。我們舉的例子是監聽了組件的拖拽狀態,效果如下:
startDragImmediately 指是否在用戶手指按下后立即開始拖動流程,如設置為 false 則會在用戶手指拖動一小段距離后再開始拖動流程。傳統的 ViewGroup 也有這個選項,比如用戶點擊 RecyclerView 中的列表項時,可能會有一個很微小的拖動,如果 startDragImmediately 設置為 true,那么這個微小的拖動會導致列表產生相應的微小位移。但如果為 false,則 RecyclerView 會不認為這個點擊時產生的微小位移是拖動行為,進而不去滑動列表。設置為 false 用戶體驗會好一些。
onDragStarted 與 onDragStopped 是兩個掛起回調函數,用于響應在開始拖拽與結束拖拽時的額外需求,比如開始拖動時震動一下。
reverseDirection 將手勢反向。
寫一個簡單例子,在一個方向上拖動文字。因為拿不到 Text 組件本身,因此要通過修改它的位移實現:
@Composable
fun DraggableText() {var offsetX by remember { mutableStateOf(0f) }Box(Modifier.fillMaxSize()) {Text("Compose",Modifier.offset { IntOffset(offsetX.roundToInt(), 0) }.draggable(rememberDraggableState { offsetX += it }, Orientation.Horizontal))}
}
1.2 scrollable()
scrollable() 只是一個滑動的監測工具,它不具備讓組件具有滑動功能的效果。而 verticalScroll() 與 horizontalScroll() 才能讓一個組件切實地具備滑動功能,就像在傳統 View 體系下為一個不具備滑動功能的組件在外面套上了一個 ScrollView。但 verticalScroll() 與 horizontalScroll() 底層是通過 scrollable() 進行滑動監測的。
前面說過 draggable() 是 scrollable() 的底層支撐,scrollable() 在 draggable() 的基礎上又增加了三個比較重要的功能:
- 慣性滑動
- 嵌套滑動
- 滑動觸邊效果 overScroll
增加的三個功能是針對滑動布局場景下增加的功能,比如對于 ScrollView、RecyclerView 這種布局組件而言,在滑動時具備慣性滑動、嵌套滑動,在滑動到邊緣時展示觸邊效果才有用。但手指滑動的監測未必都是用于滑動布局,比如進度條一般是用不上新增的三個效果的,所以對于這類組件只需要 draggable() 提供的基礎功能即可。
scrollable() 的用法與 draggable() 相似,區別就在于 scrollable() 新增的三個功能都作為參數需要配置:
@ExperimentalFoundationApi
fun Modifier.scrollable(state: ScrollableState, // 滾動狀態,包含嵌套滑動orientation: Orientation,overscrollEffect: OverscrollEffect?, // 滾動觸邊效果enabled: Boolean = true,reverseDirection: Boolean = false,flingBehavior: FlingBehavior? = null, // 慣性滑動interactionSource: MutableInteractionSource? = null
)
第一個參數 state 的類型是 ScrollableState,scrollable() 支持的嵌套滑動就是通過 ScrollableState 實現的。在指定這個參數時,可以通過 rememberScrollableState() 創建一個 ScrollableState 對象,但是在該函數的 lambda 表達式中必須返回一個 Float 值表名自己消耗了多少滾動距離:
Modifier.scrollable(rememberScrollableState {println("滾動了 $it 個像素")it // 必須把消耗了多少滾動距離返回,因為要實現嵌套滑動功能},orientation = Orientation.Horizontal,overscrollEffect = ScrollableDefaults.overscrollEffect(),
)
overscrollEffect 參數用來指定滑動到邊緣時的效果,該參數可以為空,為空時沒有效果。可以通過 ScrollableDefaults 提供的 overscrollEffect() 指定一個默認效果。
此外還有 flingBehavior 參數用于指定慣性滑動,它可以為空并且默認值就給了 null。但是 scrollable() 的底層實現 pointerScrollable() 會在傳入的 flingBehavior 為 null 時給它指定一個默認值 ScrollableDefaults.flingBehavior():
object ScrollableDefaults {/*** Create and remember default [FlingBehavior] that will represent natural fling curve.*/@Composablefun flingBehavior(): FlingBehavior {val flingSpec = rememberSplineBasedDecay<Float>()return remember(flingSpec) {DefaultFlingBehavior(flingSpec)}}/*** Create and remember default [OverscrollEffect] that will be used for showing over scroll* effects.*/@Composable@ExperimentalFoundationApifun overscrollEffect(): OverscrollEffect {return rememberOverscrollEffect()}
}
因此無論是慣性滑動還是觸邊效果,都可以使用 ScrollableDefaults 提供的默認效果即可。
1.3 swipeable()
swipeable() 與 scrollable() 一樣,都對 draggable() 實現了定制,只不過場景不同。scrollable() 是用于橫向或縱向的滑動布局組件,swipeable() 適用于有明確終點的滑動場景,比如滑動刪除、側滑菜單、滑動解鎖等。
swipeable() 在 material 和 material2 包中可見,在 material3 中被隱藏了。因此只能使用由它實現的組件,比如滑動刪除組件 SwipeToDismiss。
2、嵌套滑動與 nestedScroll()
在傳統的 View 體系中,開始是不支持嵌套滑動的,像很原始的 ScrollView 與 ListView 都不支持嵌套滑動。后來隨著需求的增加,Google 以 Jetpack 庫的方式開始支持嵌套滑動,如 RecyclerView 與 NestedScrollView 等。Compose 作為 Jetpack 庫中比較年輕的成員,自然會對嵌套滑動有更全面、更完善的支持,比如 Modifier 的 scrollable()、LazyColumn/LazyRow 都支持嵌套滑動。
并且,Compose 對于很多常見的嵌套滑動需求都提供了實現。比如 Scaffold 配合 LargeTopAppBar 可以實現頂部 AppBar 與頁面內容的嵌套滑動。但應用的需求千變萬化,總會遇到 Compose 沒有提供現成實現的嵌套滑動的需求,這就是本節課要學習嵌套滑動的目的。
Compose 通過 nestedScroll() 自定義嵌套滑動邏輯,在介紹 nestedScroll() 之前,先介紹一下 Compose 嵌套滑動的整體邏輯。
Compose 的嵌套滑動由最內層的組件負責觸摸事件的處理,它的外層組件并不直接負責觸摸事件的處理,而是只接受它的子滑動組件發送過來的滑動事件的回調通知,以實現整體的嵌套滑動。
具體來說,每一個組件在進行滑動之前會先去詢問它的父組件是否要消費這一段滑動距離,如果父組件不消費或者不完全消費,剩余的距離才會由自己消費。如果自己沒有完全消費掉這段距離,會第二次詢問父組件是否消費。也就是說,子組件在滑動之前與滑動之后會對父組件進行兩次詢問,以應對父組件優先滑動與子組件優先滑動的不同情況。父組件需要開放子組件滑動之前與滑動之后兩個回調函數,這樣子組件在滑動前后會分別調用這兩個接口通知父組件,子組件要進行滑動了,這樣父組件可以根據自身需求決定是否在子組件之前或之后滑動。
接下來再看 nestedScroll() 的具體內容:
/**
* 修改元素以使其參與嵌套滾動層次結構。
* 有兩種參與嵌套滾動的方式:作為滾動子元素,通過 NestedScrollDispatcher 將滾動事件傳遞到嵌套滾動鏈;
* 作為嵌套滾動鏈中的成員,提供 NestedScrollConnection,當下面的另一個嵌套滾動子元素分派滾動事件時將調用它。
* 在鏈中以 NestedScrollConnection 的形式參與是強制性的,但滾動事件的分派是可選的,因為有些情況下,元素
* 希望參與嵌套滾動,但本身并不是可滾動的。
* 參數:
* connection - 與嵌套滾動系統連接以參與事件鏈接,當可滾動的后代正在滾動時接收事件
* dispatcher - 要附加到嵌套滾動系統上的對象,可以在其上調用 dispatch* 方法,以通知嵌套滾動系統中的
* 祖先發生的滾動
*/
fun Modifier.nestedScroll(connection: NestedScrollConnection,dispatcher: NestedScrollDispatcher? = null
): Modifier
每次調用 nestedScroll(),都會向 Compose 的 UI 樹內插入一個嵌套滑動的節點,而 nestedScroll() 的兩個參數,就是為該節點提供的信息。
如果把嵌套滑動看作一個鏈條,為了讓這個鏈條中插入一個新的滑動組件后還能正常運轉,被插入的滑動組件需要做三件事:
- 作為嵌套滑動的子組件,在滑動前和滑動后都去調用一下嵌套滑動父組件的相應的回調函數(由 NestedScrollDispatcher 實現)
- 作為嵌套滑動的父組件,在嵌套滑動子組件滑動前調用父組件的回調函數時,做出正確的處理:
- 再向上,回調自身的嵌套滑動的父組件的回調函數
- 如果父組件不消費或者沒有完全消費,則觸發自身的滑動邏輯(由 NestedScrollConnection 實現)
其中,第 2 點中的第一條已經由 nestedScroll() 實現了,因此自定義嵌套滑動組件時要通過參數實現余下的兩件事。
下面我們舉個例子來說明如何實現。首先準備一個支持滑動但不支持嵌套滑動的組件 Column,然后在該組件內部添加一個 LazyColumn 作為嵌套滑動的內部組件:
@Composable
fun NestedScrollSample() {var offsetY by remember { mutableStateOf(0f) }Column(Modifier.offset { IntOffset(0, offsetY.roundToInt()) }// draggable() 沒支持嵌套滑動.draggable(rememberDraggableState { offsetY += it }, Orientation.Vertical)) {for (i in 1..10) {Text("第 $i 項")}LazyColumn(Modifier.height(50.dp).background(Color.Yellow)) {items(5) {Text("內部 List - 第 $it 項")}}}
}
然后我們給 Column 的 Modifier 加上 nestedScroll(),使其變為一個支持嵌套滑動的組件,主要問題在于如何提供 nestedScroll() 的兩個參數 NestedScrollConnection 和 NestedScrollDispatcher。
NestedScrollConnection 會讓組件作為父組件去響應子組件滑動時,父組件應該做哪些事。比如對于我們要實現的例子來說,當子組件 LazyColumn 滑動時,我們是優先讓子組件滑動,子組件滑動之后如果有未消費完的距離進行二次詢問時,我們作為父組件才進行消費,因此在實現 NestedScrollConnection 時要重寫 onPostScroll():
val connection = remember {object : NestedScrollConnection {// onPostScroll() 負責子組件滑動之后父組件做出的對應處理,如果父組件需要// 在子組件滑動之前進行滑動的話,需要重寫 onPreScroll()override fun onPostScroll(consumed: Offset,available: Offset,source: NestedScrollSource): Offset {offsetY += available.y// 返回消耗了多少滑動距離return available}}// 慣性滑動可以用 ScrollableDefaults.flingBehavior().performFling()}
NestedScrollConnection 接口內實際定義了四個函數,分別是 onPreScroll()、onPostScroll()、onPreFling() 與 onPostFling(),分別用于實現子組件滑動前、子組件滑動后、子組件慣性滑動前、子組件慣性滑動后,父組件是否消費以及如何消費滑動距離的邏輯。如果實際需求中需要對慣性滑動也有要求,可以使用上節講過的 ScrollableDefaults.flingBehavior() 獲取一個默認行為的 FlingBehavior,再調用它的 performFling() 進行慣性滑動。
以上是對 nestedScroll() 所需的第一個參數 NestedScrollConnection 的講解。對于第二個參數 NestedScrollDispatcher 要做的就是在子組件滑動前與滑動后回調父組件對應的滑動函數,將處理權交給父組件,并根據父組件的滑動結果做出相應的處理:
// 創建一個 NestedScrollDispatcher 對象val dispather = remember { NestedScrollDispatcher() }Column(Modifier.offset { IntOffset(0, offsetY.roundToInt()) }.draggable(rememberDraggableState {// 子組件滑動前,先詢問父組件是否滑動val consumed = dispather.dispatchPreScroll(Offset(0f, it), NestedScrollSource.Drag)// 子組件滑動offsetY += it - consumed.y// 子組件滑動后,再次詢問父組件是否滑動dispather.dispatchPostScroll(Offset(0f, it), Offset.Zero, NestedScrollSource.Drag)}, Orientation.Vertical).nestedScroll(connection, dispather))
dispatchPreScroll() 有兩個參數 available 與 source:
/*** 觸發預滾動傳遞。這會觸發所有祖先的 NestedScrollConnection.onPreScroll,使它們有可能在需要時* 預先消費增量。* 參數:* available - 從滾動事件中獲得的增量* source - 滾動事件的來源* 返回所有祖先在鏈中預先消耗的總增量。此增量對于此節點不可用,因此它應相應地調整消耗。*/fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {return parent?.onPreScroll(available, source) ?: Offset.Zero}
available 表示子組件傳過來的本次可以滑動的偏移總量,在這個嵌套滑動鏈上的所有祖先本次預滑動的偏移量不能超過這個值。在例子中,這個參數傳的是 Offset(0f, it),表示把垂直方向上本次可以滑動的所有增量都給了父組件。
source 表示滑動事件的來源,常見的來源有 Drag 滑動、Fling 慣性滑動兩種。
dispatchPreScroll() 的返回值就是父組件消費了多少距離,由于我們的例子中沒有讓 NestedScrollConnection 重寫 onPreScroll(),因此 dispatchPreScroll() 就沒有消費,所以返回 0。
接下來就是子組件滑動,這里是用 offsetY += it - consumed.y
讓子組件消費了所有距離。因為 consumed.y 是 0,那么 offsetY 的增量就是本次所有的滑動增量 it。
最后再調用 dispatchPostScroll() 再次詢問父組件是否進行滑動,它有三個參數,第一個是子組件消費了多少距離,第二個參數是給父組件剩余的可滑動距離是多少。由于前面已經讓子組件消費了所有距離,因此第一個參數填子組件消費掉的 Offset(0f, it),第二個參數填剩余可滑動距離,實際上是 0,也即 Offset.Zero。
完整的代碼如下:
@Composable
fun NestedScrollSample() {var offsetY by remember { mutableStateOf(0f) }val dispather = remember { NestedScrollDispatcher() }val connection = remember {object : NestedScrollConnection {// onPostScroll() 負責子組件滑動之后父組件做出的對應處理,如果父組件需要// 在子組件滑動之前進行滑動的話,需要重寫 onPreScroll()override fun onPostScroll(consumed: Offset,available: Offset,source: NestedScrollSource): Offset {offsetY += available.y// 返回消耗了多少滑動距離return available}}}Column(Modifier.offset { IntOffset(0, offsetY.roundToInt()) }.draggable(rememberDraggableState {val consumed = dispather.dispatchPreScroll(Offset(0f, it), NestedScrollSource.Drag)offsetY += it - consumed.ydispather.dispatchPostScroll(Offset(0f, it), Offset.Zero, NestedScrollSource.Drag)}, Orientation.Vertical).nestedScroll(connection, dispather)) {for (i in 1..10) {Text("第 $i 項")}LazyColumn(Modifier.height(50.dp).background(Color.Yellow)) {items(5) {Text("內部 List - 第 $it 項")}}}
}
效果如下:
3、二維滑動監測
Compose 沒有直接提供可以進行二維滑動監測的 Modifier 函數,但是我們可以用更底層的函數來實現這個功能。
首先調用 Modifier.pointerInput(),pointerInput() 這個函數是一個非常底層的函數,它可以做最底層的觸摸檢測,拿到最基礎的觸摸事件,從而做最精細的觸摸手勢的識別與算法的定制。并且它內部也提供了常用的手勢識別函數,比如與拖拽相關的有如下四種:
雖然看起來是 4 組 8 個函數,但實際上同名的指向是同一個函數,只不過調用方式不同。以 detectDragGestures() 為例:
/**
* 等待指針按下和任何方向上的觸摸閾值,然后對每個拖動事件調用 onDrag 的手勢檢測器。它遵循
* awaitTouchSlopOrCancellation 的觸摸閾值檢測,但一旦觸摸閾值被越過,它將自動消耗位置變化。
* 當通過最后已知的指針位置傳遞觸摸閾值時,將調用 onDragStart。當所有指針都彈起時將調用 onDragEnd,
* 并且如果另一個手勢消耗了指針輸入,則將調用 onDragCancel,取消這個手勢。
*/
suspend fun PointerInputScope.detectDragGestures(onDragStart: (Offset) -> Unit = { },onDragEnd: () -> Unit = { },onDragCancel: () -> Unit = { },onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)
它的前三個參數都提供了默認值,而最后一個函數參數是唯一必填的參數。因此假如你只想指定 onDrag,那么就對應圖中的第一種 lambda 的調用方式,選擇后 AS 會自動將參數填好;如果還需要提供其他參數,就使用第二種調用方式。
我們重點來看 onDrag 這個回調函數的兩個參數:
- change:更底層的類,封裝了觸發這一次滑動事件背后的觸摸事件的那根手指相關的信息
- dragAmount:拖拽的偏移量,類型是 Offset 可以表示二維的偏移量
Compose 將 Android 原生的觸摸事件封裝成 Compose 的觸摸事件,并且對這個觸摸事件進行分析,分析后將其封裝為滑動事件,但它底層還是 Compose 的觸摸事件,所以才稱為“滑動事件背后的觸摸事件”。然后,Compose 也支持多點觸控,只不過 Compose 的多點觸控監控的是最先落下的手指,而 Android 原生多點觸控監測的是最后落下的手指。造成的不同體驗就是,原生的可以兩個手指輪番滑動,而 Compose 只有在先落下的手指抬起后,才能由后落下的手指繼續滑動。
但不論是 Compose 還是原生,它們都只監測正在滑動中的手指,而 change 就含有這個手指的信息,包括手指的 ID 以及位置信息。因此 dragAmount 可以視為一個便捷的冗余信息,它所表示的拖拽的偏移量是可以通過 change 內包含的信息計算出來的。
然后我們再說說這一組函數中的其他三個函數:
- detectHorizontalDragGestures() 與 detectVerticalDragGestures() 是在水平與垂直方向上的一維滑動監測
- detectDragGesturesAfterLongPress() 是監測在長按之后的二維滑動手勢
最后再來說說 pointerInput() 配合 detectDragGestures() 與 draggable() 的區別。二者都是做滑動監測的,所以代碼沒有本質上的區別,甚至在最底層的代碼上(如拖拽的判定代碼)使用相同的函數,它們的主要區別在于定位不同:
- draggable() 是較上層、更高級的函數,需要實現相同功能時,用 draggable() 寫起來更方便
- detectDragGestures() 是較底層、更基礎的函數,能提供更多底層信息
4、多指手勢
多指手勢可以分為兩類:
- 自定義的多指手勢識別:自己分析觸摸到屏幕上的每一根手指的滑動軌跡,然后識別對應的手勢
- 利用 API 處理預設好的手勢
本節講解第 2 種,下節介紹第 1 種。
Compose 提供了三種多指手勢的識別:移動、放縮與旋轉,它們都存在于 detectTransformGestures 函數中,該函數也需要在 pointerInput() 內使用。我們先來看該函數的參數:
/**
* 一個用于旋轉、平移和縮放的手勢檢測器。一旦達到觸摸閾值,用戶可以使用旋轉、平移和縮放手勢。
* 當發生旋轉、縮放或平移中的任何一種手勢時,將調用 onGesture,傳遞旋轉角度(以度為單位)、
* 縮放比例因子和像素偏移量。每個改變都是前一次調用和當前手勢之間的差異。在觸摸閾值之后,這將
* 消耗所有位置變化。onGesture 還將提供所有已按下指針的中心點。
*
* 如果 panZoomLock 設置為 true,則只有在檢測到旋轉的觸摸閾值之前才允許旋轉,然后才能進行平移
* 或縮放動作。否則,將檢測到平移和縮放手勢,但不會檢測到旋轉手勢。如果 panZoomLock 設置為 false,
* 則一旦觸摸閾值被觸發,將檢測到所有三種手勢。
*/
suspend fun PointerInputScope.detectTransformGestures(panZoomLock: Boolean = false,onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
第一個參數 panZoomLock 是一個開關,分為兩種情況:
- 當它為 false 時,三種手勢可以同時識別
- 當它為 true 時,如果先識別到旋轉操作,那么就不會再監測滑動和縮放;如果先監測到滑動或縮放,那么就不會再監測旋轉。相當于是把滑動和縮放放在一組,旋轉單獨放在另外一組,只監測先觸發的那組操作
onGesture 參數是一個回調函數,它的參數含義如下:
- centroid:所有按下手指的中心點。這是一個輔助參數,需要配合后面三個參數使用
- pan:位移參數,表示中心點 centroid 在這一時刻與上一時刻的位置偏移量
- zoom:這一時刻與上一時刻相比的放縮倍數
- rotation:這一時刻與上一時刻相比的旋轉角度