【Compose】親手封裝一個(gè)簡(jiǎn)單靈活的下拉刷新上拉加載 Compose Layout

關(guān)注個(gè)人簡(jiǎn)介,技術(shù)不迷路

Compose 的下拉刷新有現(xiàn)成的 Material 庫(kù)可以直接使用,非常簡(jiǎn)單方便。

但是上拉加載目前沒(méi)看到有封裝的特別好的庫(kù),Paging 有些場(chǎng)景無(wú)法滿足,而且上拉加載也是個(gè)比較簡(jiǎn)單的功能,沒(méi)必要再去依賴一個(gè)質(zhì)量未知的庫(kù)。我們可以基于目前的 LazyList 簡(jiǎn)單的封裝一個(gè)靈活的組件。

基本原則是仍然基于現(xiàn)有的 PullRefresh 以及 LazyList API 實(shí)現(xiàn),不依賴三方庫(kù),使用簡(jiǎn)單靈活好用。

接口設(shè)計(jì)

首先我們將這個(gè)可以上拉加載下拉刷新的 Compose 函數(shù)命名為 LoadableLazyColumn。

上面說(shuō)到我們需要基于 PullRefresh 以及 LazyList API 實(shí)現(xiàn),這兩個(gè)組件都具備各自的 State。

  • PullRefreshState :下拉刷新的 State
  • LazyListState :LazyColumn 的 State

由于我們需要在此基礎(chǔ)上提供上拉加載的能力,那還需要一個(gè)上拉加載的 State,我們可以將其命名為 LoadMoreState ,目前 LoadMoreState 需要包含兩個(gè)參數(shù):

  1. loadMoreRemainCountThreshold :加載更多的剩余 Item 個(gè)數(shù)閾值,當(dāng)剩余個(gè)數(shù)小于等于這個(gè)閾值時(shí)開始發(fā)起加載更多請(qǐng)求。
  2. onLoadMore :加載更多的事件回調(diào)。

既然提供了 LoadMoreState ,我們還應(yīng)該提供一個(gè)對(duì)應(yīng)的 remember 函數(shù)。

@Composable
fun rememberLoadMoreState(
    loadMoreRemainCountThreshold: Int,
    onLoadMore: () -> Unit,
): LoadMoreState {
    return remember {
        LoadMoreState(loadMoreRemainCountThreshold, onLoadMore)
    }
}

上面我們只是單純的定義了 LoadMoreState,同時(shí)我們也知道了 LoadableLazyColumn 還包含另外兩個(gè) State,總共也就是三個(gè) State。

現(xiàn)在我們需要?jiǎng)?chuàng)建 LoadableLazyColumnState ,它需要包含上面說(shuō)的三個(gè) State。

@OptIn(ExperimentalMaterialApi::class)
data class LoadableLazyColumnState(
    val lazyListState: LazyListState,
    val pullRefreshState: PullRefreshState,
    val loadMoreState: LoadMoreState,
)

以及對(duì)應(yīng)的 remember 方法。

不過(guò)上面說(shuō)的三個(gè) state 只是我們的內(nèi)部實(shí)現(xiàn),這不是調(diào)用者需要考慮的事情,對(duì)于使用者來(lái)說(shuō)這只是一個(gè) state,因此我們的 remember 方法的參數(shù)應(yīng)該是這三個(gè) state 的合集。

@Composable
@ExperimentalMaterialApi
fun rememberLoadableLazyColumnState(
    refreshing: Boolean,
    onRefresh: () -> Unit,
    onLoadMore: () -> Unit,
    refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
    refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
    loadMoreRemainCountThreshold: Int = 5,
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0
): LoadableLazyColumnState {
    val pullRefreshState = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = onRefresh,
        refreshingOffset = refreshingOffset,
        refreshThreshold = refreshThreshold,
    )

    val lazyListState = rememberLazyListState(
        initialFirstVisibleItemScrollOffset = initialFirstVisibleItemScrollOffset,
        initialFirstVisibleItemIndex = initialFirstVisibleItemIndex,
    )

    val loadMoreState = rememberLoadMoreState(loadMoreRemainCountThreshold, onLoadMore)

    return remember {
        LoadableLazyColumnState(
            lazyListState = lazyListState,
            pullRefreshState = pullRefreshState,
            loadMoreState = loadMoreState,
        )
    }
}

這樣我們就創(chuàng)建了 LoadableLazyColumnState。

然后 LoadableLazyColumn 這個(gè)函數(shù)的入?yún)⒕惋@而易見了。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LoadableLazyColumn(
    modifier: Modifier = Modifier,
    state: LoadableLazyColumnState,
    refreshing: Boolean,
    loading: Boolean,
    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,
    loadingContent: (@Composable () -> Unit)? = null,
    content: LazyListScope.() -> Unit,
)

實(shí)現(xiàn)方案

這里會(huì)根據(jù) LazyList 滑動(dòng)事件來(lái)觸發(fā)加載更多事件,當(dāng)滑動(dòng)事件結(jié)束后,判斷用戶是否為向下滑動(dòng),并且剩余元素的個(gè)數(shù)小于等于設(shè)定的閾值。

所幸 lazyListState 提供了這些狀態(tài),我們可以通過(guò)它那計(jì)算出上面的情況。

val lazyListState = state.lazyListState
// 獲取 lazyList 布局信息
val listLayoutInfo by remember { derivedStateOf { lazyListState.layoutInfo } }

可以通過(guò)下面的方法獲取到 LazyList 是否正在滑動(dòng):

// Whether this [ScrollableState] is currently scrolling by gesture, 
// fling or programmatically ornot.
lazyListState.isScrollInProgress

然后通過(guò)下面的兩個(gè)方法獲取到最后一個(gè)可見的 index,以及 item 總數(shù):

listLayoutInfo.visibleItemsInfo.lastOrNull()?.index
listLayoutInfo.totalItemsCount

上面說(shuō)的幾個(gè)方法都是獲取當(dāng)前狀態(tài),但我們的目的是判斷狀態(tài)的變化,主要是下面兩個(gè)事件變化:

  • 滑動(dòng)停止事件
  • 最后一個(gè)可見 index 變化事件

如果我們能在滑動(dòng)事件停止后判斷最后一個(gè)可見 index 與上次滑動(dòng)結(jié)束后的最后一個(gè)可見 index 相比的大小,就知道是向上滑動(dòng)還是向下滑動(dòng)了。再加上最后一個(gè)可見 index 與閾值相比,就可以判斷觸發(fā)加載更多事件了。

這里我們使用 remember 函數(shù)來(lái)實(shí)現(xiàn),即 remember 上次的值,與當(dāng)前值做對(duì)比。

// 上次是否正在滑動(dòng)
var lastTimeIsScrollInProgress by remember {
    mutableStateOf(lazyListState.isScrollInProgress)
}
// 上次滑動(dòng)結(jié)束后最后一個(gè)可見的index
var lastTimeLastVisibleIndex by remember {
    mutableStateOf(listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
}
// 當(dāng)前是否正在滑動(dòng)
val currentIsScrollInProgress = lazyListState.isScrollInProgress
// 當(dāng)前最后一個(gè)可見的 index
val currentLastVisibleIndex = listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0

通過(guò)上面的代碼我們就拿到了所有需要的狀態(tài)了,然后簡(jiǎn)單對(duì)比一下即可。

if (!currentIsScrollInProgress && lastTimeIsScrollInProgress) {
    if (currentLastVisibleIndex != lastTimeLastVisibleIndex) {
        val isScrollDown = currentLastVisibleIndex > lastTimeLastVisibleIndex
        val remainCount = listLayoutInfo.totalItemsCount - currentLastVisibleIndex - 1
        if (isScrollDown && remainCount <= state.loadMoreState.loadMoreRemainCountThreshold) {
            LaunchedEffect(Unit) {
                state.loadMoreState.onLoadMore()
            }
        }
    }
    // 滑動(dòng)結(jié)束后再更新值
    lastTimeLastVisibleIndex = currentLastVisibleIndex
}
lastTimeIsScrollInProgress = currentIsScrollInProgress

這樣就差不多了,看下所有的代碼。

package com.zhangke.framework.loadable.lazycolumn

import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshDefaults
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LoadableLazyColumn(
    modifier: Modifier = Modifier,
    state: LoadableLazyColumnState,
    refreshing: Boolean,
    loading: Boolean,
    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,
    loadingContent: (@Composable () -> Unit)? = null,
    content: LazyListScope.() -> Unit,
) {
    val lazyListState = state.lazyListState
    // 獲取 lazyList 布局信息
    val listLayoutInfo by remember { derivedStateOf { lazyListState.layoutInfo } }
    Box(
        modifier = modifier
            .pullRefresh(state.pullRefreshState)
    ) {
        LazyColumn(
            contentPadding = contentPadding,
            state = state.lazyListState,
            reverseLayout = reverseLayout,
            verticalArrangement = verticalArrangement,
            horizontalAlignment = horizontalAlignment,
            flingBehavior = flingBehavior,
            userScrollEnabled = userScrollEnabled,
            content = {
                content()
                item {
                    if (loadingContent != null) {
                        loadingContent()
                    } else {
                        if (loading) {
                            Box(modifier = Modifier.fillMaxWidth()) {
                                CircularProgressIndicator(
                                    modifier = Modifier
                                        .size(30.dp)
                                        .align(Alignment.Center)
                                )
                            }
                        }
                    }
                }
            },
        )
        PullRefreshIndicator(
            refreshing,
            state.pullRefreshState,
            Modifier.align(Alignment.TopCenter)
        )
    }
    // 上次是否正在滑動(dòng)
    var lastTimeIsScrollInProgress by remember {
        mutableStateOf(lazyListState.isScrollInProgress)
    }
    // 上次滑動(dòng)結(jié)束后最后一個(gè)可見的index
    var lastTimeLastVisibleIndex by remember {
        mutableStateOf(listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
    }
    // 當(dāng)前是否正在滑動(dòng)
    val currentIsScrollInProgress = lazyListState.isScrollInProgress
    // 當(dāng)前最后一個(gè)可見的 index
    val currentLastVisibleIndex = listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
    if (!currentIsScrollInProgress && lastTimeIsScrollInProgress) {
        if (currentLastVisibleIndex != lastTimeLastVisibleIndex) {
            val isScrollDown = currentLastVisibleIndex > lastTimeLastVisibleIndex
            val remainCount = listLayoutInfo.totalItemsCount - currentLastVisibleIndex - 1
            if (isScrollDown && remainCount <= state.loadMoreState.loadMoreRemainCountThreshold) {
                LaunchedEffect(Unit) {
                    state.loadMoreState.onLoadMore()
                }
            }
        }
        // 滑動(dòng)結(jié)束后再更新值
        lastTimeLastVisibleIndex = currentLastVisibleIndex
    }
    lastTimeIsScrollInProgress = currentIsScrollInProgress
}

@Composable
@ExperimentalMaterialApi
fun rememberLoadableLazyColumnState(
    refreshing: Boolean,
    onRefresh: () -> Unit,
    onLoadMore: () -> Unit,
    refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
    refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
    loadMoreRemainCountThreshold: Int = 5,
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0
): LoadableLazyColumnState {
    val pullRefreshState = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = onRefresh,
        refreshingOffset = refreshingOffset,
        refreshThreshold = refreshThreshold,
    )

    val lazyListState = rememberLazyListState(
        initialFirstVisibleItemScrollOffset = initialFirstVisibleItemScrollOffset,
        initialFirstVisibleItemIndex = initialFirstVisibleItemIndex,
    )

    val loadMoreState = rememberLoadMoreState(loadMoreRemainCountThreshold, onLoadMore)

    return remember {
        LoadableLazyColumnState(
            lazyListState = lazyListState,
            pullRefreshState = pullRefreshState,
            loadMoreState = loadMoreState,
        )
    }
}

@Composable
fun rememberLoadMoreState(
    loadMoreRemainCountThreshold: Int,
    onLoadMore: () -> Unit,
): LoadMoreState {
    return remember {
        LoadMoreState(loadMoreRemainCountThreshold, onLoadMore)
    }
}

data class LoadMoreState(
    val loadMoreRemainCountThreshold: Int,
    val onLoadMore: () -> Unit,
)

@OptIn(ExperimentalMaterialApi::class)
data class LoadableLazyColumnState(
    val lazyListState: LazyListState,
    val pullRefreshState: PullRefreshState,
    val loadMoreState: LoadMoreState,
)

這就是所有代碼了。

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

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

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