前言
看了官方的NestedScrollConnectionSample
,研究了NestedScrollConnection
,再加上看了SwipeRefreshLayout
的源码后,觉得自己可以写一个自定义的下拉刷新上拉加载更多
最终效果
1.NestedScrollConnection
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)
}
}
-
onPreScroll:预滚动事件链。 由子Compose组合项调用,把可消费的offset发送过来,父Compose可组合项是否消费、消费多少取决于这个方法的返回值,如果不消费返回
Offset.Zero
,如果全部消费返回available
- 参数
available
: 可消费的偏移量,可以理解为available = Offset(x = currentEvent.x-beforEvent.x,y=currentEvent.y-beforeEvent.y)
,就是指相邻的两个事件的偏移量 - 参数
source
:滑动事件来源NestedScrollSource.Drag
:只要用户的手指没有离开屏幕都是drag,在onPreFling
方法回调前这个source都是dragNestedScrollSource.Fling
:用户滑动屏幕后由Velocity计算后的一系列滑动都是Fling,onPreFling
方法回调后这个source就都是Fling了
- 返回
offset
: 这个return 的offset
就是父亲compose组件消费的偏移量,它的取值范围在Offset.Zero
和available
之间,当然也包括它们;返回zero:就是完全不消费;返回available:那子compose组件收到的available可用的offset就一直是Offset.Zero
,就会导致子compose组件无法滑动了。
- 参数
用代码来模拟去理解:
while(true){
awaitPointerEvent()
//可消费的offset
val available = Offset(x = currentEvent.x-beforEvent.x,y=currentEvent.y-beforeEvent.y)
//父组件消费的offset
val parentConsumed = nestedScrollConnection.onPreScroll(available,NestedScrollConnection.Drag)
//子组件可消费的offset
val childAvilable = available - parentConsumed
//子组件处理childAvilable
...
}
-
onPostScroll:发布滚动事件传递。也是由子compose通知,把自己(该儿子compose组件)消费的offset发送过来
- 参数
consumed
: 由子compose发送过来已经消费的offset - 参数
available
: 由子compose发送过来,子compose 剩余没消费的offset(例如:子compose是一个lazyColumn,lazyColumn已经处于顶部,但是用户还是往下拖动,lazyColumn已经处于顶部了无法在向上滑动了,就消费不了这个offset,就会把这个offset给到这个onPostScroll
的available
了) - 参数
source
:滑动事件来源(同上onPreScroll
的参数source
) - 返回
offset
: 它的取值范围也在Offset.Zero
和available
之间;如果返回Offset.Zero
:那子compose后续发给onPostScroll
的available
就是Zero;如果返回available
,那子compose后续就会把它剩余没消费的offset发给onPostScroll
的available
- 参数
这里我画了一个时序图方便理解:
sequenceDiagram
子组件->>父组件的nestedScrollConnection: 把available给到onPreScroll
父组件的nestedScrollConnection->>父组件的nestedScrollConnection: childAvilable = available-onPreScroll(available)
父组件的nestedScrollConnection-->>子组件: 把childAvilable给子组件
子组件->>子组件: 消费childAvilable得到childConsumed和childUnConsumed
子组件->>子组件: 根据上一次的父onPostScroll判断postAvailable为childUnConsumed还是Zero
子组件->>父组件的nestedScrollConnection: 把childConsumed和postAvailable给到onPostScroll
父组件的nestedScrollConnection->>子组件: 把onPostScroll(childConsumed,postAvailable)的结果给子组件
子组件->>子组件: 记录下父是否完全消费了childUnConsumed
注意:根据上一次的父onPostScroll判断postAvailable为childUnConsumed还是Zero 这一步
只要上一次onPostScroll返回的offset小于上一次子组件未消费的Offset, 后续所有的事件流onPostScroll的Available都是Zero ,
-
onPreFling:预投掷事件链。当用户的手指离开屏幕的瞬间,会回调此方法,以此方法为界限,后续的onPreScroll和onPostScroll的source都是NestedScrollSource.Fling
- 参数
available: Velocity
:可消费的速度,由子组件传递过来 - 返回
Velocity
:父组件已消费的速度,和onPreScroll的原理一样,子组件可消费的速度childAvailableVelocity = available - onPreFling(available)
,当子组件可消费的速度childAvailableVelocity
为Zero,子组件不会有任何fling效果,用户的手指离开屏幕的瞬间,子组件的滑动就立即停止,之后不会触发onPreScroll
和onPostScroll
,直接触发onPostFling
- 参数
-
onPostFling :发布投掷事件链。由子组件在完成投掷时调用,参数consumed是子组件已经消费的速度,参数available是子组件未消费的速度;返回
return
:(没研究过,不知道要给到谁)
2.实现NestedScrollConnection
available.y 大于0表示向下拖动,小于0表示向上拖动,那如何区分是正常拖动子组合项,还是子组合项达到顶部的下拉、底部的上拉
打印log会发现,当lazyColumn在顶部的时候,手指继续向下拖动,会发现第一个onPostScroll的available不为0,当onPostScroll也返回available的时候,后续的onPostScroll的available都不为0,在底部手指向上拖动的情况和这也一样;
正常拖动lazyColumn的时候onPostScroll的available 一直是0
private const val DragMultiplier = 0.5f
private class SwipeRefreshNestedScrollConnection(
private val state: MySwipeRefreshState,
private val coroutineScope: CoroutineScope,
private val onRefresh: () -> Unit,
private val onLoadMore: () -> Unit
) : NestedScrollConnection {
var refreshEnabled: Boolean = false//是否开启下拉刷新
var loadMoreEnabled: Boolean = false//是否开启上拉加载
var refreshTrigger: Float = 100f//最大的下上拉的距离
var indicatorHeight: Float = 50f//顶部、底部下上组合项的高度
private var isTop = false //是否是顶部的下拉
private var isBottom = false//是否是达到
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = when {
//刷新和更多都禁用 则不处理
!refreshEnabled && !loadMoreEnabled -> Offset.Zero
//当处于刷新状态或者更多状态,不处理
state.loadState != NORMAL -> Offset.Zero
source == NestedScrollSource.Drag -> {
Log.v("hj", "onPreScroll available = $available")
if (available.y > 0 && isBottom) {
onScroll(available)
} else if (available.y < 0 && isTop) {
onScroll(available)
} else {
Offset.Zero
}
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
//刷新和更多都禁用 则不处理
if (!refreshEnabled && !loadMoreEnabled) {
return Offset.Zero
}
//当处于刷新状态或者更多状态,不处理
else if (state.loadState != NORMAL) {
return Offset.Zero
} else if (source == NestedScrollSource.Drag) {
Log.d("hj", "onPostScroll available = $available , consumed = $consumed")
if (available.y < 0) {
if (!isBottom) {
isBottom = true
}
if (isBottom) {
return onScroll(available)
}
} else if (available.y > 0) {
if (!isTop) {
isTop = true
}
if (isTop) {
return onScroll(available)
}
}
}
return Offset.Zero
}
private fun onScroll(available: Offset): Offset {
if (!isBottom && !isTop) {
return Offset.Zero
}
if (available.y > 0 && isTop) {
state.isSwipeInProgress = true
} else if (available.y < 0 && isBottom) {
state.isSwipeInProgress = true
} else if (state.indicatorOffset.roundToInt() == 0) {
state.isSwipeInProgress = false
}
val newOffset = (available.y * DragMultiplier + state.indicatorOffset).let {
if (isTop) it.coerceAtLeast(0f) else it.coerceAtMost(0f)
}
val dragConsumed = newOffset - state.indicatorOffset
return if (dragConsumed.absoluteValue >= 0.5f) {
coroutineScope.launch {
state.dispatchScrollDelta(
dragConsumed,
if (isTop) TOP else BOTTOM,
refreshTrigger,
)
}
// Return the consumed Y
Offset(x = 0f, y = dragConsumed / DragMultiplier)
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
// If we're dragging, not currently refreshing and scrolled
// past the trigger point, refresh!
if (state.loadState == NORMAL && abs(state.indicatorOffset) >= indicatorHeight) {
if (isTop) {
onRefresh()
} else if (isBottom) {
onLoadMore()
}
}
// Reset the drag in progress state
state.isSwipeInProgress = false
// Don't consume any velocity, to allow the scrolling layout to fling
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero.also {
isTop = false
isBottom = false
}
}
}
当然这里是参考了SwipeRefreshLayout的实现
3. 实现自己的SwipeRefreshState
//常规状态
const val NORMAL = 0
//下拉刷新状态
const val REFRESHING = 1
//上拉加载状态
const val LOADING_MORE = 2
@Stable
class MySwipeRefreshState(
loadState: Int,
) {
private val _indicatorOffset = Animatable(0f)
private val mutatorMutex = MutatorMutex()
var loadState: Int by mutableStateOf(loadState)
/**
* Whether a swipe/drag is currently in progress.
*/
var isSwipeInProgress: Boolean by mutableStateOf(false)
internal set
//上下拉的偏移量等等
var progress: SwipeProgress by mutableStateOf(SwipeProgress())
internal set
/**
* The current offset for the indicator, in pixels.
*/
internal val indicatorOffset: Float get() = _indicatorOffset.value
internal suspend fun animateOffsetTo(
offset: Float,
) {
mutatorMutex.mutate {
_indicatorOffset.animateTo(offset)
}
}
/**
* Dispatch scroll delta in pixels from touch events.
*/
internal suspend fun dispatchScrollDelta(
delta: Float,
location: Int,
maxOffsetY: Float,
) {
mutatorMutex.mutate(MutatePriority.UserInput) {
_indicatorOffset.snapTo(_indicatorOffset.value + delta)
updateProgress(
location = location,
maxOffsetY = maxOffsetY,
)
}
}
/**
* 更新progress
* @param maxOffsetY 下拉或者上拉indicator最大高度
*/
private fun updateProgress(
offsetY: Float = abs(indicatorOffset),
location: Int,
maxOffsetY: Float,
) {
val offsetPercent = min(1f, offsetY / maxOffsetY)
val offset = min(maxOffsetY, offsetY)
progress = SwipeProgress(location, offset, offsetPercent)
}
}
const val NONE = 0
const val TOP = 1 //indicator在顶部
const val BOTTOM = 2 //indicator在底部
@Immutable
data class SwipeProgress(
val location: Int = NONE,//是在顶部还是在底部
val offset: Float = 0f,//可见indicator的高度
/*@FloatRange(from = 0.0, to = 1.0)*/
val fraction: Float = 0f //0到1,0: indicator不可见 1:可见indicator的最大高度
)
加了一个SwipeProgress用于content的整体位移,还有indicator的动画
4. 实现自己的SwipeRefresh
@Composable
fun MySwipeRefresh(
state: MySwipeRefreshState,
onRefresh: () -> Unit,//下拉刷新回调
onLoadMore: () -> Unit,//上拉加载更多回调
modifier: Modifier = Modifier,
refreshTriggerDistance: Dp = 120.dp,//indication可见的最大高度
indicationHeight: Dp = 56.dp,//indication的高度
refreshEnabled: Boolean = true,//是否支持下拉刷新
loadMoreEnabled: Boolean = true,//是否支持上拉加载更多
indicator: @Composable BoxScope.(modifier: Modifier, state: MySwipeRefreshState, indicatorHeight: Dp) -> Unit = { m, s, height ->
LoadingIndicatorSample(m, s, height)
},//顶部或者底部的Indicator
content: @Composable (modifier: Modifier) -> Unit,
) {
val refreshTriggerPx = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
val indicationHeightPx = with(LocalDensity.current) { indicationHeight.toPx() }
// Our LaunchedEffect, which animates the indicator to its resting position
LaunchedEffect(state.isSwipeInProgress) {
if (!state.isSwipeInProgress) {
// If there's not a swipe in progress, rest the indicator at 0f
state.animateOffsetTo(0f)
}
}
val coroutineScope = rememberCoroutineScope()
val updatedOnRefresh = rememberUpdatedState(onRefresh)
val updatedOnLoadMore = rememberUpdatedState(onLoadMore)
val nestedScrollConnection = remember(state, coroutineScope) {
SwipeRefreshNestedScrollConnection(
state,
coroutineScope,
onRefresh = { updatedOnRefresh.value.invoke() },
onLoadMore = { updatedOnLoadMore.value.invoke() }
)
}.apply {
this.refreshEnabled = refreshEnabled
this.loadMoreEnabled = loadMoreEnabled
this.refreshTrigger = refreshTriggerPx
this.indicatorHeight = indicationHeightPx
}
BoxWithConstraints(modifier.nestedScroll(connection = nestedScrollConnection)) {
if (!state.isSwipeInProgress)
LaunchedEffect((state.loadState == REFRESHING || state.loadState == LOADING_MORE)) {
//回弹动画
animate(
animationSpec = tween(durationMillis = 300),
initialValue = state.progress.offset,
targetValue = when (state.loadState) {
LOADING_MORE -> indicationHeightPx
REFRESHING -> indicationHeightPx
else -> 0f
}
) { value, _ ->
if (!state.isSwipeInProgress) {
state.progress = state.progress.copy(
offset = value,
fraction = min(1f, value / refreshTriggerPx)
)
}
}
}
val offsetDp = with(LocalDensity.current) {
state.progress.offset.toDp()
}
//子可组合项 根据state.progress来设置子可组合项的padding
content(
modifier = when (state.progress.location) {
TOP -> Modifier.padding(top = offsetDp)
BOTTOM -> Modifier.padding(bottom = offsetDp)
else -> Modifier
}
)
if (state.progress.location != NONE) {
//顶部、底部的indicator 纵坐标跟随state.progress移动
Box(modifier = Modifier
.fillMaxWidth()
.height(refreshTriggerDistance)
.graphicsLayer {
translationY =
if (state.progress.location == LOADING_MORE) constraints.maxHeight - state.progress.offset
else state.progress.offset - refreshTriggerPx
}
) {
indicator(
Modifier.align(if (state.progress.location == TOP) Alignment.BottomStart else Alignment.TopStart),
state = state,
indicatorHeight = indicationHeight
)
}
}
}
}
5. 实现一个简单的Indicator
@Composable
fun BoxScope.LoadingIndicatorSample(
modifier: Modifier,
state: MySwipeRefreshState,
indicatorHeight: Dp
) {
val height = max(30.dp, with(LocalDensity.current) {
state.progress.offset.toDp()
})
Box(
modifier
.fillMaxWidth()
.height(height), contentAlignment = Alignment.Center
) {
if (state.isSwipeInProgress) {
if (state.progress.offset <= with(LocalDensity.current) { indicatorHeight.toPx() }) {
Text(text = if (state.progress.location == TOP) "下拉刷新" else "上拉加载更多")
} else {
Text(text = if (state.progress.location == TOP) "松开刷新" else "松开加载更多")
}
} else {
AnimatedVisibility(state.loadState == REFRESHING || state.loadState == LOADING_MORE) {
//加载中
CircularProgressIndicator()
}
}
}
}
使用
val scope = rememberCoroutineScope()
val state = rememberSwipeRefreshState(NORMAL)
var list by remember {
mutableStateOf(List(20){"I'm item $it"})
}
MySwipeRefresh(
state = state,
indicator = {modifier, s, indicatorHeight ->
LoadingIndicator(modifier,s,indicatorHeight)
},
onRefresh = {
scope.launch {
state.loadState = REFRESHING
//模拟网络请求
delay(2000)
list = List(20){"I'm item $it"}
state.loadState = NORMAL
}
},
onLoadMore = {
scope.launch {
state.loadState = LOADING_MORE
//模拟网络请求
delay(2000)
list = list + List(20){"I'm item ${it+list.size}"}
state.loadState = NORMAL
}
}
){modifier->
//注意这里要把modifier设置过来,要不然LazyColumn不会跟随它上下拖动
LazyColumn(modifier) {
items(items = list, key = {it}){
Text(text = it,
Modifier
.fillMaxWidth()
.padding(10.dp))
}
}
}
效果如下:
6.实现最终仿QQ音乐下拉效果
这里用Canvas实现
@Composable
fun BoxScope.LoadingIndicator(
modifier: Modifier,
state: MySwipeRefreshState,
indicatorHeight: Dp
) {
val density = LocalDensity.current
//N个矩形条的宽度
val iconWidth = 35.dp
//canvas高度
val canvasHeight = max(30.dp, with(density) {
state.progress.offset.toDp()
})
//矩形条的颜色
val contentColor = MaterialTheme.colors.onSurface
//每个矩形的高度
val rectHeightArray = remember {
arrayOf(12.dp, 16.dp, 20.dp, 16.dp, 12.dp).map { with(density) { it.toPx() } }
.toFloatArray()
}
//每个item的宽度
val itemWidth = with(density) { (iconWidth / rectHeightArray.size).toPx() }
//矩形条的宽度
val rectWidth = itemWidth / 2
if (state.isSwipeInProgress) {
Canvas(
modifier = modifier
.fillMaxWidth()
.height(canvasHeight)
) {
//让N个圆角矩形横向居中
val paddingStart = (size.width - iconWidth.toPx()) / 2
//开始动画的偏移距离
val startAnimOffset = 20.dp.toPx()
//@FloatRange(from = 0.0, to = 1.0) 矩形高度的percent百分比
val realFraction =
((state.progress.offset - startAnimOffset).coerceAtLeast(0f) / (indicatorHeight.toPx() - startAnimOffset)).coerceAtMost(
1f
)
//N个矩形条拼接到一起的高度,并且跟随realFraction变化
val visibleRectHeight = rectHeightArray.sum() * realFraction
rectHeightArray.forEachIndexed { index, maxLineHeight ->
val start = paddingStart + itemWidth * index + (itemWidth - rectWidth)
val bgTop = (size.height - rectHeightArray[index]) / 2
//背景
drawRoundRect(
color = contentColor.copy(alpha = 0.25f),
topLeft = Offset(x = start, y = bgTop),
size = Size(width = rectWidth, height = rectHeightArray[index]),
cornerRadius = CornerRadius(rectWidth / 2)
)
//单个矩形条的高度,跟随realFraction来变化
val height =
(visibleRectHeight - (rectHeightArray.filterIndexed { i, _ -> i < index }
.sum())).coerceAtLeast(0f).coerceAtMost(maxLineHeight)
val top = (size.height - height) / 2
drawRoundRect(
color = contentColor,
topLeft = Offset(x = start, y = top),
size = Size(width = rectWidth, height = height),
cornerRadius = CornerRadius(rectWidth / 2)
)
}
}
} else {
AnimatedVisibility(
state.loadState != NORMAL,
modifier = modifier
.fillMaxWidth()
.height(canvasHeight),
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
val target = 200f
//无线循环的动画
val infiniteTransition = rememberInfiniteTransition()
val value by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = target,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
//根据无线循环的动画得到每个矩形高度的percent 百分比
val percents = arrayOf(
getOffsetPercent(value, 0f, target),
getOffsetPercent(value, 30f, target),
getOffsetPercent(value, 50f, target),
getOffsetPercent(value, 70f, target),
getOffsetPercent(value, 100f, target),
)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(canvasHeight)
) {
val maxHeight = 20.dp.toPx()
val paddingStart = (size.width - iconWidth.toPx()) / 2
percents.forEachIndexed { index, percent ->
val start = paddingStart + itemWidth * index + (itemWidth - rectWidth)
val top = (size.height - maxHeight * percent.coerceAtLeast(0.25f)) / 2
drawRoundRect(
color = contentColor,
topLeft = Offset(x = start, y = top),
size = Size(width = rectWidth, height = maxHeight * percent),
cornerRadius = CornerRadius(rectWidth / 2)
)
}
}
}
}
}
fun getOffsetPercent(value: Float, offset: Float, spacing: Float, minValue: Float = 0f): Float {
val toValue = value + offset
val resValue =
if (toValue < spacing / 2) {
toValue
} else if (toValue < spacing) {
spacing - toValue
} else {
toValue - spacing
}
return (resValue / 100f).coerceAtLeast(minValue)
}
最终效果:
7 结合Lottie实现一个下拉、上拉的Lottie动画效果
Lottie可以实现很酷炫的动画,也很早就支持了Jetpack compose,这里是Lottie Compose的使用文档
@Composable
fun BoxScope.LottieLoadingIndicator(
modifier: Modifier,
state: MySwipeRefreshState,
indicatorHeight: Dp
) {
if(state.progress.fraction > 0f) {
val density = LocalDensity.current
//高度
val height = max(56.dp, with(density) {
state.progress.offset.toDp()
})
//加载中
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_loading3))
val progress = animateLottieCompositionAsState(
iterations = Int.MAX_VALUE,
speed = 2.5f,
composition = composition,
)
var lastFrame = remember {
0f
}
LottieAnimation(
composition = composition,
progress = {
if (state.loadState == NORMAL) {
lastFrame
} else {
lastFrame = progress.value
lastFrame
}
},
modifier = modifier
.fillMaxWidth()
.height(height)
)
}
}