介绍
UI参照SmartRefreshLayout仿写,基于compose实现。有下拉刷新&上拉加载功能(无需Paging3),自定义头尾布局。
效果图
如何实现
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开始时的速度
- available:
- 返回值:当前组件消费的速度,如果不想消费可返回
Velocity.Zero
onPostFling
- 方法描述:获取
Fling结束时的速度。 - 参数列表:
- available:
Fling开始时的速度
- available:
- 返回值:当前组件消费的速度,如果不想消费可返回
Velocity.Zero
我们自定义一个SmartSwipeRefreshNestedScrollConnection继承NestedScrollConnection接口。
private class SmartSwipeRefreshNestedScrollConnection(
val state: SmartSwipeRefreshState, private val coroutineScope: CoroutineScope
) : NestedScrollConnection {}
Drag事件分析
假设以下场景,手指下拉500像素
1、假设子布局消费了100像素滚动到了顶部,剩下的400像素交给父布局处理。
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像素,则需要预先劫持滑动事件处理尾布局滚动,剩下的交由子布局继续滑动。
onPreScroll方法中available=Offset(0,500),需要预先劫持滑动事件处理尾布局滚动,因为偏移了300像素,所以尾部向下滑动到消失为止最多只能消费300像素,返回Offset(0,300),剩下200像素交由子布局进行消费。
onPostScroll方法中无需处理
假设以下场景,手指上拉500像素
1、假设子布局消费了100像素滚动到了底部,剩下的400像素交给父布局处理。
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像素,则需要预先劫持滑动事件处理头布局滚动,剩下的交由子布局继续滑动。
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中。
头尾布局可以直接用offset、graphicsLayer修饰符进行偏移
内容布局不建议直接用offset、graphicsLayer修饰符进行偏移,会导致刷新时,由于内容布局被偏移出屏幕之外,滚动内容布局显示不全问题。但是使用padding的时候设置top没问题,设置bottom有问题,需要传入内容布局的滚动状态ScrollableState进来,进行同步偏移。
@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.refreshFlag与state.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的)
项目源码
接入
repositories {
mavenCentral()
}
dependencies {
implementation "io.github.loren-moon:composesmartrefresh:2.1.0"
}