前言
在最近我利用業(yè)余時(shí)間使用 Compose 寫(xiě)的 Gihub APP 中,它的首頁(yè)結(jié)構(gòu)是這樣的:

采用了 Drawer 嵌套 Pager 的結(jié)構(gòu)。
這就會(huì)出現(xiàn)一個(gè)問(wèn)題,那就是 Drawer 和 Pager 都需要監(jiān)聽(tīng)橫向滑動(dòng)手勢(shì),從而實(shí)現(xiàn)展開(kāi) Drawer 和 切換 Pager 的功能。
那么,如果我把他們嵌套在一起使用會(huì)發(fā)生什么呢?誰(shuí)能最終拿到手勢(shì)事件呢?
而在我這個(gè) APP 中其中一個(gè) Pager 頁(yè)面中又額外嵌套了一個(gè) webview 頁(yè)面,這個(gè)頁(yè)面也需要獲取到橫向滑動(dòng)手勢(shì),如果此時(shí)我切換到這個(gè)頁(yè)面又會(huì)發(fā)生什么呢?
實(shí)際發(fā)生的和我們希望的
在上述的場(chǎng)景中,實(shí)際會(huì)發(fā)生的情況是,如果我們手勢(shì)滑動(dòng)的位置是在中間的內(nèi)容區(qū)域的話,觸發(fā)的會(huì)是 Pager 的手勢(shì),從而發(fā)生頁(yè)面的切換。
如果我們的手勢(shì)滑動(dòng)的區(qū)域是在中間內(nèi)容區(qū)域之外,例如頂部菜單欄的話觸發(fā)的就是 Drawer 的手勢(shì),從而發(fā)生展開(kāi) Drawer 的事件。
至于這個(gè) webview ,顯然,無(wú)論如何它都不會(huì)被觸發(fā)。
那么,我們期望的處理流程是什么樣的呢?
顯然,我們期望的是無(wú)論滑動(dòng)哪個(gè)地方的手勢(shì)都應(yīng)該優(yōu)先觸發(fā) Pager 然后在到達(dá)第一頁(yè)的時(shí)候向右滑動(dòng)改為觸發(fā) Drawer 而向左滑動(dòng)則依舊觸發(fā) Pager ,至于 webview 我們期望的是無(wú)論它是在哪個(gè)頁(yè)面,只要我們的手指觸摸到的是它的內(nèi)容范圍,則應(yīng)該優(yōu)先觸發(fā)它的手勢(shì)。
那么,怎么才能實(shí)現(xiàn)我們的目的呢?
其實(shí)在 Compose 1.2.0 版本中就已經(jīng)提供了官方的嵌套滾動(dòng)互操作的支持:Nested scrolling interop 。
仔細(xì)閱讀這篇指南就會(huì)發(fā)現(xiàn),雖然 Compose 官方提供了嵌套滾動(dòng)互操作的 API 支持,大多數(shù)情況下只需要添加一個(gè) Modifier.nestedScroll(nestedScrollInterop) 即可實(shí)現(xiàn)我們上述所說(shuō)的需求。
然而,并非所有的組件都支持上述這個(gè) API:
This issue is a result of the expectations built in scrollable composables. Scrollable composables have a "nested-scroll-by-default" rule, which means that any scrollable container must participate in the nested scroll chain, both as a parent via NestedScrollConnection, and as a child via NestedScrollDispatcher. The child would then drive a nested scroll for the parent when the child is at the bound. As an example, this rule allows Compose Pager and Compose LazyRow to work well together. However, when interoperability scrolling is being done with ViewPager2 or RecyclerView, since these don’t implement NestedScrollingParent3, the continuous scrolling from child to parent is not possible.
不幸的是,我這里所需要實(shí)現(xiàn)嵌套滾動(dòng)的恰好是不受支持的組件。
所以,我們只能自己去實(shí)現(xiàn)了。
解決 Drawer 和 Pager 的滑動(dòng)沖突
那么,我們就先從簡(jiǎn)單的開(kāi)始,先去解決同為 Compose 組件的 Drawer 和 Pager 的沖突。
在開(kāi)始之前,我們先明確一下我們需要解決的問(wèn)題,那就是我們需要實(shí)現(xiàn)如果在第一頁(yè)時(shí),如果向右滑則觸發(fā) Drawer 展開(kāi)側(cè)欄;向左滑則觸發(fā) Pager 切換至下一頁(yè)。如果不在第一頁(yè)那么就只需要觸發(fā) Pager 切換頁(yè)面。
為了解決這個(gè)問(wèn)題,我首先想到的方法是如同原生 View 那樣的,通過(guò)攔截觸摸事件,然后在按照我們需求去重新分配觸摸事件。
但是在我實(shí)際嘗試過(guò)程中發(fā)現(xiàn)在 Compose 中想要攔截并重新分配觸摸事件似乎不是那么的好實(shí)現(xiàn)。
所以我這里使用了一種折中的方法來(lái)實(shí)現(xiàn)。
由于相對(duì)于 Drawer 來(lái)說(shuō) Pager 是其子界面,所以這里我們選擇給 Pager 添加一個(gè) Modifier.draggable() 修飾,使用這個(gè)修飾我們可以獲取到在 Pager 的單一方向的滑動(dòng)手勢(shì),以及其滑動(dòng)距離等數(shù)據(jù):
HorizontalPager(
// ……
state = pagerState,
modifier = Modifier.draggable(state = rememberDraggableState {offset ->
// ……
// 這里的 offset 即獲取到的手勢(shì)滑動(dòng)的變化值
},
orientation = Orientation.Horizontal, // 這里表示只獲取水平方向上的手勢(shì)
enabled = pagerState.currentPage == 0)
) { page ->
// ……
}
在這里按照我們的需求,我們也給這個(gè) Modifier.draggable 修飾加上了啟用條件 enabled = pagerState.currentPage == 0 即只有當(dāng)前處于第一頁(yè)時(shí)才啟用這個(gè)獲取手勢(shì)的修飾。
接下來(lái),我們按照需求,判斷手勢(shì)的變化值來(lái)確定是需要展開(kāi) Drawer 還是切換 Pager ,其實(shí)判斷方法也很簡(jiǎn)單,如果值為正則說(shuō)明是向右滑動(dòng),則應(yīng)該展開(kāi) Drawer ;如果值為負(fù)則說(shuō)明是向左滑動(dòng),應(yīng)該要切換 Pager 的頁(yè)面:
HorizontalPager(
// ……
state = pagerState,
modifier = Modifier.draggable(state = rememberDraggableState {offset ->
if (drawerState.isClosed && !drawerState.isAnimationRunning) {
if (offset >= 5f) {
// ……
// 在這里觸發(fā)展開(kāi) Drawer
}
else if (offset < -5f && pagerState.canScrollForward && !pagerState.isScrollInProgress){
// ……
// 在這里觸發(fā)切換頁(yè)面
}
}
},
orientation = Orientation.Horizontal,
enabled = pagerState.currentPage == 0)
) { page ->
// ……
}
在這里我們?yōu)榱吮苊庹`觸,把判斷的閾值設(shè)置為了 ± 5 個(gè)單位。
另外,為了避免在已經(jīng)開(kāi)始展開(kāi) Drawer 或切換頁(yè)面時(shí)重復(fù)觸發(fā),我們首先要確保當(dāng)前 Drawer 處于關(guān)閉狀態(tài),且沒(méi)有處于狀態(tài)變化中:
if (drawerState.isClosed && !drawerState.isAnimationRunning)
同理,當(dāng)觸發(fā)切換頁(yè)面時(shí)也需要保證當(dāng)前沒(méi)有在切換過(guò)程中,且應(yīng)當(dāng)處于可以切換的狀態(tài):
if (offset < -5f && pagerState.canScrollForward && !pagerState.isScrollInProgress)
在這里我們分別用 drawerState.open() 和 pagerState.animateScrollToPage(1) 來(lái)觸發(fā)展開(kāi) Drawer 和切換頁(yè)面。
其實(shí)這里如果想做的更“友好”一點(diǎn)或者說(shuō)更“跟手”一點(diǎn),那么我們應(yīng)該是根據(jù)當(dāng)前的手勢(shì)滑動(dòng)的值實(shí)時(shí)更新相應(yīng)的布局變化值,直到達(dá)到某個(gè)閾值才認(rèn)為狀態(tài)變更完成,否則“彈回”未變更前。而不是像現(xiàn)在這樣,不管滑了多少距離,直接二話不說(shuō)就直接觸發(fā)狀態(tài)完全變化。
但是目前 Drawer 和 Pager 都沒(méi)有提供相應(yīng)的 API,所以只能這么粗暴的去實(shí)現(xiàn)了。
(Draer 雖然有一個(gè) drawerState.offset 參數(shù),但是它是只讀的)
至此,雖然不太完美,但是也實(shí)現(xiàn)了我們的需求,效果如下:

解決 Pager 和 Webview 的沖突
上一節(jié)我們講了同為 Compose 組件之間的嵌套滾動(dòng)沖突的解決方法,接下來(lái)我們講一講 Compose 嵌套原生 View 的滑動(dòng)沖突解決方法。
但是正如我在前言中所說(shuō), Compose 并不能很好的攔截并重新分配觸摸事件。但是 VIew 是可以很容易的做到這一點(diǎn)的,因此我們的想法很簡(jiǎn)單,就是在 Webview 中攔截掉所有的觸摸事件即可。
只是在這個(gè) APP 中,webview 是被嵌套在多個(gè)有可能攔截觸摸事件的 Compose 組件中的: webview -> LazyColumn -> SwipeRefresh -> Pager -> Drawer 。
好在,這幾個(gè)組件都提供了禁用攔截觸摸事件的參數(shù):
LazyColumn 中有一個(gè) userScrollEnabled 參數(shù),當(dāng)將這個(gè)參數(shù)設(shè)置為 false 時(shí),LazyColumn 就不會(huì)再攔截觸摸事件。
SwipeRefresh 中有一個(gè) swipeEnabled 參數(shù),當(dāng)將這個(gè)參數(shù)設(shè)置為 false 時(shí),SwipeRefresh 就不會(huì)再攔截觸摸事件。
HorizontalPager 中有一個(gè)名為 userScrollEnabled 的參數(shù),當(dāng)將這個(gè)參數(shù)設(shè)置為 false 時(shí),Pager 就不會(huì)再攔截觸摸事件。
ModalNavigationDrawer 的中有一個(gè)叫 gesturesEnabled 的參數(shù),將其設(shè)置為 false 時(shí)也不會(huì)再攔截觸摸事件。
所以這里我們就從這幾個(gè)參數(shù)下手,首先為 webview 設(shè)置觸摸監(jiān)聽(tīng),如果 webview 接收到了觸摸事件就回調(diào)給上述幾個(gè)函數(shù),設(shè)置其對(duì)應(yīng)的參數(shù)為 false,確保其不會(huì)攔截 webview 的觸摸事件,當(dāng) webview 失去觸摸事件時(shí)就將其設(shè)置會(huì) true,讓他們繼續(xù)相應(yīng)對(duì)應(yīng)組件的觸摸事件。
為了實(shí)現(xiàn)這個(gè)目的,我們首先給封裝的 webview 組件提供一個(gè) onTouchEvent 回調(diào):
@Composable
fun CustomWebView(
// ……
onTouchEvent: ((event: MotionEvent) -> Boolean)? = null
) {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
// ……
if (onTouchEvent != null) {
setOnTouchListener { v, event ->
onTouchEvent(event)
}
}
}
}
)
}
在上述代碼中,我們把觸摸事件回調(diào)給了 onTouchEvent 函數(shù)。
因此我們?cè)趯?shí)際調(diào)用 CustomWebView 這樣寫(xiě):
CustomWebView(
// ……
onTouchEvent = {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
changeaScrollState(false)
false
}
MotionEvent.ACTION_UP -> {
changeaScrollState(true)
false
}
else -> {
false
}
}
}
)
上述代碼中,我們通過(guò) changeaScrollState 函數(shù)設(shè)置我們一開(kāi)始提到的幾個(gè) Compose 組件的觸摸事件啟用狀態(tài)。
這里有一個(gè)地方需要注意,在 onTouchEvent 中記得一定要返回 false 表示只是獲取這個(gè)觸摸事件但是不消費(fèi),否則雖然觸摸事件不會(huì)被其他 Compose 攔截消費(fèi)了,但是卻被我們自己消費(fèi)了,webview 依然是接受不到這個(gè)觸摸事件的。
最終實(shí)現(xiàn)效果如下:

可以看到,現(xiàn)在 Webview 已經(jīng)能夠自由的滾動(dòng)而不會(huì)受到幾個(gè)父布局的影響了,并且當(dāng)我們滑動(dòng)的是 webview 以外的區(qū)域時(shí),其他組件依舊能夠正常滾動(dòng)。
總結(jié)
以上就是我目前遇到的在 Compose 中的手勢(shì)沖突的情況,以及我的解決方案。
完整的代碼在這里: GithubAppByCompose
可以看到其實(shí)核心思路也是和使用 view 時(shí)一樣,根據(jù)我們自己實(shí)際業(yè)務(wù)需求,重新分配不同的觸摸事件給不同的 UI 。
不過(guò)我的處理方式實(shí)在無(wú)法稱(chēng)作優(yōu)雅,所以各位大佬如果有更優(yōu)雅的處理方式,希望能不吝賜教。
關(guān)注個(gè)人簡(jiǎn)介,技術(shù)不迷路