Compose版SmartRefreshLayout【下拉刷新&上拉加载】

4,821 阅读9分钟

介绍

UI参照SmartRefreshLayout仿写,基于compose实现。有下拉刷新&上拉加载功能(无需Paging3),自定义头尾布局。

效果图

refresh_success.gifrefresh_error.gifuntitled.gif
load_success.gifload_error.gif

如何实现

1、思考设计SmartRefreshLayout需要什么?

  • 头尾布局可以自定义
  • 预先测量头尾布局的高度
  • 处理嵌套滚动,父布局消费还是子布局消费滑动事件
  • 刷新&加载更多的监听
  • 下拉刷新&上拉加载的开关
  • Drag阈值与Fling阈值设置

2、使用SubcomposeLayout测量header以及footer布局的高度

SubcomposeLayout如何使用?点我学习

根据传入的头尾布局,预先测量出其高度,将高度传入到内容布局。

@Composable
private fun SubComposeSmartSwipeRefresh(  
    headerIndicator: (@Composable () -> Unit)?,  
    footerIndicator: (@Composable () -> Unit)?,  
    content: @Composable (header: Int, footer: Int) -> Unit  
) {  
    SubcomposeLayout { constraints ->  
        val headerPlaceable = subcompose("header", headerIndicator ?: {}).firstOrNull()?.measure(constraints)  
        val footerPlaceable = subcompose("footer", footerIndicator ?: {}).firstOrNull()?.measure(constraints)  
        val contentPlaceable =  
            subcompose("content") { content(headerPlaceable?.height ?: 0, footerPlaceable?.height ?: 0) }.first().measure(constraints)  
        layout(contentPlaceable.width, contentPlaceable.height) {  
            contentPlaceable.placeRelative(0, 0)  
        }  
    }  
}

3、定义嵌套滚动NestedScrollConnection

Modifier.nestedScroll 修饰符主要用于处理嵌套滑动的场景,为父布局劫持消费子布局滑动手势提供了可能,里面需要传入一个NestedScrollConnection

onPreScroll

  • 方法描述:预先劫持滑动事件,消费后再交由子布局。
  • 参数列表:
    • available:当前可用的滑动事件偏移量
    • source:滑动事件的类型
  • 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero

onPostScroll

  • 方法描述:获取子布局处理后的滑动事件。
  • 参数列表:
    • consumed:之前消费的所有滑动事件偏移量
    • available:当前剩下还可用的滑动事件偏移量
    • source:滑动事件的类型
  • 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理

onPreFling

  • 方法描述:获取 Fling 开始时的速度。
  • 参数列表:
    • available:Fling 开始时的速度
  • 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero

onPostFling

  • 方法描述:获取 Fling 结束时的速度。
  • 参数列表:
    • available:Fling 开始时的速度
  • 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero

我们自定义一个SmartSwipeRefreshNestedScrollConnection继承NestedScrollConnection接口。

private class SmartSwipeRefreshNestedScrollConnection(  
    val state: SmartSwipeRefreshState, private val coroutineScope: CoroutineScope  
) : NestedScrollConnection {}

Drag事件分析

假设以下场景,手指下拉500像素

1、假设子布局消费了100像素滚动到了顶部,剩下的400像素交给父布局处理。

image.png

onPreScroll方法中available=Offset(0,500),此时无需预先劫持事件,将滑动事件都交由子布局进行处理,返回Offset.Zero

onPostScroll方法中consumed=Offset(0,100),available=Offset(0,400),子布局没消费完成的400像素,则是我们头布局需要的偏移量,消费完成后,返回当前组件消费滑动事件偏移量。

如果头布局允许任意范围的下拉则返回Offset(0,400)
如果头布局设置了允许拖动的最大范围,例如头布局仅允许向下拖动350像素,则返回Offset(0,350),剩下的50像素则继续交由父布局去处理。

2、假设此时尾布局已经向上偏移300像素,则需要预先劫持滑动事件处理尾布局滚动,剩下的交由子布局继续滑动。

image.png

onPreScroll方法中available=Offset(0,500),需要预先劫持滑动事件处理尾布局滚动,因为偏移了300像素,所以尾部向下滑动到消失为止最多只能消费300像素,返回Offset(0,300),剩下200像素交由子布局进行消费。

onPostScroll方法中无需处理

假设以下场景,手指上拉500像素

1、假设子布局消费了100像素滚动到了底部,剩下的400像素交给父布局处理。

image.png

onPreScroll方法中available=Offset(0,500),此时无需预先劫持事件,将滑动事件都交由子布局进行处理,返回Offset.Zero

onPostScroll方法中consumed=Offset(0,100),available=Offset(0,400),子布局没消费完成的400像素,则是我们尾布局需要的偏移量,消费完成后,返回当前组件消费滑动事件偏移量。

如果尾布局允许任意范围的下拉则返回Offset(0,400)
如果尾布局设置了允许拖动的最大范围,例如尾布局仅允许向上拖动350像素,则返回Offset(0,350),剩下的50像素则继续交由父布局去处理。

2、假设此时头布局已经向下偏移300像素,则需要预先劫持滑动事件处理头布局滚动,剩下的交由子布局继续滑动。

image.png

onPreScroll方法中available=Offset(0,500),需要预先劫持滑动事件处理头布局滚动,因为偏移了300像素,所以头部向上滑动到消失为止最多只能消费300像素,返回Offset(0,300),剩下200像素交由子布局进行消费。

onPostScroll方法中无需处理

Fling事件分析

Drag之后当我们松开手指,会触发onPreFling事件,此时根据头尾布局的偏移量判断触发刷新,未触发则会继续收到一连串的onPreScroll与onPostScroll事件。 惯性滚动停止后,会触发onPostFling事件,根据偏移量做头尾布局的折叠动画。

通过上述的分析,我们可以写出如下的代码

private class SmartSwipeRefreshNestedScrollConnection(
    val state: SmartSwipeRefreshState, private val coroutineScope: CoroutineScope

) : NestedScrollConnection {

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return when {
            // 刷新状态组件不消费滑动事件
            state.isLoading() -> Offset.Zero
            // 手指上滑&头布局显示的情况下
            available.y < 0 && state.indicatorOffset > 0 -> {
                // header can drag [state.indicatorOffset, 0]
                val canConsumed = (available.y * state.stickinessLevel).coerceAtLeast(0 - state.indicatorOffset)
                scroll(canConsumed)
            }
            // 手指下滑&尾布局显示的情况下
            available.y > 0 && state.indicatorOffset < 0 -> {
                // footer can drag [state.indicatorOffset, 0]
                val canConsumed = (available.y * state.stickinessLevel).coerceAtMost(0 - state.indicatorOffset
                scroll(canConsumed)
            }
            else -> Offset.Zero
        }
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        return when {
            // 刷新状态组件不消费滑动事件
            state.isLoading() -> Offset.Zero
            // 手指下滑&允许下拉刷新&头布局还未显示
            // state.strategyIndicatorHeight是头尾布局允许的最大滑动范围
            available.y > 0 && state.enableRefresh && state.headerHeight != 0f -> {
                val canConsumed = if (source == NestedScrollSource.Fling) {
                    (available.y * state.stickinessLevel).coerceAtMost(state.strategyIndicatorHeight(state.flingHeaderIndicatorStrategy) - state.indicatorOffset)
                } else {
                    (available.y * state.stickinessLevel).coerceAtMost(state.strategyIndicatorHeight(state.dragHeaderIndicatorStrategy) - state.indicatorOffset)
                }
                scroll(canConsumed)
            }
            // 手指上滑&允许上拉加载&尾布局还未显示
            available.y < 0 && state.enableLoadMore && state.footerHeight != 0f -> {
                val canConsumed = if (source == NestedScrollSource.Fling) {
                    (available.y * state.stickinessLevel).coerceAtLeast(-state.strategyIndicatorHeight(state.flingFooterIndicatorStrategy) - state.indicatorOffset)
                } else {
                    (available.y * state.stickinessLevel).coerceAtLeast(-state.strategyIndicatorHeight(state.dragFooterIndicatorStrategy) - state.indicatorOffset)
                }
                scroll(canConsumed)
            }

            else -> Offset.Zero
        }
    }

    private fun scroll(canConsumed: Float): Offset {
        return if (canConsumed.absoluteValue > 0.5f) {
            coroutineScope.launch {
                state.snapOffsetTo(state.indicatorOffset + canConsumed)
            }
            Offset(0f, canConsumed / state.stickinessLevel)
        } else {
            Offset.Zero
        }
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        if (state.isLoading()) {
            return Velocity.Zero
        }
        // 判断松手的瞬间内容布局是否滚动到底了
        state.releaseIsEdge = state.indicatorOffset != 0f

        if (state.indicatorOffset >= state.headerHeight && state.releaseIsEdge) {
            if (state.refreshFlag != SmartSwipeStateFlag.REFRESHING) {
                // 滚动到完整显示头布局的位置并触发刷新
                state.refreshFlag = SmartSwipeStateFlag.REFRESHING
                state.animateOffsetTo(state.headerHeight)
                return available
            }
        }

        if (state.indicatorOffset <= -state.footerHeight && state.releaseIsEdge) {
            if (state.loadMoreFlag != SmartSwipeStateFlag.REFRESHING) {
                // 滚动到完整显示尾布局的位置并触发刷新
                state.loadMoreFlag = SmartSwipeStateFlag.REFRESHING
                state.animateOffsetTo(-state.footerHeight)
                return available
            }
        }

        return super.onPreFling(available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        if (state.isLoading()) {
            return Velocity.Zero
        }

        if (state.refreshFlag != SmartSwipeStateFlag.REFRESHING && state.indicatorOffset > 0) {
            // 折叠动画 隐藏头布局
            state.refreshFlag = SmartSwipeStateFlag.IDLE
            state.animateOffsetTo(0f)
        }

        if (state.loadMoreFlag != SmartSwipeStateFlag.REFRESHING && state.indicatorOffset < 0) {
            // 折叠动画 隐藏尾布局
            state.loadMoreFlag = SmartSwipeStateFlag.IDLE
            state.animateOffsetTo(0f)
        }

        return super.onPostFling(consumed, available)
    }

}

4、定义SmartSwipeRefreshState

粘性等级 默认0.5,即滑动到边缘时手指滑动100px,头尾布局实际偏移50px

var stickinessLevel = 0.5f

头尾布局测量高度 通过SubcomposeLayout预先测量得出

var headerHeight = 0f
var footerHeight = 0f

其中滑动阈值有四个属性控制

// 头布局拖拽策略 默认Drag无限制,可以任意拖动
var dragHeaderIndicatorStrategy: ThresholdScrollStrategy = ThresholdScrollStrategy.UnLimited
// 尾布局拖拽策略 默认Drag无限制,可以任意拖动
var dragFooterIndicatorStrategy: ThresholdScrollStrategy = ThresholdScrollStrategy.UnLimited
// 头布局快速滑动策略 默认快速滑动时滚动到内容布局顶部就停止
var flingHeaderIndicatorStrategy: ThresholdScrollStrategy = ThresholdScrollStrategy.None
// 尾布局快速滑动策略 默认快速滑动时滚动到内容布局底部就停止
var flingFooterIndicatorStrategy: ThresholdScrollStrategy = ThresholdScrollStrategy.None

fun strategyIndicatorHeight(strategy: ThresholdScrollStrategy): Float = when (strategy) {  
    ThresholdScrollStrategy.None -> 0f  
    is ThresholdScrollStrategy.Fixed -> strategy.height  
    else -> Float.MAX_VALUE  
}

/**  
 * 边界阈值策略  
 * [ThresholdScrollStrategy.None] 阈值为0  
 * [ThresholdScrollStrategy.UnLimited] 阈值为任意  
 * [ThresholdScrollStrategy.Fixed] 阈值为固定数值  
 */  
sealed interface ThresholdScrollStrategy {  
    data object None : ThresholdScrollStrategy  
    data object UnLimited : ThresholdScrollStrategy  
    data class Fixed(val height: Float) : ThresholdScrollStrategy  
}

下拉刷新&上拉加载开关

var enableRefresh = true
var enableLoadMore = true

头状态&尾状态

var refreshFlag by mutableStateOf(SmartSwipeStateFlag.IDLE)  
var loadMoreFlag by mutableStateOf(SmartSwipeStateFlag.IDLE)

enum class SmartSwipeStateFlag {  
    IDLE, REFRESHING, SUCCESS, ERROR, TIPS_DOWN, TIPS_RELEASE  
}

偏移量动画

private val _indicatorOffset = Animatable(0f)  
private val mutatorMutex = MutatorMutex()  
  
val indicatorOffset: Float  
    get() = _indicatorOffset.value  

var animateIsOver by mutableStateOf(true)
// 是否是刷新状态
fun isLoading() = !animateIsOver || refreshFlag == SmartSwipeStateFlag.REFRESHING || loadMoreFlag == SmartSwipeStateFlag.REFRESHING  
  
suspend fun animateOffsetTo(offset: Float) {  
    mutatorMutex.mutate {  
        _indicatorOffset.animateTo(offset) {  
            if (this.value == 0f) { 
                // 折叠动画结束
                animateIsOver = true  
            }  
        }  
    }  
}  
  
suspend fun snapOffsetTo(offset: Float) {  
    mutatorMutex.mutate(MutatePriority.UserInput) {  
        _indicatorOffset.snapTo(offset)  
  
        if (indicatorOffset >= headerHeight) {
            // 头布局完全显示则提示释放刷新
            refreshFlag = SmartSwipeStateFlag.TIPS_RELEASE  
        } else if (indicatorOffset <= -footerHeight) {
            // 尾布局完全显示则提示释放刷新
            loadMoreFlag = SmartSwipeStateFlag.TIPS_RELEASE  
        } else {  
            if (indicatorOffset > 0) {  
                refreshFlag = SmartSwipeStateFlag.TIPS_DOWN  
            }  
            if (indicatorOffset < 0) {  
                loadMoreFlag = SmartSwipeStateFlag.TIPS_DOWN  
            }  
        }  
    }  
}

5、整体布局

使用Modifier.clipToBounds裁剪,以便头尾布局的隐藏。 将上面创建的NestedScrollConnection设置到外层Box中。

头尾布局可以直接用offsetgraphicsLayer修饰符进行偏移 内容布局不建议直接用offsetgraphicsLayer修饰符进行偏移,会导致刷新时,由于内容布局被偏移出屏幕之外,滚动内容布局显示不全问题。但是使用padding的时候设置top没问题,设置bottom有问题,需要传入内容布局的滚动状态ScrollableState进来,进行同步偏移。

image.png

image.png

@Composable
fun SmartSwipeRefresh(
    modifier: Modifier = Modifier,
    state: SmartSwipeRefreshState,
    onRefresh: (suspend () -> Unit)? = null,
    onLoadMore: (suspend () -> Unit)? = null,
    headerIndicator: @Composable (() -> Unit)? = { MyRefreshHeader(flag = state.refreshFlag) },
    footerIndicator: @Composable (() -> Unit)? = { MyRefreshHeader(flag = state.loadMoreFlag) },
    contentScrollState: ScrollableState? = null,
    content: @Composable () -> Unit
) {
    val coroutineScope = rememberCoroutineScope()
    val connection = remember(coroutineScope) {
        SmartSwipeRefreshNestedScrollConnection(state, coroutineScope)
    }

    Box(
        modifier = modifier.clipToBounds()
    ) {
        SubComposeSmartSwipeRefresh(
            headerIndicator = headerIndicator, footerIndicator = footerIndicator
        ) { header, footer ->
            state.headerHeight = header.toFloat()
            state.footerHeight = footer.toFloat()

            Box(modifier = Modifier.nestedScroll(connection)) {
                val p = with(LocalDensity.current) { state.indicatorOffset.toDp() }
                val contentModifier = when {
                    p > 0.dp -> Modifier.padding(top = p)
                    p < 0.dp && contentScrollState != null -> Modifier.padding(bottom = -p)
                    p < 0.dp -> Modifier.graphicsLayer { translationY = state.indicatorOffset }
                    else -> Modifier
                }
                // 内容布局
                Box(modifier = contentModifier) {
                    content()
                }
                // 头布局
                headerIndicator?.let {
                    Box(modifier = Modifier
                        .align(Alignment.TopCenter)
                        .graphicsLayer { translationY = -header.toFloat() + state.indicatorOffset }) {
                        headerIndicator()
                    }
                }
                // 尾布局
                footerIndicator?.let {
                    Box(modifier = Modifier
                        .align(Alignment.BottomCenter)
                        .graphicsLayer { translationY = footer.toFloat() + state.indicatorOffset }) {
                        footerIndicator()
                    }
                }
            }
        }
    }
}

6、观测数据状态变化

    LaunchedEffect(state.refreshFlag) {
        when (state.refreshFlag) {
            SmartSwipeStateFlag.REFRESHING -> {
                state.animateIsOver = false
                onRefresh?.invoke()
            }

            SmartSwipeStateFlag.ERROR, SmartSwipeStateFlag.SUCCESS -> {
                // 刷新结束时头布局停留500ms
                delay(500)
                state.animateOffsetTo(0f)
            }

            else -> {}
        }
    }

    LaunchedEffect(state.loadMoreFlag) {
        when (state.loadMoreFlag) {
            SmartSwipeStateFlag.REFRESHING -> {
                state.animateIsOver = false
                onLoadMore?.invoke()
            }

            SmartSwipeStateFlag.ERROR, SmartSwipeStateFlag.SUCCESS -> {
                // 加载结束时尾布局停留500ms
                delay(500)
                state.animateOffsetTo(0f)
            }

            else -> {}
        }
    }

    LaunchedEffect(state.indicatorOffset) {
        // 上拉到底部显示尾布局时同步偏移量给内容布局
        if (state.indicatorOffset < 0 && state.loadMoreFlag != SmartSwipeStateFlag.SUCCESS) {
            contentScrollState?.dispatchRawDelta(-state.indicatorOffset)
        }
    }

7、杂七杂八

首次进入页面自动触发刷新动画进行加载数据 设置SmartSwipeRefreshState.needFirstRefresh = true

LaunchedEffect(Unit) {  
    if (state.needFirstRefresh) {  
        state.initRefresh()  
    }  
}

设置头尾滑动阈值

with(LocalDensity.current) {
    refreshState.dragHeaderIndicatorStrategy = ThresholdScrollStrategy.UnLimited
    refreshState.dragFooterIndicatorStrategy = ThresholdScrollStrategy.Fixed(160.dp.toPx())  
    refreshState.flingHeaderIndicatorStrategy = ThresholdScrollStrategy.None  
    refreshState.flingFooterIndicatorStrategy = ThresholdScrollStrategy.Fixed(80.dp.toPx())  
}

如何使用

初始化viewModel,观测mainUiState

val viewModel by viewModels<MainViewModel>()
val mainUiState = viewModel.mainUiState.observeAsState()

观察数据刷新or加载成功与失败,将对应的状态通知到state.refreshFlagstate.loadMoreFlag

LaunchedEffect(mainUiState.value) {
    mainUiState.value?.let {
        if (it.isLoadMore) {
            refreshState.loadMoreFlag = when (it.flag) {
                true -> SmartSwipeStateFlag.SUCCESS
                false -> SmartSwipeStateFlag.ERROR
            }
        } else {
            refreshState.refreshFlag = when (it.flag) {
                true -> SmartSwipeStateFlag.SUCCESS
                false -> SmartSwipeStateFlag.ERROR
            }
        }
    }
}

页面布局CompositionLocalProvider(LocalOverscrollConfiguration.provides(null))用来去除上下滚动到边界的水波纹。

setContent {
    val scrollState = rememberLazyListState()
    val refreshState = rememberSmartSwipeRefreshState()
    // 快速滚动头尾允许的阈值
    with(LocalDensity.current) {
        refreshState.dragHeaderIndicatorStrategy = ThresholdScrollStrategy.UnLimited
        refreshState.dragFooterIndicatorStrategy = ThresholdScrollStrategy.Fixed(160.dp.toPx())
        refreshState.flingHeaderIndicatorStrategy = ThresholdScrollStrategy.None
        refreshState.flingFooterIndicatorStrategy = ThresholdScrollStrategy.Fixed(80.dp.toPx())
    }
    refreshState.needFirstRefresh = true
    Column {
        SmartSwipeRefresh(
            modifier = Modifier.fillMaxSize(),
            onRefresh = {
                viewModel.fillData(true)
            },
            onLoadMore = {
                viewModel.fillData(false)
            },
            state = refreshState,
            headerIndicator = {
                MyRefreshHeader(refreshState.refreshFlag, true)
            },
            footerIndicator = {
                MyRefreshFooter(refreshState.loadMoreFlag, true)
            },
            contentScrollState = scrollState
        ) {
            CompositionLocalProvider(LocalOverscrollConfiguration.provides(null)) {
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    state = scrollState
                ) {
                    mainUiState.value?.data?.let {
                        items(it) { item ->
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .wrapContentHeight()
                                    .background(Color.LightGray)
                                    .padding(16.dp),
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                Image(
                                    modifier = Modifier
                                        .width(32.dp)
                                        .height(32.dp),
                                    painter = painterResource(id = item.icon),
                                    contentDescription = null
                                )
                                Spacer(modifier = Modifier.width(16.dp))
                                Text(text = item.title)
                            }
                        }
                    }
                }
            }
        }
    }
}

一些情况无需关注成功失败的情况下,直接如下即可

onRefresh = {
        viewModel.fillData(true)
        // 延迟1000ms折叠头布局
        delay(1000)
        refreshState.refreshFlag = SmartSwipeStateFlag.SUCCESS
    }

总结

这个版本完全仿写SmartRefreshLayout,使用compose开发新页面能做到与原有项目的风格统一(例如我们公司的旧页面刷新加载都是用的SmartRefreshLayout)。无需增加Paging3学习成本(谷歌还是推荐我们使用Paging3的)

项目源码

源码 ComposeSmartRefresh

接入

repositories {
    mavenCentral()
}

dependencies {
    implementation "io.github.loren-moon:composesmartrefresh:2.1.0"
}

🎉🎉🎉