You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何为RecyclerView实现自定义下拉刷新(非原生Swipe to refresh)

Android自定义下拉刷新布局实现方案

核心实现思路

基于AndroidX的NestedScrolling系列接口实现,无需依赖原生SwipeRefreshLayout,可兼容所有实现了嵌套滚动规范的可滚动子组件(RecyclerView、NestedScrollView等),完全匹配动图的交互逻辑。

第一步:明确交互对齐规则

先确认所有和演示效果一致的交互点:

  • 子View滚动到顶部后才响应下拉手势,滚动过程无卡顿
  • 下拉时头部跟随手势同步显示,拖动存在阻尼感,不会跟着手指距离等比例滑动
  • 下拉距离未达刷新阈值时,松手直接回弹收起头部,不触发刷新
  • 下拉距离超过刷新阈值时,松手后头部固定在刷新位置,触发刷新回调
  • 刷新过程中屏蔽下拉手势,刷新完成后自动平滑收起头部
  • 头部动画跟随拖动进度实时更新,刷新阶段播放循环加载动画

第二步:自定义刷新容器核心代码实现

2.1 基础布局结构

自定义ViewGroup包含两个固定子View:刷新头部、内容容器,初始状态下头部偏移到屏幕外不可见

class CustomRefreshLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr), NestedScrollingParent3 {

    // 基础配置参数,可根据需求调整
    private val headerHeight = dp2px(60) // 刷新头部高度
    private val refreshThreshold = dp2px(50) // 触发刷新的阈值
    private val maxDragDistance = dp2px(90) // 最大拖动距离
    private val dragRate = 0.5f // 拖动阻尼系数,拉2px显示1px提升手感

    private var currentOffset = 0 // 当前头部偏移量
    private var isRefreshing = false // 刷新状态标记
    var onRefreshListener: (() -> Unit)? = null // 刷新回调

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 测量内容区域
        val contentView = getChildAt(1)
        measureChild(contentView, widthMeasureSpec, heightMeasureSpec)
        // 测量刷新头部
        val headerView = getChildAt(0)
        measureChild(headerView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(headerHeight, MeasureSpec.EXACTLY))
        setMeasuredDimension(measuredWidth, measuredHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val header = getChildAt(0)
        val content = getChildAt(1)
        // 头部初始布局在内容区域上方,屏幕外不可见
        header.layout(0, -headerHeight, r, 0)
        content.layout(0, 0, r, b - t)
    }
}

2.2 嵌套滚动处理

接管子View滚动到顶部后的下拉手势,消费滚动距离更新头部位置

override fun onNestedPreScroll(
    target: View, dx: Int, dy: Int, consumed: IntArray, type: Int
) {
    // dy<0代表下拉手势,仅当未在刷新、子View已经滚动到顶部时处理
    if (dy < 0 && !isRefreshing && !target.canScrollVertically(-1)) {
        val newOffset = (currentOffset - dy * dragRate).toInt().coerceAtMost(maxDragDistance)
        if (newOffset > 0) {
            consumed[1] = dy // 消费当前滚动距离,子View不再滚动
            currentOffset = newOffset
            updateHeaderPosition()
            updateHeaderProgress()
        }
    }
}

// 更新头部位置
private fun updateHeaderPosition() {
    val header = getChildAt(0)
    header.translationY = currentOffset.toFloat()
}

// 跟随拖动进度更新头部UI,比如箭头旋转、进度条填充等
private fun updateHeaderProgress() {
    val progress = (currentOffset.toFloat() / refreshThreshold).coerceAtMost(1f)
    (getChildAt(0) as RefreshHeader).updateProgress(progress)
}

2.3 手势松开逻辑处理

根据当前拖动距离判断是触发刷新还是回弹取消

override fun onStopNestedScroll(target: View, type: Int) {
    if (isRefreshing) return
    if (currentOffset >= refreshThreshold) {
        // 超过刷新阈值,触发刷新
        setRefreshing(true)
        onRefreshListener?.invoke()
    } else {
        // 未到阈值,回弹收起头部
        animateOffsetTo(0)
    }
}

// 平滑滚动到目标偏移量
private fun animateOffsetTo(targetOffset: Int) {
    ValueAnimator.ofInt(currentOffset, targetOffset).apply {
        duration = 300
        interpolator = DecelerateInterpolator()
        addUpdateListener { anim ->
            currentOffset = anim.animatedValue as Int
            updateHeaderPosition()
        }
        start()
    }
}

// 对外暴露的刷新状态设置方法
fun setRefreshing(refreshing: Boolean) {
    if (isRefreshing == refreshing) return
    isRefreshing = refreshing
    if (refreshing) {
        animateOffsetTo(refreshThreshold)
        (getChildAt(0) as RefreshHeader).startLoadingAnim()
    } else {
        (getChildAt(0) as RefreshHeader).stopLoadingAnim()
        animateOffsetTo(0)
    }
}

第三步:刷新头部实现

自定义头部View,根据进度更新UI即可,示例逻辑如下:

class RefreshHeader @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    private val arrowIv: ImageView
    private val progressBar: ProgressBar

    init {
        // 加载头部布局,包含箭头、加载动画等元素
        inflate(context, R.layout.layout_refresh_header, this)
        arrowIv = findViewById(R.id.arrow_iv)
        progressBar = findViewById(R.id.progress_bar)
    }

    // 拖动阶段更新进度
    fun updateProgress(progress: Float) {
        arrowIv.rotation = progress * 180 // 箭头随进度旋转180度
        progressBar.progress = (progress * 100).toInt()
    }

    // 刷新阶段启动循环动画
    fun startLoadingAnim() {
        arrowIv.visibility = GONE
        progressBar.visibility = VISIBLE
        // 启动旋转动画
    }

    // 刷新结束停止动画
    fun stopLoadingAnim() {
        arrowIv.visibility = VISIBLE
        progressBar.visibility = GONE
        // 停止旋转动画
    }
}

第四步:使用方式

布局中直接嵌套即可:

<com.example.CustomRefreshLayout
    android:id="@+id/refresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.RefreshHeader
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</com.example.CustomRefreshLayout>

代码中设置刷新监听:

refresh_layout.onRefreshListener = {
    // 执行业务刷新逻辑,比如请求网络数据
    // 刷新完成后调用 refresh_layout.setRefreshing(false) 收起头部
}

内容的提问来源于stack exchange,提问作者Mustufa Ansari

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.28 18:36:05