下拉刷新+抽屉

本文介绍了一款自定义的下拉刷新组件,支持添加Header和Content,可通过伪代码了解如何使用。组件允许自由调整刷新阈值,实现平滑动画效果,并提供了下拉刷新的监听接口。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

噢天啊,这个命名糟透了

支持下拉刷新,且下拉时显示全屏。看下效果图吧。最后有完整代码

在这里插入图片描述

本来是丝滑般顺畅,这模拟器卡出翔了。。。。

下面说使用?

支持添加header和content

他们分别对应addHeaderView,addContentView。伪代码

	 val topPage = findViewById<TopPageLinearLayout>(R.id.topPage)
    val headerView = layoutInflater.inflate(R.layout.hedaer_view, topPage, false)
    val toolBar = layoutInflater.inflate(R.layout.content_toolbar, topPage, false)
    val recyclerLayout = layoutInflater.inflate(R.layout.content_view, topPage, false)


    topPage.addHeaderView(headerView)
    topPage.addContentView(toolBar)
    topPage.addContentView(recyclerLayout)

HeaderView不可以重复添加,因为它的直接父是一个FrameLayout

ContentView可以重复添加,因为它的直接父是一个垂直的LinearLayout

它们的直接父类都是全屏显示的。

下拉刷新

下拉刷新只需要添加刷新监听即可,伪代码

	     topPage.addRefreshListener(object :TopPageLinearLayout.AddRefreshListener{
        override fun refresh() {
            GlobalScope.launch(Dispatchers.Main) {
                delay(2000)
                topPage.setRefresh(false)
            }
        }
    })

这里模拟了2秒耗时请求,只要在成功或失败后调用setRefresh(false)即可。

下面说代码

TopPageLinearLayout继承自LinearLayout,如果有时间的话继承ViewGroup更好,可以做的比这个体验好很多,当然这个也可以做更好的优化。回头有时间整个2.0

主要关注点:

移动使用的是属性动画

有动画插值器,没提供外部方法,你可以看下代码直接改成直接想要的

下拉刷新的阈值是可以设置,目前是headerView高度的5/1,当然也就是屏幕的

关闭header的阈值也是5/1

contentView可以是任意view,但最好是recyclerView,这样便于获取滑动到顶点的状态,如果你非要在 

LinearLayout或其他viewGroup中嵌套recyclerView,那你只需要在外部通过id获取到recyclerView并调

用TopPageLinearLayout的addRecyclerView即可。

下拉刷新默认不开启,开启请添加监听器addRefreshListener

下拉刷新的view就是一个TextView,如果觉得简单,可以替换,但只能自己改代码

当然还有一些其他的public方法,自己看一下吧,都有注释

代码

没有使用任何第三方库,复制应该不会报错

	/**
 * todo  问题:
 * 1.显示在headerView的时候不应该再接收下拉事件  此时只处理上拉  √
 * 3.现在内容区域使用recyclerView做的,可以解决滑动冲突的问题,但是用户可能是任意view 比如LinearLayout嵌套recyclerView ×
 * 4.如果第一个item被隐藏或者没有完全显示到顶部 无法显示header 这不科学  监听recyclerView的onTouchEvent? 计算偏移量? 就不消费事件了?  ×
 * 5.已经位于顶部时 x轴往左滑动下面显示出背景? bug  √
 * 6.最好的用法是传入唯一子recyclerView 如果需要导航栏跟随内容区域滑动,可以将activity的theme改为noActionBar,再使用toolbar等控件addContentView
 *  最后addRecyclerView,但是注意他们不会跟随recyclerView滑动,不要想着在LinearLayout里嵌入RecyclerView来用,这个暂不支持  √
 *  7.如果不使用列表,比如传入某个view或viewGroup也支持,但是他们不能嵌套滑动列表,如recyclerView,ListView等。参加第3条 √
 *  9.直接设置的屏幕高度导致子view内容现在屏幕外 可能是状态的高度导致的? √
 *  8.支持下拉刷新,当滑动的距离达到下拉刷新阈值时应该显示一个view,比如文本,提示:松开刷新  这个view最好支持外部传入,比如外部自定义动画? √
 *
 *
 *
 *  1.先Gone掉headerView 这样默认显示的就是contentView
 *  2.在Layout调用后,已获取到高度 获取view的高度并赋值
 *  3.如果显示view后matchparent导致view丢失 考虑修改view的高度为测量后高度
 */

/**
 *Created by chen on 2020
 */
open class TopPageLinearLayout : LinearLayout {

/**
 *  表示当前显示的View  contentView or headerView?   在滑动结束时赋值
 */
private enum class TargetView {
    HEADER_VIEW,//headerView
    CONTENT_VIEW//contentView
}

//headerView
private var headerView: FrameLayout? = null

//contentView
private var contentView: LinearLayout? = null

//当前显示的View  contentView or headerView?
private var targetView: TargetView = TargetView.CONTENT_VIEW

//屏幕高度
private var heightPixels = -3

private var findY = 0.0f //up时获取手指滑动的总距离

//  打开header阈值    屏幕高度 /  threshold = 阈值
private val OPEN_THRESHOLD = 5

// 关闭header阈值    屏幕高度 /  threshold = 阈值
private val CLOSE_THRESHOLD = 5

/**
 * down时记录的y点击位置
 */
private var interceptStartY = 0f

/**
 *  move时记录y移动位置 包括up时都需要使用到该坐标
 */
private var interceptEndY = 0f

/**
 * contentView的子视图 @see [addContentView]的函数代码中可以看到 只有参数是recyclerView时才能被赋值
 * 使用它的好处是,通过它的一些支持方法 当它滑动到顶点时
 * 可以获取其已经处于顶点的状态,这样可以支持下拉显示headerView,当然recyclerView不是一定要作为唯一的子
 * 但是调用 @see [addContentView] 时最好作为最后一个view添加,当recyclerView的列表滑动到顶点时,整个contentView中
 * 的所有子view都会跟随滑动,比如你需要添加一个toolbar?
 *            topPage.addContentView(toolBar)
 *            topPage.addContentView(recyclerLayout)
 *     这样添加后不会影响recyclerView的列表滑动,且当滑动到定点时 toolBar和recyclerLayout都会参与滑动
 *
 *
 * 为了应对一些特殊的场景,比如recyclerView一定被嵌套,也可以定义类继承TopPageLinearLayout,添加一个
 * 方法,只要将RecyclerView对象赋值给@see [recyclerView] ,这样一样可以做到在滑动到顶点时继续滑动header
 */
protected var recyclerView: View? = null


private val TAG = "TopPageLinearLayout"

/**
 * 下拉刷新的view
 */
private var refreshView: TextView? = null

/**
 * 下拉刷新的回调接口
 */
private var refreshListener: AddRefreshListener? = null

constructor(context: Context) : this(context, null)

constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

constructor(context: Context, attrs: AttributeSet?, defAttr: Int) : super(
    context,
    attrs,
    defAttr
) {
    this.orientation = VERTICAL

    //添加一个headerView
    headerView = FrameLayout(this.context)
    val layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
    headerView?.layoutParams = layoutParams
    headerView?.visibility = View.GONE
    //添加下拉刷新View
    refreshView = TextView(context)
    refreshView!!.text = "下拉刷新"
    refreshView!!.setTextColor(Color.parseColor("#000000"))
    refreshView!!.tag = false
    refreshView!!.gravity = Gravity.CENTER or Gravity.BOTTOM
    val refreshViewLayoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
 
    headerView!!.addView(refreshView)
    //下拉刷新的View处于屏幕低端
    refreshView!!.setPadding(0, 100, 0, 100)
    refreshViewLayoutParams.gravity = Gravity.BOTTOM
    refreshView!!.layoutParams = refreshViewLayoutParams
    //添加header
    addView(headerView)

    //添加内容View
    contentView = LinearLayout(this.context)
    contentView?.orientation = VERTICAL
    val contentLayoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
    contentView?.layoutParams = contentLayoutParams
    //添加header
    addView(contentView)
}

/**
 * 手指往上滑动是负数
 * 手指往下滑动是正数
 */
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
    var intercept = false
    
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            interceptStartY = event.y
            intercept = false
        }
        MotionEvent.ACTION_MOVE -> {
            interceptEndY = event.y
            val y = interceptEndY - interceptStartY
            // y<0 表示上拉 此时y为负值
            if (y < 0) {
                //显示内容往下
                intercept = false
                // y>0表示是下拉操作  canChildScrollUp表示是否可以继续下拉
            } else if (y > 0 && !canChildScrollUp(-1)) {
                //已经到顶部 此时需要拦截事件
                Log.d(TAG, "已经到顶部 此时需要拦截MOVE事件")
                intercept = true
            }
        }
        MotionEvent.ACTION_UP -> {
            intercept = false
        }
    }
    return intercept
}

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
        }
        MotionEvent.ACTION_MOVE -> {
            interceptEndY = event.y
            findY = interceptEndY - interceptStartY
            //>0表示是向下滑动 此时y轴为正值  2.必须当前View是contentView
            if (findY > 0 && targetView == TargetView.CONTENT_VIEW) { //向header滑动
                val move = heightPixels - findY
                translate(-move)
                //下拉刷新的逻辑
                if (refreshListener != null) {//当添加了下拉刷新的监听时 才处理这部分逻辑
                    //先获取打开header的阈值 超过这个阈值header将被打开
                    val closeThreshold = heightPixels / CLOSE_THRESHOLD
                    val refreshViewHeight = refreshView!!.height
                    if (findY > closeThreshold) { //当阈值达到开启header时 隐藏下拉刷新
                        refreshView?.visibility = View.INVISIBLE
                        refreshView?.tag = false
                    } else if (findY < refreshViewHeight) {//当滑动的距离不足刷新view的高度时 只显示下拉刷新
                        refreshView?.text = "下拉刷新"
                        refreshView?.visibility = View.VISIBLE
                        refreshView?.tag = false
                    } else if (findY > refreshViewHeight) {
                        //当滑动的距离超过刷新view的高度时  显示松开立即刷新 应该在此处记录tag
                        //当up时判断tag 开启刷新
                        refreshView?.visibility = View.VISIBLE
                        refreshView?.text = "松开立即刷新"
                        refreshView?.tag = true
                    }
                }
            } else if (targetView == TargetView.HEADER_VIEW && findY < 0) {//向content滑动  <0表示是向上滑动 此时y轴为负值
                translate(findY)
            }
        }
        MotionEvent.ACTION_UP -> {
            //只有当View是contentView时才需要处理下面的事件
            if (targetView == TargetView.CONTENT_VIEW) {
                //up的时候需要判断当前滑动的距离 如果超过滑动的阈值 显示内容View或者显示headerView
                //findY是手指从按下-滑动-抬起的总距离 因为headerView是全屏的,屏幕高度,所以通常这个阈值可能是屏幕的一半或其他值
                if (findY > (heightPixels / OPEN_THRESHOLD)) {
                    //显示headerView
                    targetView = TargetView.HEADER_VIEW
                    Log.d(TAG, "CONTENT_VIEW:已经移动超过一半了")
                    translateTime(0f)
                } else {
                    targetView = TargetView.CONTENT_VIEW
                    //显示contentView
                    //调用此处说明没有滑动超过设置的阈值 此处判断用户启用了下拉刷新
                    if (refreshListener !=null && refreshView?.tag == true) {
                        //满足开启下拉刷新的条件
                        Log.d(TAG, "满足开启下拉刷新的条件")
                        refreshView?.text = "玩命加载中..."
                        refreshListener?.refresh()
                    } else {
                        Log.d(TAG, "不满足开启下拉刷新的条件")
                        Log.d(TAG, "CONTENT_VIEW:还没有移动超过一半")
                        translateTime(-heightPixels.toFloat())
                    }
                }
            } else if (targetView == TargetView.HEADER_VIEW && findY < 0) {  //处理关闭header方向的滑动结束
                if (-findY > (heightPixels / CLOSE_THRESHOLD)) {
                    Log.d(TAG, "HEADER_VIEW:已经移动超过一半了")
                    targetView = TargetView.CONTENT_VIEW
                    //显示contentView
                    translateTime(-heightPixels.toFloat())
                } else {
                    Log.d(TAG, "HEADER_VIEW:还没有移动超过一半")
                    //显示headerView
                    targetView = TargetView.HEADER_VIEW
                    translateTime(0f)
                }
            }
        }
    }
    return super.onTouchEvent(event)
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    super.onLayout(changed, l, t, r, b)
    if (heightPixels == -3) { //设置Match-Parent会导致另一个View无法显示,而设置高度则可以 所以在测量后获取view的高度赋值
        headerView?.visibility = View.VISIBLE
        heightPixels = contentView!!.height

        val layoutParams = headerView!!.layoutParams
        layoutParams.height = heightPixels

        val layoutParams1 = contentView!!.layoutParams
        layoutParams1.height = heightPixels

        translate(-heightPixels.toFloat())

        Log.d(TAG, "onLayout-heightPixels:$heightPixels")
    }
}

/**
 * 无时间移动动画跟随移动
 * 0表示原点
 * 负数往上移动
 * 正数往下移动
 * 0回到原点
 *
 * 0表示原点
 *
 * 单参数时每次移动基于当前位置
 */
private fun translate(to: Float) {
    if (headerView != null && contentView != null) {
        val header = ObjectAnimator.ofFloat(headerView!!, "translationY", to)
        val content = ObjectAnimator.ofFloat(contentView!!, "translationY", to)
        val animatorSet = AnimatorSet()
        animatorSet.duration = 0
        //同时执行
        animatorSet.playTogether(header, content)
        animatorSet.start()
    }
}

/**
 * 有插值器效果的移动结束动画
 * 0表示原点
 * 负数往上移动
 * 正数往下移动
 * 0回到原点
 *
 * 0表示原点
 *
 * 单参数时每次移动基于当前位置
 */
private fun translateTime(to: Float) {
    if (headerView != null && contentView != null) {
        val header = ObjectAnimator.ofFloat(headerView!!, "translationY", to)
        val content = ObjectAnimator.ofFloat(contentView!!, "translationY", to)
        val animatorSet = AnimatorSet()
        val interpolator = BounceInterpolator()
        animatorSet.duration = 600
        //同时执行
        animatorSet.interpolator = interpolator
        animatorSet.playTogether(header, content)
        animatorSet.start()
    }
}

/**
 * @return Whether it is possible for the child view of this layout to
 * scroll up. Override this if the child view is a custom view.
 * 负数查询是否可以下拉 正数查询是否可以上拉
 */
private fun canChildScrollUp(direction: Int): Boolean {
    return recyclerView?.canScrollVertically(direction) ?: false
}

/**
 * 添加一个headerView
 */
fun addHeaderView(@NonNull view: View) {
    //添加一个headerView
    headerView?.addView(view)
}

/**
 * 添加一个contentView
 */
fun addContentView(@NonNull view: View) {
    if (view is RecyclerView) {
        recyclerView = view
    }
    //添加一个headerView
    contentView?.addView(view)
}

/**
 * 添加一个刷新view
 */
fun addRefreshView(@NonNull view: View) {
    headerView?.addView(view)
}

/**
 * 给headerView设置背景颜色 当然完全可以通过传入的全屏headerView自己设置颜色
 * 如果设置的颜色无效 则查看是否是传入的全屏headerView挡住了父的背景
 */
fun setHeaderViewBackgroundColor(@NonNull colorString: String) {
    headerView?.setBackgroundColor(Color.parseColor(colorString))
}

/**
 * 给contentView设置背景颜色
 */
fun setContentViewBackgroundColor(@NonNull colorString: String) {
    contentView?.setBackgroundColor(Color.parseColor(colorString))
}

/**
 * 当你的recyclerView不是顶级view时,你需要将你的recyclerView传递给我,便于我获取到滑动到顶点的状态
 *
 */
fun addRecyclerView(recyclerView: RecyclerView) {
    this.recyclerView = recyclerView
}

/**
 * 开启/关闭 下拉刷新
 *
 * 开启指的是开启下拉刷新的功能
 * 关闭指的是关闭正在刷新的view界面
 */
fun setRefresh(refresh: Boolean) {
    //当为false时需要 判断当前是否处于刷新状态 如果是则需要关闭刷新页面
    if (!refresh) {
        val tag = refreshView?.tag ?: false
        if (tag == true) {
            refreshView?.tag = false
            translateTime(-heightPixels.toFloat())
        }
    }
}

/**
 * 添加下拉刷新的监听
 */
fun addRefreshListener(listener: AddRefreshListener) {
    refreshListener = listener
}

interface AddRefreshListener {
    fun refresh()
}
}

参考资料:SwipeRefreshLayout

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值