Kotlin之下拉刷新与上拉加载控件

引言


自从RecyclerView出现后ListView便渐渐退出了舞台的中央,所有ListView能够做到的RecyclerView都会做的更加优秀。今天就来讲讲
RecyclerView比ListView逊色的地方。

用过两者的人会知道RecyclerView没有header和footer,这是因为RecyclerView本身并不需要特别指明header和footer,他有ViewType这一特性完美的弯道超车ListView。

如果需要下拉刷新,官方也有MD组合套件中的SwipeRefreshLayout。

但如果是一些酷炫的下拉刷新动画,根据用户下拉的程度动态的展示动画,单在这一点上ListView就方便很多。

ListView可以根据用户下拉的距离换算成变化比例,动态的更新header或footer的高度再加以动画效果可以产生令人舒爽的弹性效果和趣味的动画。

今天要来讲的下拉刷新与上拉加载控件就是为了给RecyclerView增加此种效果。

效果图

正文


实现方案并没有采用网上主流的通过修改Adapter,增加ViewType来达到效果,原因有

1、由于kotlin需要严格声明变量类型(ViewHolder的问题),导致通过使用代理的设计模式(这种设计模式可以让使用者不需要关心header和footer的这两种ViewType,完全交由代理去完成。极大的保证了Adapter的编码原生性)设计Adapter困难重重或是说束手束脚。

2、个人原因,本就是学习项目,借此也想通过设计此控件复习下事件的分发机制和动画相关的内容。

设计思路

LinearLayout包裹HeaderView、RecyclerView、FooterView

处理LinearLayout的dispatchTouchEvent,如果触发了相关操作则截获事件,否则放行。

知识点

1、判断RecyclerView的位置

方法一:通过RecyclerView的layoutManager获取可见的第一个ItemView在整体中的位置,如果是第一个再判读其距离父view顶部的距离。

    private fun isScrollToTop(): Boolean {
        val layoutManager = recyclerView.layoutManager as LinearLayoutManager
        val position = layoutManager.findFirstVisibleItemPosition()
        if (position != 0) {
            return false
        }
        val firstVisiableChildView = layoutManager.findViewByPosition(position)
        return firstVisiableChildView.top == 0
    }

方法二:利用RecyclerView的高度、RecyclerView滚动的距离、当前显示RecyclerView的高度,三个数据进行判断。
View本身也有判断方法canScrollVertically,原理相同。(这方面内容是从网上学习到的,但由于找不到我所看的原文作者了所以没贴,因为后来去找作者时发现了好几个人写了类似的内容,实在分不清最先看到的是谁了)

fun isScrollToBottom(): Boolean {
        return recyclerView.computeVerticalScrollExtent() + recyclerView.computeVerticalScrollOffset() >= recyclerView.computeVerticalScrollRange()
    }

2、Activity、ViewGroup、View的事件分发机制,以及事件分发的几个方法,提一下而已不做详细展开
dispatchTouchEvent
onInterceptTouchEvent
onTouchEvent

Code

有了以上的知识,下面的代码就好理解了

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        if (isScrollToTop()) {

            Logger.i("RefreshRecyclerView", "ScrollToTop")
            when (ev.action) {

                MotionEvent.ACTION_DOWN -> {
                    startY = ev.y
                }

                MotionEvent.ACTION_MOVE -> {
                    if (currentState != STATE_REFRESHING) {
                        if (ev.y - startY > 0) {
                            changeState(STATE_PULLING)
                            headerView.setVisibleHeight(ev.y - startY)
                            return true
                        }
                        changeState(STATE_NORMAL)
                    }
                }

                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    if (currentState == STATE_PULLING) {
                        var toState = STATE_NORMAL
                        if (headerView.isEnoughtToRefresh()) {
                            toState = STATE_REFRESHING
                        }
                        changeState(toState)
                    }
                }

            }
        }

        if (isScrollToBottom()) {
            //此时底部没有动画,日后扩展
            Logger.i("RefreshRecyclerView", "ScrollToBottom")
            changeState(STATE_LOADING)

        }

        return super.dispatchTouchEvent(ev)
    }

为了使header规范我用了以下方法

/**
 * Created by mr.lin on 2018/1/16.
 * RefreshRecyclerView统一header的父类
 */
abstract class HeaderView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : FrameLayout(context, attrs, defStyleAttr) {

    abstract fun setVisibleHeight(height: Float)

    abstract fun isEnoughtToRefresh(): Boolean

    abstract fun startRefresh()

    abstract fun endRefresh()

    abstract fun cancelRefresh()

}

所有的headerView需要实现此抽象类,这样更换header也会比较方便。

动画

只讲简单的和我遇到的问题

最初我使用了Animation,由于多动画之间有间隔时间,于是采用了view的postDelayed()方法,导致动画乱七八糟,无法cancel。这才想到了Set,但是用AnimationSet还是AnimatorSet。

Animation和Animator
Animator是4.0之后才有的,与Animation不同的是Animator是通过改变属性从而产生动画,而Animation则是多次绘制。性能上Animator会占优,详细内容可以查看这里。

AnimationSet和AnimatorSet
AnimationSet真的就只是一个集合,内部成员可以设置是否公用AnimationSet的属性。
AnimatorSet则不同,它可以控制内部Animator的播放顺序和其他操作。

我可能讲的不是很清楚,大家可以查阅下相关资料。我这里做个Animation和Animator的总结:

Animation翻译成中文为动画,Animator翻译成中文为动画者

只可意会,不可言传。

/**
 * Created by mr.lin on 2018/1/15.
 * 默认HeaderView
 */
class DefaultHeaderView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : HeaderView(context, attrs, defStyleAttr) {

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

    private var headerHeight = CommonUtils.dpTopx(50f)

    private lateinit var rotateAnimator1: ObjectAnimator
    private lateinit var rotateAnimator2: ObjectAnimator
    private lateinit var rotateAnimator3: ObjectAnimator
    private lateinit var rotateAnimator4: ObjectAnimator
    private lateinit var animatorSet: AnimatorSet

    private var valueAnimator: ValueAnimator = ValueAnimator()

    init {
        LayoutInflater.from(context).inflate(R.layout.view_defaultheaderview, this)

        var params = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0)
        params.gravity = Gravity.CENTER
        layoutParams = params
    }

    override fun setVisibleHeight(height: Float) {
        var params = layoutParams
        params.height = height.toInt()
        layoutParams = params
    }

    override fun isEnoughtToRefresh(): Boolean {
        var currentHeight = layoutParams.height
        return currentHeight >= headerHeight / 2
    }

    override fun startRefresh() {
        changeHeight(layoutParams.height, headerHeight, {}, { startRotate() })
    }

    override fun endRefresh() {
        changeHeight(layoutParams.height, 0, { stopRotate() }, {})
    }

    override fun cancelRefresh() {
        changeHeight(layoutParams.height, 0, {}, {})
    }

    private fun changeHeight(currentHeight: Int, target: Int, start: () -> Unit, end: () -> Unit) {
        if (valueAnimator.isRunning) {
            valueAnimator.cancel()
        }
        valueAnimator = ValueAnimator.ofInt(currentHeight, target)
        valueAnimator.duration = 500
        valueAnimator.addUpdateListener {
            var params = layoutParams
            params.height = valueAnimator.animatedValue as Int
            layoutParams = params
        }
        valueAnimator.addListener(object : Animator.AnimatorListener {
            override fun onAnimationRepeat(animation: Animator?) {
            }

            override fun onAnimationCancel(animation: Animator?) {
            }

            override fun onAnimationStart(animation: Animator?) {
                start()
            }

            override fun onAnimationEnd(animation: Animator?) {
                end()
            }
        })
        valueAnimator.start()
    }

    private fun startRotate() {
        initRotate()
        animatorSet.start()
    }

    private fun initRotate() {
        rotateAnimator1 = ObjectAnimator.ofFloat(iv1, "rotation", 0f, 360f).setDuration(1000)
        rotateAnimator1.repeatCount = INFINITE
        rotateAnimator2 = ObjectAnimator.ofFloat(iv2, "rotation", 0f, 360f).setDuration(1000)
        rotateAnimator2.repeatCount = INFINITE
        rotateAnimator3 = ObjectAnimator.ofFloat(iv3, "rotation", 0f, 360f).setDuration(1000)
        rotateAnimator3.repeatCount = INFINITE
        rotateAnimator4 = ObjectAnimator.ofFloat(iv4, "rotation", 0f, 360f).setDuration(1000)
        rotateAnimator4.repeatCount = INFINITE
        animatorSet = AnimatorSet()
        animatorSet.play(rotateAnimator1)
        animatorSet.play(rotateAnimator4).after(200)
        animatorSet.play(rotateAnimator3).after(400)
        animatorSet.play(rotateAnimator2).after(600)
    }

    private fun stopRotate() {
        animatorSet.end()
        animatorSet.cancel()
    }

}

结束语

不知道有没有更好的实现方案,总感觉性能还能够再优化,奈何实力不足,还请大家多多赐教

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容