Jetpack Compose(8)——巢狀滾動

SharpCJ發表於2024-06-27

目錄
  • 前言
  • 一、Jetpack Compose 中處理巢狀滾動的思想
  • 二、Modifier.nestedScroll
    • 2.1 NestedScrollConnection
    • 2.2 NestedScrollDispatcher
  • 三、實操講解
    • 3.1 父元件消費子元件給過來的事件——NestedScrollConnection
    • 3.2 子元件對事件進行分發——NestedScrollDispatcher
    • 3.2 按照分發順序依次消費
  • 小結

前言

所謂巢狀滾動,就是兩個元件之間出現滾動事件衝突了,要給與特定的處理邏輯。在傳統 View 系統中稱之為滑動衝突,一般有兩種解決方案,外部攔截法和內部攔截法。在 Jetpack Compose 中,提供了 Modifier.nestedScroll 修飾符用來處理巢狀滾動的場景。

一、Jetpack Compose 中處理巢狀滾動的思想

在介紹 Modifier.nestedScroll 之前,需要先了解 Compose 中巢狀滾動的處理思想。當元件獲得滾動事件後,先交給它的父元件消費,父元件消費之後,將剩餘可用的滾動事件在給到子元件,子元件再消費,子元件消費之後,再將剩餘的滾動事件再給到父元件。

第一趟 ... -> 孫 ——> 子 ——> 父 -> ...
第二趟 ... <- 孫 <—— 子 <—— 父 <- ...
第三趟 ... -> 孫 ——> 子 ——> 父 -> ...

二、Modifier.nestedScroll

有了整體思路之後,再來看 Modifier.nestedScroll 這個修飾符。

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier

使用 nestedScroll 引數列表中有一個必選引數 connection 和一個可選引數 dispatcher

  • connection: 巢狀滑動手勢處理的核心邏輯,內部回撥可以在子佈局獲得滑動事件前預先消費掉部分或全部手勢偏移量,也可以獲取子佈局消費後剩下的手勢偏移量。

  • dispatcher:排程器,內部包含用於父佈局的 NestedScrollConnection , 可以呼叫 dispatch* 方法來通知父佈局發生滑動

2.1 NestedScrollConnection

NestedScrollConnection 提供了四個回撥方法。

interface NestedScrollConnection {
    /**
    * 預先劫持滑動事件,消費後再交由子佈局。
    * available:當前可用的滑動事件偏移量
    * source:滑動事件的型別
    * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回Offset.Zero
    */
    fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

    /**
    * 獲取子佈局處理後的滑動事件
    * consumed:之前消費的所有滑動事件偏移量
    * available:當前剩下還可用的滑動事件偏移量
    * source:滑動事件的型別
    * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回 Offset.Zero ,則剩下偏移量會繼續交由當前佈局的父佈局進行處理
    */
    fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = Offset.Zero

    /**
     * 獲取 Fling 開始時的速度
     * available:Fling 開始時的速度
     * 返回值:當前元件消費的速度,如果不想消費可返回 Velocity.Zero
     */
    suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

    /**
     * 獲取 Fling 結束時的速度資訊
     * consumed:之前消費的所有速度
     * available:當前剩下還可用的速度
     * 返回值:當前元件消費的速度,如果不想消費可返回Velocity.Zero,剩下速度會繼續交由當前佈局的父佈局進行處理
     */
    suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity.Zero
    }
}

關於各個方法的含義已經在方法註釋中標註了。

注意 Fling 的含義: 當我們手指在滑動列表時,如果是快速滑動並抬起,則列表會根據慣性繼續飄一段距離後停下,這個行為就是 Fling ,onPreFling 在你手指剛抬起時便會回撥,而 onPostFling 會在飄一段距離停下後回撥。

2.2 NestedScrollDispatcher

NestedScrollDispatcher 的主要方法:

fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
    return parent?.onPreScroll(available, source) ?: Offset.Zero
}

fun dispatchPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
    }

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
}

其實方法實現就能清楚,實際上讓其父元件呼叫 NestedScrollConnection 中的預消費與後消費方法。

三、實操講解

3.1 父元件消費子元件給過來的事件——NestedScrollConnection

先上效果圖:

簡單分析一下效果:

  1. 佈局分為兩部分,上面是一張圖片,下面是一個滑動列表
  2. 滑動過程中,上滑時,首先頭部響應滑動,收縮到最小高度之後,列表再開始向上滑動。下滑時,也是頭部先影響滑動,頭部圖片展開到最大高度之後,列表再開始向下滑動。即:不論上上滑還是下滑,都是頭部圖片先響應。
  3. 我們希望是按住列表能滑動,按住頭部圖片是不能滑動的,也就是說頭部圖片不會檢測滑動事件,只有下面列表會檢測滑動事件。

下面開始編碼:

  1. 手寫整體佈局應該是 Column 實現,頭部使用一個 Image ,下面使用 LazyColumn
@Composable
fun NestedScrollDemo() {
    Column(
        modifier = Modifier.fillMaxSize()) {
            Image(
                painter = painterResource(id = R.mipmap.rc_1),
                contentDescription = null,
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )

            LazyColumn {
                repeat(50) {
                    item {
                        Text(text = "item --> $it", modifier = Modifier.fillMaxWidth())
                    }
                }
            }
    }
}
  1. 給 Column 元件使用 Modifier.nestedScroll。
    這裡簡單做一些定義:頭部圖片最小高度為 80.dp, 最大高度為 200.dp。注意 dp 和 px 之間的轉換。
@Composable
fun NestedScrollDemo() {
    val minHeight = 80.dp
    val maxHeight = 200.dp
    val density = LocalDensity.current

    val minHeightPx = with(density) {
        minHeight.toPx()
    }

    val maxHeightPx = with(density) {
        maxHeight.toPx()
    }

    var topHeightPx by remember {
        mutableStateOf(maxHeightPx)
    }

    val connection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                return super.onPreScroll(available, source)
            }

            override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
                return super.onPostScroll(consumed, available, source)
            }

            override suspend fun onPreFling(available: Velocity): Velocity {
                return super.onPreFling(available)
            }

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

    Column(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(connection = connection)
    ) {
        Image(
            painter = painterResource(id = R.mipmap.rc_1),
            contentDescription = null,
            contentScale = ContentScale.FillBounds,
            modifier = Modifier
                .fillMaxWidth()
                .height(with(density) {
                    topHeightPx.toDp()
                })
        )

        LazyColumn {
            repeat(50) {
                item {
                    Text(text = "item --> $it", modifier = Modifier.fillMaxWidth())
                }
            }
        }
    }
}
  1. 最後就是編寫滑動處理邏輯了。LazyColumn 列表檢測到滑動事件,把這個滑動距離先給到父元件Column 消費,Column 消費之後,把剩餘的再給到 LazyColumn 消費,LazyColumn 消費之後,還有剩餘,再給回 Column 消費。其中 LazyColumn 消費事件,不用我們處理,我們的 Modifier.nestedScroll 作用在 Column 上,我們需要預先消費 LazyColumn 給過來的滑動距離——在 onPreScroll 中實現,然後把剩餘的給到 LazyColumn,最後 LazyColumn 消費後還有剩餘的滑動距離,Column 處理 —— 在 onPostScroll 中處理。
val connection = remember {
    object : NestedScrollConnection {
        /**
         * 預先劫持滑動事件,消費後再交由子佈局。
         * available:當前可用的滑動事件偏移量
         * source:滑動事件的型別
         * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回Offset.Zero
         */
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if (source == NestedScrollSource.Drag) {  // 判斷是滑動事件
                if (available.y < 0) { // 向上滑動
                    val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                    if (available.y > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                        topHeightPx += available.y
                        return Offset(x = 0f, y = available.y)
                    } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離。剩餘的給到子元件。
                        topHeightPx += dH
                        return Offset(x = 0f, y = dH)
                    }
                } else { // 下滑
                    val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                    if (available.y < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                        topHeightPx += available.y
                        return Offset(x = 0f, y = available.y)
                    } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離。剩餘的給到子元件。
                        topHeightPx += dH
                        return Offset(x = 0f, y = dH)
                    }
                }
            } else {  // 如果不是滑動事件,就不消費。
                return Offset.Zero
            }
        }

        /**
         * 獲取子佈局處理後的滑動事件
         * consumed:之前消費的所有滑動事件偏移量
         * available:當前剩下還可用的滑動事件偏移量
         * source:滑動事件的型別
         * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回 Offset.Zero ,則剩下偏移量會繼續交由當前佈局的父佈局進行處理
         */
        override fun onPostScroll(
            consumed: Offset, available: Offset, source: NestedScrollSource
        ): Offset {
            // 子元件處理後的剩餘的滑動距離,此處不需要消費了,直接不消費。
            return Offset.Zero
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            return super.onPreFling(available)
        }

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

所有的程式碼透過註釋已經寫的很詳細了。這樣就實現了上圖的效果。

3.2 子元件對事件進行分發——NestedScrollDispatcher

滑動距離消費不一定要體現在位置、大小之類的變化上。當使用 Modifier.nestedScroll 修飾符處理巢狀滾動時,絕大多數場景使用外部攔截法就能輕鬆實現,給父容器修飾,實現 NestedScollConnection 方法。

使用內部攔截法,一般用於父元件也可以消費事件,需要子容器使用 Modifier.nestedScroll ,併合理使用 NestedScrollDispatcher 的方法。

看下面這個示例

同樣簡單分析一下效果:

  1. 整個父元件是一個 LazyColumn, 自身可以滾動
  2. LazyColumn 中的一個元素是一張圖片,使用 Image 元件,當按住圖片滾動時,優先處理圖片的收縮與展開。

實現如下:

@Composable
fun NestedScrollDemo4() {
    val minHeight = 80.dp
    val maxHeight = 200.dp
    val density = LocalDensity.current

    val minHeightPx = with(density) {
        minHeight.toPx()
    }

    val maxHeightPx = with(density) {
        maxHeight.toPx()
    }

    var topHeightPx by remember {
        mutableStateOf(maxHeightPx)
    }

    val connection = remember {
        object : NestedScrollConnection {}
    }

    val dispatcher = NestedScrollDispatcher()

    LazyColumn(
        modifier = Modifier
            .background(Color.LightGray)
            .fillMaxSize()
    ) {
        for (i in 0..10) {
            item {
                Text(text = "item --> $i", modifier = Modifier.fillMaxWidth())
            }
        }
        item {
            Image(
                painter = painterResource(id = R.mipmap.rc_1),
                contentDescription = null,
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(with(density) {
                        topHeightPx.toDp()
                    })
                    .draggable(
                        state = rememberDraggableState { onDelta ->
                            // 1. 滑動距離,給到父元件先消費
                            // 呼叫父元件劫持滑動事件,讓父元件先消費,返回值是父元件消費掉的滑動距離
                            // 這裡並不想讓父元件先消費,就給父元件傳了 Offset.Zero。 返回值也就是 Offset.Zero。
                            val consumed = dispatcher.dispatchPreScroll(
                                available = Offset(x = 0f, y = 0f), source = NestedScrollSource.Drag
                            )

                            // 2. 父元件消費完之後,剩餘的滑動距離,自己按需消費

                            // 計算父元件消費後剩餘的可使用的滑動距離
                            val availableY = onDelta - consumed.y

                            // canConsumeY 是當前需要消費掉的距離
                            val canConsumeY = if (availableY < 0) { // 向上滑動
                                val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                                if (availableY > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                                    availableY
                                } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離
                                    dH
                                }
                            } else { // 下滑
                                val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                                if (availableY < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                                    availableY
                                } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離
                                    dH
                                }
                            }

                            // 把當前消費掉的距離給到圖片高度
                            topHeightPx += canConsumeY

                            // 父元件消費後,以及本次消費後,最後剩餘的滑動距離
                            val remain = onDelta - consumed.y - canConsumeY

                            // 3. 自己消費完之後,還有剩餘的滑動距離,再給到父元件
                            dispatcher.dispatchPostScroll(
                                consumed = Offset(x = 0f, y = consumed.y + canConsumeY), // 這裡是總共消費的滑動距離,包括父元件消費的和本次自己消費的
                                available = Offset(0f, remain),  // 剩餘可用的滑動距離
                                source = NestedScrollSource.Drag
                            )
                        }, orientation = Orientation.Vertical
                    )
                    .nestedScroll(connection, dispatcher)
            )
        }
        for (j in 11..40) {
            item {
                Text(text = "item --> $j", modifier = Modifier.fillMaxWidth())
            }
        }
    }
}

關鍵程式碼都已經加上了註釋。看起來應該是非常清晰的。

這裡,主要是內部使用 dispatcher 進行事件攔截。

3.2 按照分發順序依次消費

在 3.1 的例子中,頭部圖片是不檢測滑動事件的,手指按住圖片滑動是不會響應的,現在需要修改為按住上面圖片也是可以滑動,將頭部收縮和展開。

下面開始改造:

  1. 給 Column 加上 Modifier.draggable 修飾
Column(
        modifier = Modifier
            .fillMaxSize()
            .draggable(
                state = rememberDraggableState { onDelta ->

                },
                orientation = Orientation.Vertical
            )
            .nestedScroll(connection = connection)
) {
    ...
}
  1. 宣告 dispatcher,使用 dispatcher 處理巢狀滑動事件
val dispatcher = remember { NestedScrollDispatcher() }

Column(
    modifier = Modifier
        .fillMaxSize()
        .draggable(
            state = rememberDraggableState { onDelta ->

                // 1. 滑動距離,給到父元件先消費
                // 呼叫父元件劫持滑動事件,讓父元件先消費,返回值是父元件消費掉的滑動距離
                val consumed = dispatcher.dispatchPreScroll(
                    available = Offset(x = 0f, y = onDelta), source = NestedScrollSource.Drag
                )

                // 2. 父元件消費完之後,剩餘的滑動距離,自己按需消費

                // 計算父元件消費後剩餘的可使用的滑動距離
                val availableY = (onDelta - consumed.y)

                // consume 是當前需要消費掉的距離
                val consumeY = if (availableY < 0) { // 向上滑動
                    val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                    if (availableY > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                        availableY
                    } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離
                        dH
                    }
                } else { // 下滑
                    val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                    if (availableY < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                        availableY
                    } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離
                        dH
                    }
                }

                // 把當前消費掉的距離給到圖片高度
                topHeightPx += consumeY

                // 父元件消費後,以及本次消費後,最後剩餘的滑動距離
                val remain = onDelta - consumed.y - consumeY

                // 3. 自己消費完之後,還有剩餘的滑動距離,再給到父元件
                dispatcher.dispatchPostScroll(
                    consumed = Offset(x = 0f, y = consumed.y + consumeY), // 這裡是總共消費的滑動距離,包括父元件消費的和本次自己消費的
                    available = Offset(0f, remain),  // 剩餘可用的滑動距離
                    source = NestedScrollSource.Drag
                )
            },
             orientation = Orientation.Vertical
        )
        .nestedScroll(
            connection = connection, 
            dispatcher = dispatcher
        )
) {
    ...
}

同樣程式碼註釋已經寫得非常清晰了。

完整程式碼如下:

@Composable
fun NestedScrollDemo2() {
    val minHeight = 80.dp
    val maxHeight = 200.dp
    val density = LocalDensity.current

    val minHeightPx = with(density) {
        minHeight.toPx()
    }

    val maxHeightPx = with(density) {
        maxHeight.toPx()
    }

    var topHeightPx by remember {
        mutableStateOf(maxHeightPx)
    }

    val dispatcher = remember { NestedScrollDispatcher() }

    val connection = remember {
        object : NestedScrollConnection {
            /**
             * 預先劫持滑動事件,消費後再交由子佈局。
             * available:當前可用的滑動事件偏移量
             * source:滑動事件的型別
             * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回Offset.Zero
             */
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                if (source == NestedScrollSource.Drag) {  // 判斷是滑動事件
                    if (available.y < 0) { // 向上滑動
                        val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                        if (available.y > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                            topHeightPx += available.y
                            return Offset(x = 0f, y = available.y)
                        } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離。剩餘的給到子元件。
                            topHeightPx += dH
                            return Offset(x = 0f, y = dH)
                        }
                    } else { // 下滑
                        val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                        if (available.y < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                            topHeightPx += available.y
                            return Offset(x = 0f, y = available.y)
                        } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離。剩餘的給到子元件。
                            topHeightPx += dH
                            return Offset(x = 0f, y = dH)
                        }
                    }
                } else {  // 如果不是滑動事件,就不消費。
                    return Offset.Zero
                }
            }

            /**
             * 獲取子佈局處理後的滑動事件
             * consumed:之前消費的所有滑動事件偏移量
             * available:當前剩下還可用的滑動事件偏移量
             * source:滑動事件的型別
             * 返回值:當前元件消費的滑動事件偏移量,如果不想消費可返回 Offset.Zero ,則剩下偏移量會繼續交由當前佈局的父佈局進行處理
             */
            override fun onPostScroll(
                consumed: Offset, available: Offset, source: NestedScrollSource
            ): Offset {
                // 子元件處理後的剩餘的滑動距離,此處不需要消費了,直接不消費。
                return Offset.Zero
            }

            override suspend fun onPreFling(available: Velocity): Velocity {
                return super.onPreFling(available)
            }

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

    Column(
        modifier = Modifier
            .fillMaxSize()
            .draggable(
                state = rememberDraggableState { onDelta ->

                    // 1. 滑動距離,給到父元件先消費
                    // 呼叫父元件劫持滑動事件,讓父元件先消費,返回值是父元件消費掉的滑動距離
                    val consumed = dispatcher.dispatchPreScroll(
                        available = Offset(x = 0f, y = onDelta), source = NestedScrollSource.Drag
                    )

                    // 2. 父元件消費完之後,剩餘的滑動距離,自己按需消費

                    // 計算父元件消費後剩餘的可使用的滑動距離
                    val availableY = (onDelta - consumed.y)

                    // consume 是當前需要消費掉的距離
                    val consumeY = if (availableY < 0) { // 向上滑動
                        val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                        if (availableY > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                            availableY
                        } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離
                            dH
                        }
                    } else { // 下滑
                        val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                        if (availableY < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                            availableY
                        } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離
                            dH
                        }
                    }

                    // 把當前消費掉的距離給到圖片高度
                    topHeightPx += consumeY

                    // 父元件消費後,以及本次消費後,最後剩餘的滑動距離
                    val remain = onDelta - consumed.y - consumeY

                    // 3. 自己消費完之後,還有剩餘的滑動距離,再給到父元件
                    dispatcher.dispatchPostScroll(
                        consumed = Offset(x = 0f, y = consumed.y + consumeY), // 這裡是總共消費的滑動距離,包括父元件消費的和本次自己消費的
                        available = Offset(0f, remain),  // 剩餘可用的滑動距離
                        source = NestedScrollSource.Drag
                    )
                }, orientation = Orientation.Vertical
            )
            .nestedScroll(
                connection = connection, dispatcher = dispatcher
            )
    ) {
        Image(
            painter = painterResource(id = R.mipmap.rc_1),
            contentDescription = null,
            contentScale = ContentScale.FillBounds,
            modifier = Modifier
                .fillMaxWidth()
                .height(with(density) {
                    topHeightPx.toDp()
                })
        )

        LazyColumn {
            repeat(50) {
                item {
                    Text(text = "item --> $it", modifier = Modifier.fillMaxWidth())
                }
            }
        }
    }
}

執行效果:

看效果圖不明顯,實際上就是按住圖片位置拖動是可以收縮和展開頂部圖片的。
當然,其實要實現這個效果,也不用整這麼複雜,完全可以給 Image 設定 draggable 修飾符來實現:

Image(
    painter = painterResource(id = R.mipmap.rc_1),
    contentDescription = null,
    contentScale = ContentScale.FillBounds,
    modifier = Modifier
        .fillMaxWidth()
        .height(with(density) {
            topHeightPx.toDp()
        })
        .draggable(
            state = rememberDraggableState { onDelta ->
                val consumeY = if (onDelta < 0) { // 向上滑動
                    val dH = minHeightPx - topHeightPx  // 向上滑動過程中,還差多少達到最小高度
                    if (onDelta > dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最小高度,就將當前可用距離全部消費掉
                        onDelta
                    } else {  // 如果當前可用的滑動距離足夠達到最小高度,就只消費掉需要的距離
                        dH
                    }
                } else { // 下滑
                    val dH = maxHeightPx - topHeightPx  // 向下滑動過程中,還差多少達到最大高度
                    if (onDelta < dH) {  // 如果當前可用的滑動距離全部消費都不足以達到最大高度,就將當前可用距離全部消費掉
                        onDelta
                    } else {  // 如果當前可用的滑動距離足夠達到最大高度,就只消費掉需要的距離
                        dH
                    }
                }
                topHeightPx += consumeY
            },
            orientation = Orientation.Vertical
        )
)

這樣就可以了。

小結

  1. 本文介紹了 Jetpack Compose 中巢狀滾動的相關知識。
    Compose 中巢狀滾動事件的分發思想是,滾動事件會預先交給父元件預先處理,父元件處理消費之後,自己處理剩餘滾動距離,自己處理消費完之後,還有剩餘,會再交給父元件處理。
  2. 一般來說,當子元件檢測滾動事件,則需要實現 NestedScrollConnection 中的 onPreScrollonPostScroll 方法。當自己檢測滾動事件,則需要使用 NestedScrollDispatcher 的相關方法對滾動事件進行分發。
  3. 另外還有 Fling 事件,慣性滾動,其分發思想與滾動一致,不同的它的值表示速度。另外慣性滾動過程實現比較複雜,Compose 提供了預設實現,ScrollableDefaults.flingBehavior(),感興趣的朋友可以繼續研究。

相關文章