Compose編程思想 -- 觸摸事件和嵌套滑動(dòng)事件處理

前言

在這篇文章中,我將會(huì)介紹在Compose中如何完成觸摸事件和嵌套滑動(dòng)的處理。

1 Compose中的觸摸事件

在原生的View體系中,常見的觸摸事件有:ACTION_DOWN、ACTION_MOVE、ACTION_UP,當(dāng)手指按下時(shí),會(huì)遍歷View樹型結(jié)構(gòu)拿到mFirstTouchTarget,以此將后續(xù)的MOVE事件和UP事件都交給這個(gè)組件消費(fèi),在View中消費(fèi)事件是通過onTouchEvent方法處理的。

如果我們想要對事件進(jìn)行攔截,通常會(huì)重寫onInterceptTouchEvent,根據(jù)具體的業(yè)務(wù)場景來判斷是否攔截事件,以及在嵌套的滑動(dòng)組件中,對于事件沖突的處理尤為重要,所以本節(jié)我將會(huì)介紹在Compose中如何完成觸摸事件的處理。

1.1 Compose中的點(diǎn)擊事件

在Compose當(dāng)中的Modifier提供了clickable函數(shù)用于處理點(diǎn)擊事件;

Text(text = "點(diǎn)擊我", Modifier.clickable {
    Log.d(TAG, "TestTouchEvent: 單擊事件")
})

而對于雙擊,長按,則是另一個(gè)函數(shù)combinedClickable來完成。

fun Modifier.combinedClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onLongClickLabel: String? = null,
    onLongClick: (() -> Unit)? = null,
    onDoubleClick: (() -> Unit)? = null,
    onClick: () -> Unit
){
    // ......
}

其實(shí)從源碼中可以看到(這里我不帶大家看了,可以自行查看,很簡單的源碼),像點(diǎn)擊手勢的處理,是通過Modifier.pointInput來處理的。

Text(text = "點(diǎn)擊我", Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val event = awaitPointerEvent()
        when(event.type){
            PointerEventType.Press ->{
                Log.d(TAG, "TestTouchEvent: Press")
            }
            PointerEventType.Move->{
                Log.d(TAG, "TestTouchEvent: Move")
            }
            PointerEventType.Exit->{
                Log.d(TAG, "TestTouchEvent: Exit")
            }
            PointerEventType.Scroll->{
                Log.d(TAG, "TestTouchEvent: Scroll")
            }
        }
    }
})

Modifier.pointInput算是Compose對于觸摸反饋?zhàn)畹讓拥奶幚砹?,通過awaitPointerEvent可以獲取用戶輸入的事件,根據(jù)類型判斷是Press(點(diǎn)擊)、Move(移動(dòng))、Scroll(滑動(dòng))等事件類型。

Text(text = "點(diǎn)擊我", Modifier.pointerInput(Unit) {
    detectTapGestures {
        Log.d(TAG, "TestTouchEvent: 點(diǎn)擊了")
    }
})

或者直接在pointInputScope中通過detectTapGestures來監(jiān)測點(diǎn)擊事件。

這是我之前在介紹Compose時(shí),已經(jīng)使用過點(diǎn)擊事件,這里是簡單的對點(diǎn)擊事件的底層實(shí)現(xiàn)做了介紹,接下來我要介紹一下滑動(dòng)事件。

1.2 Compose中的滑動(dòng)事件 - draggable

在Compose中,提供了draggablescrollable函數(shù),用于處理滑動(dòng)事件,先看下draggable函數(shù)。

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函數(shù)中,有兩個(gè)必填的值,stateorientation,在滑動(dòng)組件中,例如LazyColumn、Pager等,必須要有一個(gè)state對象。

1.2.1 LazyColumn中的state對象

LazyColumn中,state默認(rèn)是執(zhí)行了rememberLazyListState函數(shù),利用其返回值,也就是得到了LazyListState對象。

@Composable
fun TestScrollableState() {
    val list = remember {
        mutableStateListOf("A", "B", "C", "D", "E", "F")
    }
    val scope = rememberCoroutineScope()
    val state = rememberLazyListState()
    LazyColumn(state = state) {
        items(list) {
            Text(
                text = "當(dāng)前字母:$it",
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
    Button(onClick = {
        scope.launch {
            state.scrollToItem(list.size - 1)
        }
    }) {
        Text(text = "定位")
    }
}

那么這個(gè)state是干什么用的呢?其實(shí)就是為了用來處理列表的滑動(dòng),或者監(jiān)聽列表滑動(dòng)。 我們常見的一個(gè)需求就是,當(dāng)進(jìn)到某個(gè)頁面時(shí),需要定位到列表中的某個(gè)元素,那么如果使用RecyclerView,那么可以通過scrollToPosition(index)來完成。

但是Compose是聲明式的UI,無法拿到組件的實(shí)例對象,因此就是通過state來完成滑動(dòng)的控制,例如點(diǎn)擊按鈕滑動(dòng)到列表最后一位,那么就調(diào)用state的scrollToItem函數(shù)來完成。

1.2.2 draggable函數(shù)分析

再回到draggable函數(shù),除了state之外,還需要設(shè)置orientation,就是滑動(dòng)的方向。因?yàn)?code>draggable是監(jiān)聽一維方向的滑動(dòng), 因此只能拿到x軸或者y軸方向上滑動(dòng)偏移量。

@Composable
fun TestDraggable() {

    Text(
        text = "懸浮窗",
        Modifier
            .size(200.dp)
            .background(Color.Blue)
            .draggable(rememberDraggableState {
                Log.d(TAG, "TestDraggable: $it")
            }, Orientation.Horizontal)
    )

}

因?yàn)?code>draggable也需要一個(gè)state,一般情況下都是會(huì)使用rememberDraggableState來生成一個(gè)DraggableState,我們看其回調(diào)值其實(shí)就是一個(gè)float類型的參數(shù),意味著draggable就是用來檢測一維方向上的偏移量。

@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {
    val onDeltaState = rememberUpdatedState(onDelta)
    return remember { DraggableState { onDeltaState.value.invoke(it) } }
}

還有一個(gè)參數(shù),不是必填項(xiàng),就是interactionSource,能夠反映此時(shí)的用戶與界面的交互狀態(tài),假設(shè)有個(gè)需求,當(dāng)組件拖拽的時(shí)候,需要顯示某個(gè)文案;停止拖拽之后顯示另一個(gè)文案。

@Composable
fun TestDraggable() {

    val interaction = remember {
        MutableInteractionSource()
    }
    Column {

        Text(
            text = "懸浮窗",
            Modifier
                .size(200.dp)
                .background(Color.Blue)
                .draggable(rememberDraggableState {
                    Log.d(TAG, "TestDraggable: $it")
                }, Orientation.Horizontal, interactionSource = interaction)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "靜止?fàn)顟B(tài)中")
        }
    }

}

可以將MutableInteractionSource轉(zhuǎn)換為可監(jiān)聽的State,當(dāng)拖動(dòng)狀態(tài)發(fā)生變化時(shí),可以監(jiān)聽到。

1.2.3 通過draggable實(shí)現(xiàn)拖拽效果

在前面我提到,所有的滑動(dòng)組件都會(huì)使用到state,像draggable中使用到的rememberDraggableState可以拿到一維方向的偏移量,那么肯定能夠在偏移量上做文章,實(shí)現(xiàn)拖拽效果。

@Composable
fun TestDraggable() {

    val interaction = remember {
        MutableInteractionSource()
    }
    // x軸的滑動(dòng)距離
    var scrollX = remember {
        mutableStateOf(0)
    }
    Column {

        Text(
            text = "懸浮窗",
            Modifier
                .draggable(rememberDraggableState {
                    // 發(fā)起重組
                    scrollX.value += it.toInt()
                }, Orientation.Horizontal, interactionSource = interaction)
                .layout { measurable, constraints ->
                    val placeable = measurable.measure(constraints = constraints)
                    layout(placeable.width, placeable.height) {
                        //擺放位置
                        placeable.placeRelative(IntOffset(scrollX.value, 0))
                    }
                }
                .size(200.dp)
                .background(Color.Blue)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "靜止?fàn)顟B(tài)中")
        }
    }

}

例如記錄一個(gè)scrollX,用于記錄水平方向的偏移量,這個(gè)值是累加的,每次拖拽都會(huì)觸發(fā)重組重新測量布局,在Modifier.layout函數(shù)中進(jìn)行布局的重新擺放邏輯。

1.3 Compose中的滑動(dòng)事件 - scrollable

這一節(jié)將會(huì)介紹scrollable的使用,其實(shí)如果看過scrollable的源碼,會(huì)發(fā)現(xiàn)它在底層還是通過draggable實(shí)現(xiàn)的。

@ExperimentalFoundationApi
fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    overscrollEffect: OverscrollEffect?,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
): Modifier {
       // ......
}

那么為什么不統(tǒng)一用draggable,而是要單獨(dú)加了一個(gè)scrollable?原因就是通過scrollable要做一些精細(xì)化的效果處理,例如:慣性滑動(dòng)、嵌套滑動(dòng)nestscroll、邊界回彈等。

其實(shí)很好理解,draggable從字面意思上看就是拖拽,雖然滑動(dòng)也是拖拽的一種,但是并不意味著所有的拖拽場景都需要所謂的慣性滑動(dòng)、嵌套滑動(dòng),例如設(shè)置中的進(jìn)度條。

來看下使用,我下面是用scrollable實(shí)現(xiàn)了組件的橫向移動(dòng)能力,

@Composable
fun TestScrollable() {

    val currentX = remember {
        mutableStateOf(0)
    }

    Text(
        text = "懸浮窗",
        Modifier
            .offset {
                IntOffset(currentX.value, 0)
            }
            .scrollable(rememberScrollableState {
                currentX.value += it.toInt()
                it
            }, Orientation.Horizontal)
            .size(200.dp)
            .background(Color.Blue)
    )
}

draggable一樣,scrollable也需要一個(gè)state,Compose給提供好了就是rememberScrollableState函數(shù)。

@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
    val lambdaState = rememberUpdatedState(consumeScrollDelta)
    return remember { ScrollableState { lambdaState.value.invoke(it) } }
}

但是需要注意的是,和rememberDraggableState不同的是,它需要一個(gè)返回值,這個(gè)返回值代表當(dāng)前橫滑的距離被某個(gè)組件消費(fèi)了多少。 以便將剩余的滑動(dòng)距離交給父容器或者子組件消費(fèi),這就是嵌套滑動(dòng)的原理所在。

除此之外,scrollable中還提供了overscrollEffect參數(shù),用于處理觸邊后的回彈效果,Compose中提供了默認(rèn)的實(shí)現(xiàn)ScrollableDefaults.overscrollEffect()。

flingBehavior則是用于處理慣性滑動(dòng),默認(rèn)可以不傳值,在底層使用默認(rèn)值。

// 如果沒有特殊的慣性滑動(dòng)需求,底層使用默認(rèn)值。
val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()

所以scrollabledraggable的基礎(chǔ)之上,增加了幾種滑動(dòng)效果的邏輯處理。

1.4 Compose的二維滑動(dòng)

前面我在介紹draggablescrollable的時(shí)候說過,他們只支持一維方向的滑動(dòng),所以需要設(shè)置orientation屬性,如果想要監(jiān)聽二維的滑動(dòng),Compose沒有提供直接使用的API,需要Modifier.pointerInput來配合完成。

在1.1 小節(jié)中,我介紹點(diǎn)擊事件的時(shí)候,提到過detectTapGestures可以從底層監(jiān)聽點(diǎn)擊事件,那么如果想要監(jiān)聽二維滑動(dòng),那么可以通過detectDragGestures來完成。

Text(text = "二維滑動(dòng)",
    Modifier
        .size(200.dp)
        .background(Color.Blue)
        .pointerInput(Unit){
            detectDragGestures { change, dragAmount -> 
                
            }
        })

detectDragGestures中,有兩個(gè)參數(shù),第一個(gè)參數(shù):PointerInputChange,代表手指點(diǎn)按的信息,每一個(gè)手指按下都有對應(yīng)的id和位置信息等,以此來處理手勢的抬起和按下;第二個(gè)參數(shù):代表的是手指滑動(dòng)的位置信息,是一個(gè)Offset類型的數(shù)據(jù),記錄x和y軸的偏移量。

@Composable
fun TestMultiScroll() {

    val currentX = remember {
        mutableStateOf(0)
    }
    val currentY = remember {
        mutableStateOf(0)
    }

    Text(text = "二維滑動(dòng)",
        Modifier
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                layout(placeable.width, placeable.height) {
                    placeable.placeRelative(currentX.value, currentY.value)
                }
            }
            .size(200.dp)
            .background(Color.Blue)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    currentX.value += dragAmount.x.roundToInt()
                    currentY.value += dragAmount.y.roundToInt()
                }
            })

}

所以要實(shí)現(xiàn)懸浮窗的拖動(dòng),就可以使用detectDragGestures來實(shí)現(xiàn)。

2 Compose中的嵌套滑動(dòng)

在傳統(tǒng)的View體系中,如何通過嵌套滑動(dòng)機(jī)制完成一些需求,它的實(shí)現(xiàn)還是比較復(fù)雜的,需要實(shí)現(xiàn)NestScrollingParentNestScrollingChild接口。

而在Compose中實(shí)現(xiàn)嵌套滑動(dòng),在1.3小節(jié)中,我介紹過了Modifier.scrollable,其實(shí)就是在其基礎(chǔ)之上實(shí)現(xiàn)。

2.1 Compose自有的嵌套滑動(dòng)組件

在傳統(tǒng)的View體系中,像RecyclerView,NestScrollView等,都具備嵌套滑動(dòng)的能力,例如RecyclerView:

public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 { // ......}

它實(shí)現(xiàn)了NestedScrollingChild2接口,所以它具備嵌套滑動(dòng)的能力。所以在Compose當(dāng)中通過Modifier.scrollable實(shí)現(xiàn)的滑動(dòng)組件,大概率都具備嵌套滑動(dòng)的能力,比如LazyColumn。

兩個(gè)LazyColumn嵌套在一起,當(dāng)滑動(dòng)內(nèi)部的LazyColumn的時(shí)候,外部的LazyColumn不會(huì)滑動(dòng),只有當(dāng)內(nèi)部的LazyColumn到底之后,外部的LazyColumn才可以繼續(xù)滑動(dòng)。

@Composable
fun TestNestScroll() {
    LazyColumn {
        item {
            LazyColumn(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            ) {
                items(10) {
                    Text(
                        text = "child $it",
                        Modifier
                            .fillMaxWidth()
                            .height(30.dp)
                            .background(Color.Red)
                    )

                }
            }
        }
        items(20) {
            Text(
                text = "parent $it",
                Modifier
                    .fillMaxWidth()
                    .height(30.dp)
                    .background(Color.Blue)
            )

        }

    }
}

當(dāng)然,作為程序員,面對一些定制化的需求,還是需要自己實(shí)現(xiàn)的。

2.2 自定義實(shí)現(xiàn)嵌套滑動(dòng)

在Compose當(dāng)中,提供了Modifier.nestedScroll來實(shí)現(xiàn)嵌套滑動(dòng),既然我要講嵌套滑動(dòng),首先需要明確一下,嵌套滑動(dòng)的原理:

其實(shí)嵌套滑動(dòng)很簡單,在Compose當(dāng)中對于父容器是不會(huì)主動(dòng)處理滑動(dòng)事件,是子組件通過回調(diào)通知父容器是否需要滑動(dòng),通常是在子組件滑動(dòng)之前「詢問」父容器是否要消費(fèi)滑動(dòng)距離,以及在子組件滑動(dòng)完成之后,也要詢問父容器是否需要消費(fèi)剩余的滑動(dòng)距離。

ok,知道原理之后,就知道該做哪些事了!

  • 通知父容器是否消費(fèi)事件,分兩次進(jìn)行;
  • 父容器接收到回調(diào)之后,選擇是否處理事件消費(fèi)

那么如何通知父容器是否消費(fèi)事件,就是采用NestedScrollDispatcher來進(jìn)行嵌套滑動(dòng)的事件分發(fā),也就是nestedScroll函數(shù)的第二個(gè)參數(shù)。

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "nestedScroll"
        properties["connection"] = connection
        properties["dispatcher"] = dispatcher
    }
) {
    val scope = rememberCoroutineScope()
    // provide noop dispatcher if needed
    val resolvedDispatcher = dispatcher ?: remember { NestedScrollDispatcher() }
    remember(connection, resolvedDispatcher, scope) {
        resolvedDispatcher.originNestedScrollScope = scope
        NestedScrollModifierLocal(resolvedDispatcher, connection)
    }
}

接下來帶大家實(shí)現(xiàn)一個(gè)嵌套滑動(dòng)組件。

@Composable
fun TestNestScroll2() {

    var currentY = remember {
        mutableStateOf(0)
    }
    

    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .draggable(rememberDraggableState {
                
                currentY.value += it.roundToInt()
            }, Orientation.Vertical)
    ) {

        for (index in 1..20) {
            Text(
                text = "第 $index 個(gè)組件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
    }
}

這個(gè)組件具備了上下滑動(dòng)的能力,接下來會(huì)處理嵌套滑動(dòng)的邏輯。

2.2.1 NestedScrollDispatcher

伙伴們重點(diǎn)看下NestedScrollDispatcher關(guān)于滑動(dòng)事件分發(fā)函數(shù)的注釋:

class NestedScrollDispatcher {

   
    // ......
    
    /**
     * 用于子組件處理滑動(dòng)之前,回調(diào)通知父容器是否需要消費(fèi)事件
     *
     * @param available 一次滑動(dòng)事件的距離
     * @param source 滑動(dòng)事件的來源
     *
     * @return 祖先節(jié)點(diǎn),或者說父容器消費(fèi)的滑動(dòng)距離
     */
    fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return parent?.onPreScroll(available, source) ?: Offset.Zero
    }

    /**
     * 子組件滑動(dòng)完成之后,再次通知父容器是否需要消費(fèi)事件
     *
     * @param consumed 當(dāng)前子組件消費(fèi)的距離
     * @param available 當(dāng)前父容器可以再次消費(fèi)的剩余距離
     * @param source 滑動(dòng)事件的來源
     *
     * @return the amount of scroll that was consumed by all ancestors
     */
    fun dispatchPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
    }

    // 下面兩個(gè)慣性函數(shù)其實(shí)是一樣的。
    
    suspend fun dispatchPreFling(available: Velocity): Velocity {
        return parent?.onPreFling(available) ?: Velocity.Zero
    }

    
    suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
        return parent?.onPostFling(consumed, available) ?: Velocity.Zero
    }
}

NestedScrollDispatcher就是用來通知父容器是否需要消費(fèi)事件的工具,所以我對draggable的內(nèi)部邏輯進(jìn)行了修改。

Modifier.draggable(rememberDraggableState { duration ->
    //滑動(dòng)前,通知父容器,有duration長度的滑動(dòng)距離,要不要消費(fèi)?
    val parentConsumed =
        dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
    //那么子組件能夠消費(fèi)的距離,需要減去父容器消費(fèi)的距離,具體父容器消費(fèi)多少,不需要關(guān)心
    val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
    currentY.value += availableDuration
    // 滑動(dòng)結(jié)束之后,再次通知父容器,要不要消費(fèi)?
    dispatch.dispatchPostScroll(
        Offset(0f, availableDuration.toFloat()), // 子組件消費(fèi)了全部的剩余距離
        Offset.Zero, // 父容器可消費(fèi)的滑動(dòng)距離為0
        NestedScrollSource.Drag
    )
}, Orientation.Vertical)

2.2.2 NestedScrollConnection

那么子組件通過NestScrollDispatcher發(fā)起的回調(diào),父容器在哪接收到呢?就是通過NestedScrollConnection,它是一個(gè)接口,所以在用的時(shí)候需要自己實(shí)現(xiàn)一個(gè)實(shí)例,或者創(chuàng)建一個(gè)匿名內(nèi)部類都可以。

接口函數(shù)的注釋可以閱讀一下:

@JvmDefaultWithCompatibility
interface NestedScrollConnection {

    /**
     * Pre scroll event chain. 它會(huì)在子組件允許父容器消費(fèi)滑動(dòng)事件的時(shí)候回調(diào),是在子組件滑動(dòng)之前接收到的回調(diào)。
     *
     * @param available 父容器可以消費(fèi)的滑動(dòng)距離,即dispatch.dispatchPreScroll傳入的第一個(gè)參數(shù)
     * @param source 滑動(dòng)事件來源
     *
     * @return 當(dāng)前組件消費(fèi)多少的滑動(dòng)事件
     */
    fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

    /**
     * Post scroll event pass. 子組件完成滑動(dòng)之后會(huì)回調(diào)
     * @param consumed 子組件消費(fèi)的滑動(dòng)距離,即dispatch.dispatchPostScroll傳入的第一個(gè)參數(shù)
     * @param available 父容器可以消費(fèi)的滑動(dòng)距離,即dispatch.dispatchPostScroll傳入的第二個(gè)參數(shù)
     * @param source 滑動(dòng)事件來源
     *
     * @return the amount that was consumed by this connection
     */
    fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = Offset.Zero

    // 慣性的我先不管了
    suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

    suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity.Zero
    }
}

所以總體的嵌套滑動(dòng)處理,在理解了其中的原理之后,其實(shí)就大概能寫出來其中的核心邏輯了,當(dāng)然這個(gè)demo不存在嵌套滑動(dòng)的邏輯,我在內(nèi)部再加一個(gè)LazyColumn。

@Composable
fun TestNestScroll2() {

    var currentY = remember {
        mutableStateOf(0)
    }

    //分發(fā)給父容器
    val dispatch = remember {
        NestedScrollDispatcher()
    }
    val connection = remember {
        object : NestedScrollConnection{
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // 假設(shè)全消費(fèi)了
                Log.d(TAG, "onPreScroll: $available ")
                return available
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                Log.d(TAG, "onPostScroll: consumed $consumed available $available")
                //子組件滑動(dòng)完成之后, 如果還有可用的距離,那么父組件就消費(fèi)
                return super.onPostScroll(consumed, available, source)
            }
        }
    }


    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .nestedScroll(connection, dispatch)
            .draggable(rememberDraggableState { duration ->
                //滑動(dòng)前,通知父容器,有duration長度的滑動(dòng)距離,要不要消費(fèi)?
                val parentConsumed =
                    dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
                //那么子組件能夠消費(fèi)的距離,需要減去父容器消費(fèi)的距離,具體父容器消費(fèi)多少,不需要關(guān)心
                val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
                Log.d(TAG, "TestNestScroll2: availableDuration $availableDuration")
                currentY.value += availableDuration
                // 滑動(dòng)結(jié)束之后,再次通知父容器,要不要消費(fèi)?
                dispatch.dispatchPostScroll(
                    Offset(0f, availableDuration.toFloat()), // 子組件消費(fèi)了全部的剩余距離
                    Offset.Zero, // 父容器可消費(fèi)的滑動(dòng)距離為0
                    NestedScrollSource.Drag
                )
            }, Orientation.Vertical)
    ) {

        for (index in 1..20) {
            Text(
                text = "第 $index 個(gè)組件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
        //內(nèi)部嵌套一個(gè)LazyColumn。
        LazyColumn(Modifier.height(80.dp)){
            items(10){
                Text(
                    text = "第 $it 個(gè)內(nèi)部組件",
                    Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                )
            }
        }
    }
}

先看下這個(gè)布局結(jié)構(gòu)

image.png

當(dāng)子組件滑動(dòng)的時(shí)候,自定義的ScrollView(父容器)會(huì)首先收到子組件的回調(diào),在onPreScroll中處理決定是否要消費(fèi)事件:

  • 假設(shè)在onPreScroll中,父容器消費(fèi)了全部的滑動(dòng)距離,那么內(nèi)部的LazyColumn就不能滑動(dòng)了;
  • 默認(rèn)不處理onPreScroll,在子組件不能再滑動(dòng)的時(shí)候,就繼續(xù)滑動(dòng)父容器,需要做下面的邏輯。
val connection = remember {
    object : NestedScrollConnection{

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            Log.d(TAG, "onPostScroll: consumed $consumed available $available")
            //子組件滑動(dòng)完成之后, 如果還有可用的距離,那么父組件就消費(fèi)
            currentY.value += available.y.toInt()
            return available
        }
    }
}

因?yàn)樽咏M件已經(jīng)無法滑動(dòng)了,因此所有的事件子組件不再消費(fèi),從而在onPostScroll中會(huì)將事件原封不動(dòng)的回調(diào)給父容器,父容器從而消費(fèi)事件繼續(xù)滑動(dòng)。

如果把定義的ScrollView放在其他的容器中,那么其自身就會(huì)成為子組件,會(huì)執(zhí)行draggable中的嵌套滑動(dòng)邏輯。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容