Jetpack compose 仿QQ音乐实现下拉刷新上拉加载更多

7,337 阅读5分钟

前言

看了官方的NestedScrollConnectionSample,研究了NestedScrollConnection,再加上看了SwipeRefreshLayout的源码后,觉得自己可以写一个自定义的下拉刷新上拉加载更多

最终效果

Canvas.gif Lottie.gif scw29-tgzei.gif tt575-5xpus.gif

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都是drag
      • NestedScrollSource.Fling:用户滑动屏幕后由Velocity计算后的一系列滑动都是Fling,onPreFling方法回调后这个source就都是Fling了
    • 返回offset: 这个return 的offset 就是父亲compose组件消费的偏移量,它的取值范围在Offset.Zeroavailable之间,当然也包括它们;返回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给到这个onPostScrollavailable了)
    • 参数source:滑动事件来源(同上onPreScroll的参数source
    • 返回offset: 它的取值范围也在Offset.Zeroavailable之间;如果返回Offset.Zero:那子compose后续发给onPostScrollavailable就是Zero;如果返回available,那子compose后续就会把它剩余没消费的offset发给onPostScrollavailable

这里我画了一个时序图方便理解:

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效果,用户的手指离开屏幕的瞬间,子组件的滑动就立即停止,之后不会触发onPreScrollonPostScroll ,直接触发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))
        }
    }
}

效果如下:

base.gif

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)
}

最终效果:

Canvas.gif

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)
        )
    }
}
Lottie.gif