查看原文
其他

SwipeRefreshLayout,用最少的代码定制最美的上下拉刷新样式

r17171709 终端研发部 2022-08-26


终端研发部

关注并回复“1024”,致力于Python/前端/FFmepg/java

了解相关更多技术,可参考看完不会写MVP架构我跪搓板,今天我们来了解一下内存泄漏的知识

正文


下拉刷新框架其实有很多,而且质量都比较高。但是在日常开发中,每一款产品都会有一套自己独特的一套刷新样式。相信有很多小伙伴在个性化定制中都或多或少的遇到过麻烦。今天我就给大家推荐一个在定制方面很出彩的一个刷新框架SwipeToLoadLayout,该框架自身完成了下拉刷新与上拉加载功能,同时将顶部视图与底部视图的UI定制功能通过接口很方便的提供给使用者自行定义。相关代码已经上传到github上,欢迎star、fork

基本流程

先简单了解一下SwipeToLoadLayout的使用流程,以下拉刷新为例:

  1. 完成Header部分,实现SwipeRefreshTrigger与SwipeRefreshTrigger接口

  2. 完成activity或fragment的布局,在SwipeToLoadLayout节点下配置好Header与下拉目标组件(如RecyclerView等)

这里还是要稍微说一下,因为这个布局过程还是有一定的规则的
首先布局的id是固定的,这个我们在ids.xml中就能看出。框架提供三个View:Header、Target、Footer,分别对应三个位置的View

<?xml version="1.0" encoding="utf-8"?>
<resources>
   <item name="swipe_target" type="id" />
   <item name="swipe_refresh_header" type="id" />
   <item name="swipe_load_more_footer" type="id" />
</resources>

其次onFinishInflate()方法告诉我们,最多只能同时存在这三个View,不能有更多的子View了

@Override
   protected void onFinishInflate() {
       super.onFinishInflate();
       final int childNum = getChildCount();
       if (childNum == 0) {
           // no child return
           return;
       } else if (0 < childNum && childNum < 4) {
           mHeaderView = findViewById(R.id.swipe_refresh_header);
           mTargetView = findViewById(R.id.swipe_target);
           mFooterView = findViewById(R.id.swipe_load_more_footer);
       } else {
           // more than three children: unsupported!
           throw new IllegalStateException("Children num must equal or less than 3");
       }
       if (mTargetView == null) {
           return;
       }
       if (mHeaderView != null && mHeaderView instanceof SwipeTrigger) {
           mHeaderView.setVisibility(GONE);
       }
       if (mFooterView != null && mFooterView instanceof SwipeTrigger) {
           mFooterView.setVisibility(GONE);
       }
   }

这样你就能得出下一步该怎么来实现了吧?没错肯定是这样的

Header的部分尤为重要。我们需在Header上实现SwipeTrigger与SwipeRefreshTrigger接口,接口中的方法分别对应滑动刷新在各个状态下的回调。它们分别为
onPrepare:代表下拉刷新开始的状态
onMove:代表正在滑动过程中的状态
onRelease:代表手指松开后,下拉刷新进入松开刷新的状态
onComplete:代表下拉刷新完成的状态
onReset:代表下拉刷新重置恢复的状态
onRefresh:代表正在刷新中的状态


有了这几个接口,我们就可以完成Header部分的任何动画效果了。当然上拉加载更多的场景,只是把SwipeRefreshTrigger接口换成

SwipeLoadMoreTrigger接口而已,其他跟下拉刷新情况完全相同

  1. 在activity或fragment中配置下拉监听事件,并在数据获取完成后主动触发刷新swipeToLoadLayout.setRefreshing(false);完成功能

更深入的部分我们放到源码分析里面再说

看起来好像很简单,那么我们就通过几个小Demo了解一下如何使用吧

仿新浪微博

之所以第一个范例选择新浪微博,是因为它是最传统刷新风格:根据箭头和文字的不同来表明当前不同的状态

如果你在早期研究过PullToRefresh,那么很容易在这个框架基础上实现相应的视图更新功能

先完成头部的定义。WeiboRefreshHeaderView作为头,其实际为一个LinearLayout

class WeiboRefreshHeaderView : LinearLayout, SwipeTrigger, SwipeRefreshTrigger

头部布局很简单

activity的布局也很简单,把头跟身子一起加在SwipeToLoadLayout里

<?xml version="1.0" encoding="utf-8"?>
<com.aspsine.swipetoloadlayout.SwipeToLoadLayout
   xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:id="@+id/swipe_weibo">


   <include
       layout="@layout/header_weibo"
       android:id="@id/swipe_refresh_header" />

   <TextView
       android:id="@id/swipe_target"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:gravity="center"
       android:text="下拉刷新"/>

</com.aspsine.swipetoloadlayout.SwipeToLoadLayout>

下面就是完成头部动画效果了。新浪微博的这个效果就是视图被下拉到头部高度之后,将箭头位置旋转一下同时更换文字,刷新时展现progressbar即可

class WeiboRefreshHeaderView : LinearLayout, SwipeTrigger, SwipeRefreshTrigger {

   var pb_weibo: ProgressBar? = null
   var iv_weibo: ImageView? = null
   var tv_weibo: TextView? = null

   // 是否发生旋转
   var rotated = false

   private val rotate_up: Animation by lazy {
       AnimationUtils.loadAnimation(context, R.anim.rotate_up)
   }

   private val rotate_down: Animation by lazy {
       AnimationUtils.loadAnimation(context, R.anim.rotate_down)
   }

   constructor(context: Context) : super(context)
   constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
   constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr)

   override fun onFinishInflate() {
       super.onFinishInflate()

       pb_weibo = findViewById(R.id.pb_weibo)
       iv_weibo = findViewById(R.id.iv_weibo)
       tv_weibo = findViewById(R.id.tv_weibo)
   }

   override fun onReset() {
       pb_weibo?.visibility = View.GONE
       iv_weibo?.visibility = View.VISIBLE
       tv_weibo?.text = "下拉刷新"
   }

   override fun onComplete() {
       tv_weibo?.text = "刷新完成"
       pb_weibo?.visibility = View.GONE
   }

   override fun onRelease() {

   }

   override fun onMove(p0: Int, p1: Boolean, p2: Boolean) {
       if (p0 > SizeUtils.dp2px(60f)) {
           if (!rotated) {
               rotated = true
               tv_weibo?.text = "释放更新"
               iv_weibo?.clearAnimation()
               iv_weibo?.startAnimation(rotate_up)
           }
       }
       else {
           if (rotated) {
               rotated = false
               tv_weibo?.text = "下拉刷新"
               iv_weibo?.clearAnimation()
               iv_weibo?.startAnimation(rotate_down)
           }
       }
   }

   override fun onPrepare() {

   }

   override fun onRefresh() {
       tv_weibo?.text = "加载中"
       iv_weibo?.clearAnimation()
       iv_weibo?.visibility = View.GONE
       pb_weibo?.visibility = View.VISIBLE
   }
}

对照一下上文的刷新周期,应该很好理解

美团外卖

美团外卖是利用ImageView直接播放一段animation直到刷新完成停止。在下拉过程中,该ImageView随着位移的距离变化而发生相应的大小变化

美团外卖动画效果是由一系列的图片组成的,所以与新浪微博效果相比更为简单一些

一样要完成头部视图的定义

剩下就是完成动画的播放与缩放的处理了

class MTRefreshHeaderView : LinearLayout, SwipeTrigger, SwipeRefreshTrigger {

   var iv_mt: ImageView? = null

   val animationDrawable: AnimationDrawable by lazy {
       iv_mt?.background as AnimationDrawable
   }

   constructor(context: Context) : super(context)
   constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
   constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr)

   override fun onFinishInflate() {
       super.onFinishInflate()

       iv_mt = findViewById(R.id.iv_mt)
   }

   override fun onReset() {

   }

   override fun onComplete() {
       animationDrawable.stop()
   }

   override fun onRelease() {

   }

   override fun onMove(p0: Int, p1: Boolean, p2: Boolean) {
       val percent = if (p0 * 1.0f / SizeUtils.dp2px(44f) > 1) 1f else p0 * 1.0f / SizeUtils.dp2px(44f)

       iv_mt?.scaleY = (0.3f + 0.7 * percent).toFloat()
       iv_mt?.scaleX = (0.3f + 0.7 * percent).toFloat()
   }

   override fun onPrepare() {
       if (!animationDrawable.isRunning) {
           animationDrawable.start()
       }

       iv_mt?.scaleY = 0.3f
       iv_mt?.scaleX = 0.3f
   }

   override fun onRefresh() {
       if (!animationDrawable.isRunning) {
           animationDrawable.start()
       }

       iv_mt?.scaleY = 1f
       iv_mt?.scaleX = 1f
   }
}

代码都很简单,很容易理解

饿了么

饿了么的效果是通过SVG来实现的

饿了么app对资源进行了混淆,所以我拿不到图片,只能随便从其他地方找一个了

一样是Header的编写,这里面有一点不同,我用android-pathview这个开源框架实现SVG播放进度控制功能

我需要将这个动画效果在下拉刷新的过程中实现

下面就是根据滑动偏移量来处理SVG播放的进度

这里你会发出一个疑问,怎么效果与饿了么有的差距?饿了么是滑动到Header完成展开之后就不再继续下滑了,那咱们这个怎么实现呢?那我只能说不好意思,在现有条件下咱们实现不了,只能通过改源码完成

那我们就顺带来阅读源码,看看这个地方怎么改进吧?

源码分析

之前的onFinishInflate咱们就不说了,那个就是告诉我们只能有三个View,分别是Header、Target、Footer

然后是测量阶段,在测量阶段可以得到两个重要的变量mHeaderHeight与mFooterHeight,他们分别代表Header与Footer的高度。同时如果定义的mRefreshTriggerOffset(松开刷新的高度)比Header或Footer的高度小,则修正这个刷新位置

@Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       // header
       if (mHeaderView != null) {
           final View headerView = mHeaderView;
           measureChildWithMargins(headerView, widthMeasureSpec, 0, heightMeasureSpec, 0);
           MarginLayoutParams lp = ((MarginLayoutParams) headerView.getLayoutParams());
           mHeaderHeight = headerView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
           if (mRefreshTriggerOffset < mHeaderHeight) {
               mRefreshTriggerOffset = mHeaderHeight;
           }
       }
       // target
       if (mTargetView != null) {
           final View targetView = mTargetView;
           measureChildWithMargins(targetView, widthMeasureSpec, 0, heightMeasureSpec, 0);
       }
       // footer
       if (mFooterView != null) {
           final View footerView = mFooterView;
           measureChildWithMargins(footerView, widthMeasureSpec, 0, heightMeasureSpec, 0);
           MarginLayoutParams lp = ((MarginLayoutParams) footerView.getLayoutParams());
           mFooterHeight = footerView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
           if (mLoadMoreTriggerOffset < mFooterHeight) {
               mLoadMoreTriggerOffset = mFooterHeight;
           }
       }
   }

在onLayout中对三个视图进行布局

@Override
   protected void onLayout(boolean changed, int l, int t, int r, int b) {
       layoutChildren();

       mHasHeaderView = (mHeaderView != null);
       mHasFooterView = (mFooterView != null);
   }

这里有一个重要的方法layoutChildren,这个方法就是改变三个视图的位置的。当然这个位置要根据不同的类型来处理,默认情况下我们都是STYLE.CLASSIC类型。

private void layoutChildren() {

      ```

       // layout header
       if (mHeaderView != null) {
           final View headerView = mHeaderView;
           MarginLayoutParams lp = (MarginLayoutParams) headerView.getLayoutParams();
           final int headerLeft = paddingLeft + lp.leftMargin;
           final int headerTop;
           switch (mSty`le)` {
               case STYLE.CLASSIC:
                   // classic
                   headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset;
                   break;
               case STYLE.ABOVE:
                   // classic
                   headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset;
                   break;
               case STYLE.BLEW:
                   // blew
                   headerTop = paddingTop + lp.topMargin;
                   break;
               case STYLE.SCALE:
                   // scale
                   headerTop = paddingTop + lp.topMargin - mHeaderHeight / 2 + mHeaderOffset / 2;
                   break;
               case STYLE.BLEW2CLASSIC:
                   // blew2classic
                   if (mHeaderOffset > mHeaderHeight) {
                       headerTop = paddingTop + lp.topMargin;
                   }
                   else {
                       headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset;
                   }
                   break;
               default:
                   // classic
                   headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset;
                   break;
           }
           final int headerRight = headerLeft + headerView.getMeasuredWidth();
           final int headerBottom = headerTop + headerView.getMeasuredHeight();
           headerView.layout(headerLeft, headerTop, headerRight, headerBottom);
       }

       // layout target
       if (mTargetView != null) {
           final View targetView = mTargetView;
           MarginLayoutParams lp = (MarginLayoutParams) targetView.getLayoutParams();
           final int targetLeft = paddingLeft + lp.leftMargin;
           final int targetTop;

           switch (mStyle) {
               case STYLE.CLASSIC:
                   // classic
                   targetTop = paddingTop + lp.topMargin + mTargetOffset;
                   break;
               case STYLE.ABOVE:
                   // above
                   targetTop = paddingTop + lp.topMargin;
                   break;
               case STYLE.BLEW:
                   // classic
                   targetTop = paddingTop + lp.topMargin + mTargetOffset;
                   break;
               case STYLE.SCALE:
                   // classic
                   targetTop = paddingTop + lp.topMargin + mTargetOffset;
                   break;
               case STYLE.BLEW2CLASSIC:
                   // classic
                   targetTop = paddingTop + lp.topMargin + mTargetOffset;
                   break;
               default:
                   // classic
                   targetTop = paddingTop + lp.topMargin + mTargetOffset;
                   break;
           }
           final int targetRight = targetLeft + targetView.getMeasuredWidth();
           final int targetBottom = targetTop + targetView.getMeasuredHeight();
           targetView.layout(targetLeft, targetTop, targetRight, targetBottom);
       }

       // layout footer
       if (mFooterView != null) {
           final View footerView = mFooterView;
           MarginLayoutParams lp = (MarginLayoutParams) footerView.getLayoutParams();
           final int footerLeft = paddingLeft + lp.leftMargin;
           final int footerBottom;
           switch (mStyle) {
               case STYLE.CLASSIC:
                   // classic
                   footerBottom = height - paddingBottom - lp.bottomMargin + mFooterHeight + mFooterOffset;
                   break;
               case STYLE.ABOVE:
                   // classic
                   footerBottom = height - paddingBottom - lp.bottomMargin + mFooterHeight + mFooterOffset;
                   break;
               case STYLE.BLEW:
                   // blew
                   footerBottom = height - paddingBottom - lp.bottomMargin;
                   break;
               case STYLE.SCALE:
                   // scale
                   footerBottom = height - paddingBottom - lp.bottomMargin + mFooterHeight / 2 + mFooterOffset / 2;
                   break;
               case STYLE.BLEW2CLASSIC:
                   // blew2classic
                   if (mFooterOffset > mFooterHeight) {
                       footerBottom = height - paddingBottom - lp.bottomMargin;
                   }
                   else {
                       footerBottom = height - paddingBottom - lp.bottomMargin + mFooterHeight + mFooterOffset;
                   }
                   break;
               default:
                   // classic
                   footerBottom = height - paddingBottom - lp.bottomMargin + mFooterHeight + mFooterOffset;
                   break;
           }
           final int footerTop = footerBottom - footerView.getMeasuredHeight();
           final int footerRight = footerLeft + footerView.getMeasuredWidth();

           footerView.layout(footerLeft, footerTop, footerRight, footerBottom);
       }

       if (mStyle == STYLE.CLASSIC
               || mStyle == STYLE.ABOVE) {
           if (mHeaderView != null) {
               mHeaderView.bringToFront();
           }
           if (mFooterView != null) {
               mFooterView.bringToFront();
           }
       } else if (mStyle == STYLE.BLEW || mStyle == STYLE.SCALE || mStyle == STYLE.BLEW2CLASSIC) {
           if (mTargetView != null) {
               mTargetView.bringToFront();
           }
       }
   }

以下拉刷新为例,看这行代码。
paddingTop与lp.topMargin都是0,mHeaderHeight是Header的高度,mHeaderOffset就是手指滑动的距离(这个稍后会有说明)。在下拉过程中,mHeaderOffset的值会越来越大,所以headerTop的值是从-mHeaderHeight开始逐渐增大的,所以headerView会向下逐步移动

headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset

而Target更为简单,你手指滑动多少它就跟着滑动多少

targetTop = paddingTop + lp.topMargin + mTargetOffset;

这样能够想象出饿了么滑动到mHeaderHeight高度之后如何处理的吧,请参考我自己定义的style—BLEW2CLASSIC

if (mHeaderOffset > mHeaderHeight) {
   headerTop = paddingTop + lp.topMargin;
}
else {
   headerTop = paddingTop + lp.topMargin - mHeaderHeight + mHeaderOffset;
}

继续往下来到事件分发部分了。由于篇幅过长。源码分析请参考原文链接。

项目地址:https://github.com/r17171709/android_demo/

阅读更多

2018Android面试经历

带你一起探究Android事件分发机制, 让面试提问不在畏惧!

40岁请离开,IBM美国五年悄悄赶走两万“老”员工

手把手教你React Native 实战之开山篇《一》

相信自己,没有做不到的,只有想不到的

在这里获得的不仅仅是技术!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存