Android开发之无痕过渡下拉刷新控件的实现思路详解
相信大家已经对下拉刷新熟悉得不能再熟悉了,市面上的下拉刷新琳琅满目,然而有很多在我看来略有缺陷,接下来我将说明一下存在的缺陷问题,然后提供一种思路来解决这一缺陷,废话不多说!往下看嘞!
1.市面一些下拉刷新控件普遍缺陷演示
以直播吧app为例:
第1种情况:
滑动控件在初始的0位置时,手势往下滑动然后再往上滑动,可以看到滑动到初始位置时滑动控件不能滑动。
原因:
下拉刷新控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被下拉刷新控件消费掉了,传递不到它的子控件即滑动控件,因此滑动控件不能滑动。
第2种情况:
滑动控件滑动到某个非0位置时,这时下拉回0位置时,可以看到下拉刷新头部没有被拉出来。
原因:
滑动控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被滑动控件消费掉了,父控件即下拉刷新控件消费不了滑动事件,因此下拉刷新头部没有被拉出来。
可能大部分人觉得无关痛痒,把手指抬起再下拉就可以了,but对于强迫症的我而言,能提供一个无痕过渡才是最符合操作逻辑的,因此接下来我来讲解下实现的思路。
2.实现的思路讲解
2.1.事件分发机制简介(来源于android开发艺术探索)
dispatchtouchevent、onintercepttouchevent和ontouchevent方法的关系伪代码
public boolean dispatchtouchevent(motionevent ev) { boolean consume = false; if(onintercepttouchevent(ev)) { consume = ontouchevent(ev); } else { consume = child.dispatchtouchevent(ev); } return consume; }
1.由代码可知若当前view拦截事件,就交给自己的ontouchevent去处理,否则就丢给子view继续走相同的流程。
2.事件传递顺序:activity -> window -> view,如果view都不处理,最终将由activity的ontouchevent
处理,是一种责任链模式的实现。
3.正常情况,一个事件序列只能被一个view拦截且消耗。
4.某个view一旦决定拦截,这一个事件序列只能由它处理,并且它的onintercepttouchevent不会再被调用
5.不消耗action_down,则事件序列都会由其父元素处理。
2.2.一般下拉刷新的实现思路猜想
首先,下拉刷新控件作为一个容器,需要重写onintercepttouchevent和ontouchevent这两个方法,然后在onintercepttouchevent中判断action_down事件,根据子控件的滑动距离做出判断,若还没滑动过,则onintercepttouchevent返回true表示其拦截事件,然后在ontouchevent中进行下拉刷新的头部显示隐藏的逻辑处理;若子控件滑动过了,不拦截事件,onintercepttouchevent返回false,后续其下拉刷新的头部显示隐藏的逻辑处理就无法被调用了。
2.3.无痕过渡下拉刷新控件的实现思路
从2.2中可以看出,要想无痕过渡,下拉刷新控件不能拦截事件,这时候你可能会问,既然把事件给了子控件,后续拉刷新头部逻辑怎么实现呢?
这时候就要用到一般都忽略的事件分发方法dispatchtouchevent了,此方法在viewgroup默认返回true表示分发事件,即使子控件拦截了事件,父布局的dispatchtouchevent仍然会被调用,因为事件是传递下来的,这个方法必定被调用。
所以我们可以在dispatchtouchevent时对子控件的滑动距离做出判断,在这里把下拉刷新的头部的逻辑处理掉,同时在函数调用return super.dispatchtouchevent(event) 前把event的action设置为action_cancel,这样子子控件就不会响应滑动的操作。
3.代码实现
3.1.确定需求
需要适配任意控件,例如recyclerview、listview、viewpager、webview以及普通的不能滑动的view
不能影响子控件原来的事件逻辑
暴露方法提供手动调用刷新功能
可以设置禁止下拉刷新功能
3.2.代码讲解
需要的变量
public class refreshlayout extends linearlayout { // 隐藏的状态 private static final int hide = 0; // 下拉刷新的状态 private static final int pull_to_refresh = 1; // 松开刷新的状态 private static final int release_to_refresh = 2; // 正在刷新的状态 private static final int refreshing = 3; // 正在隐藏的状态 private static final int hiding = 4; // 当前状态 private int mcurrentstate = hide; // 头部动画的默认时间(单位:毫秒) public static final int default_duration = 200; // 头部高度 private int mheaderheight; // 内容控件的滑动距离 private int mcontentviewoffset; // 记录上次的y坐标 private int mlasty; // 最小滑动响应距离 private int mscaledtouchslop; // 滑动的偏移量 private int mtotaldeltay; // 是否在处理头部 private boolean misheaderhandling; // 是否可以下拉刷新 private boolean misrefreshable = true; // 内容控件是否可以滑动,不能滑动的控件会做触摸事件的优化 private boolean mcontentviewscrollable = true; // 头部,为了方便演示选取了textview private textview mheader; // 容器要承载的内容控件,在xml里面要放置好 private view mcontentview; // 值动画,由于头部显示隐藏 private valueanimator mheaderanimator; // 刷新的监听器 private onrefreshlistener monrefreshlistener;
初始化时创建头部执行显示隐藏的值动画,添加头部到布局中,并且通过设置paddingtop隐藏头部
public refreshlayout(context context, attributeset attrs, int defstyleattr) { super(context, attrs, defstyleattr); init(); addheader(context); } private void init() { mscaledtouchslop = viewconfiguration.get(getcontext()).getscaledtouchslop(); mheaderanimator = valueanimator.ofint(0).setduration(default_duration); mheaderanimator.addupdatelistener(new valueanimator.animatorupdatelistener() { @override public void onanimationupdate(valueanimator valueanimator) { if (getcontext() == null) { // 若是退出activity了,动画结束不必执行头部动作 return; } // 通过设置paddingtop实现显示或者隐藏头部 int offset = (integer) valueanimator.getanimatedvalue(); mheader.setpadding(0, offset, 0, 0); } }); mheaderanimator.addlistener(new animatorlisteneradapter() { @override public void onanimationend(animator animation) { if (getcontext() == null) { // 若是退出activity了,动画结束不必执行头部动作 return; } if (mcurrentstate == release_to_refresh) { // 释放刷新状态执行的动画结束,意味接下来就是刷新了,改状态并且调用刷新的监听 mheader.settext("正在刷新..."); mcurrentstate = refreshing; if (monrefreshlistener != null) { monrefreshlistener.onrefresh(); } } else if (mcurrentstate == hiding) { // 下拉状态执行的动画结束,隐藏头部,改状态 mheader.settext("我是头部"); mcurrentstate = hide; } } }); } // 头部的创建 private void addheader(context context) { // 强制垂直方法 setorientation(linearlayout.vertical); mheader = new textview(context); mheader.setbackgroundcolor(color.gray); mheader.settextcolor(color.white); mheader.settext("我是头部"); mheader.settextsize(typedvalue.complex_unit_sp, 25); mheader.setgravity(gravity.center); addview(mheader, layoutparams.match_parent, layoutparams.wrap_content); mheader.getviewtreeobserver().addongloballayoutlistener(new viewtreeobserver.ongloballayoutlistener() { @override public void ongloballayout() { // 算出头部高度 mheaderheight = mheader.getmeasuredheight(); // 移除监听 if (build.version.sdk_int >= build.version_codes.jelly_bean) { mheader.getviewtreeobserver().removeongloballayoutlistener(this); } else { mheader.getviewtreeobserver().removeglobalonlayoutlistener(this); } // 设置paddingtop为-mheaderheight,刚好把头部隐藏掉了 mheader.setpadding(0, -mheaderheight, 0, 0); } }); }
在填充完布局后取出内容控件
@override protected void onfinishinflate() { super.onfinishinflate(); // 设置长点击或者短点击都能消耗事件,要不这样做,若孩子都不消耗,最终点击事件会被它的上级消耗掉,后面一系列的事件都只给它的上级处理了 setlongclickable(true); // 获取内容控件 mcontentview = getchildat(1); if (mcontentview == null) { // 为空抛异常,强制要求在xml设置内容控件 throw new illegalargumentexception("you must add a content view!"); } if (!(mcontentview instanceof scrollingview || mcontentview instanceof webview || mcontentview instanceof scrollview || mcontentview instanceof abslistview)) { // 不是具有滚动的控件,这里设置标志位 mcontentviewscrollable = false; } }
重头戏来了,分发对于下拉刷新的特殊处理:
1.mcontentviewoffset用于判别内容页的滑动距离,在无偏移值时才去处理下拉刷新的操作;
2.在mcontentviewoffset!=0即内容页滑动的第一个瞬间,强制把move事件改为down,是因为之前move都被拦截掉了,若不给个down让内容页重新定下滑动起点,会有一瞬间滑动一大段距离的坑爹效果。
@override public boolean dispatchtouchevent(final motionevent event) { if (!misrefreshable) { // 禁止下拉刷新,直接把事件分发 return super.dispatchtouchevent(event); } if ((mcurrentstate == refreshing || mcurrentstate == release_to_refresh || mcurrentstate == hiding) && mheaderanimator.isrunning()) { // 正在刷新,正在释放,正在隐藏头部都不处理事件,并且不分发下去 return true; } int y = (int) event.gety(); switch (event.getaction()) { case motionevent.action_down: break; case motionevent.action_move: { int deltay = y - mlasty; if (mcontentviewoffset == 0 && (deltay > 0 || (deltay < 0 && isheadershowing()))) { // 偏移值为0时,下拉或者在头部还在显示的时候上滑时,交由自己处理滑动事件 mtotaldeltay += deltay; if (mtotaldeltay > 0 && mtotaldeltay <= mscaledtouchslop && !isheadershowing()) { // 优化下拉头部,不要稍微一点位移就响应 mlasty = y; return super.dispatchtouchevent(event); } // 处理事件 onhandletouchevent(event); // 正在处理事件 misheaderhandling = true; if (mcurrentstate == refreshing) { // 正在刷新,不让contentview响应滑动 event.setaction(motionevent.action_cancel); } } else if (misheaderhandling) { // 在头部隐藏的那一瞬间的事件特殊处理 if (mcontentviewscrollable) { // 1.可滑动的view,由于之前处理头部,之前的move事件没有传递到内容页,这里 // 需要要action_down来重新告知滑动的起点,不然会瞬间滑动一段距离 // 2.对于不滑动的view设置了点击事件,若这里给它一个action_down事件,在手指 // 抬起时action_up事件会触发点击,因此这里做了处理 event.setaction(motionevent.action_down); } misheaderhandling = false; } break; } case motionevent.action_cancel: case motionevent.action_up: { if (mcontentviewoffset == 0 && isheadershowing()) { // 处理手指抬起或取消事件 onhandletouchevent(event); } mtotaldeltay = 0; break; } default: break; } mlasty = y; if (mcurrentstate != refreshing && isheadershowing() && event.getaction() != motionevent.action_up) { // 不是在刷新的时候,并且头部在显示, 不让contentview响应事件 event.setaction(motionevent.action_cancel); } return super.dispatchtouchevent(event); }
处理事件的逻辑:拿到下拉偏移量,然后动态去设置头部的paddingtop值,即可实现显示隐藏;手指抬起时根据状态决定是显示刷新还是直接隐藏头部
// 自己处理事件 public boolean onhandletouchevent(motionevent event) { int y = (int) event.gety(); switch (event.getaction()) { case motionevent.action_move: { // 拿到y方向位移 int deltay = y - mlasty; // 除以3相当于阻尼值 deltay /= 3; // 计算出移动后的头部位置 int top = deltay + mheader.getpaddingtop(); // 控制头部位置最大不超过-mheaderheight if (top < -mheaderheight) { mheader.setpadding(0, -mheaderheight, 0, 0); } else { mheader.setpadding(0, top, 0, 0); } if (mcurrentstate == refreshing) { // 之前还在刷新状态,继续维持刷新状态 mheader.settext("正在刷新..."); break; } if (mheader.getpaddingtop() > mheaderheight / 2) { // 大于mheaderheight / 2时可以刷新了 mheader.settext("可以释放刷新..."); mcurrentstate = release_to_refresh; } else { // 下拉状态 mheader.settext("正在下拉..."); mcurrentstate = pull_to_refresh; } break; } case motionevent.action_up: { if (mcurrentstate == release_to_refresh) { // 释放刷新状态,手指抬起,通过动画实现头部回到(0,0)位置 mheaderanimator.setintvalues(mheader.getpaddingtop(), 0); mheaderanimator.setduration(default_duration); mheaderanimator.start(); mheader.settext("正在释放..."); } else if (mcurrentstate == pull_to_refresh || mcurrentstate == refreshing) { // 下拉状态或者正在刷新状态,通过动画隐藏头部 mheaderanimator.setintvalues(mheader.getpaddingtop(), -mheaderheight); if (mheader.getpaddingtop() <= 0) { mheaderanimator.setduration((long) (default_duration * 1.0 / mheaderheight * (mheader.getpaddingtop() + mheaderheight))); } else { mheaderanimator.setduration(default_duration); } mheaderanimator.start(); if (mcurrentstate == pull_to_refresh) { // 下拉状态的话,把状态改为正在隐藏头部状态 mcurrentstate = hiding; mheader.settext("收回头部..."); } } break; } default: break; } mlasty = y; return super.ontouchevent(event); }
你可能会问了,这个mcontentviewoffset怎么知道呢?接下来就是处理的方法,我会针对不同的滑动控件,去设置它们的滑动距离的监听,方法各种各样,通过handletargetoffset去判别view的类型采取不同的策略;然后你可能会觉得要是我那个控件我也要实现监听咋办?这个简单,继承我已经实现的监听器,再补充你想要的功能即可,这个时候就不能再调handletargetoffset这个方法了呗。
// 设置内容页滑动距离 public void setcontentviewoffset(int offset) { mcontentviewoffset = offset; } /** * 根据不同类型的view采取不同类型策略去计算滑动距离 * * @param view 内容view */ public void handletargetoffset(view view) { if (view instanceof recyclerview) { ((recyclerview) view).addonscrolllistener(new recyclerviewonscrolllistener()); } else if (view instanceof nestedscrollview) { ((nestedscrollview) view).setonscrollchangelistener(new nestedscrollviewonscrollchangelistener()); } else if (view instanceof webview) { view.setontouchlistener(new webviewontouchlistener()); } else if (view instanceof scrollview) { view.setontouchlistener(new scrollviewontouchlistener()); } else if (view instanceof listview) { ((listview) view).setonscrolllistener(new listviewonscrolllistener()); } } /** * 适用于recyclerview的滑动距离监听 */ public class recyclerviewonscrolllistener extends recyclerview.onscrolllistener { int offset = 0; @override public void onscrolled(recyclerview recyclerview, int dx, int dy) { super.onscrolled(recyclerview, dx, dy); offset += dy; setcontentviewoffset(offset); } } /** * 适用于nestedscrollview的滑动距离监听 */ public class nestedscrollviewonscrollchangelistener implements nestedscrollview.onscrollchangelistener { @override public void onscrollchange(nestedscrollview v, int scrollx, int scrolly, int oldscrollx, int oldscrolly) { setcontentviewoffset(scrolly); } } /** * 适用于webview的滑动距离监听 */ public class webviewontouchlistener implements view.ontouchlistener { @override public boolean ontouch(view view, motionevent motionevent) { setcontentviewoffset(view.getscrolly()); return false; } } /** * 适用于scrollview的滑动距离监听 */ public class scrollviewontouchlistener extends webviewontouchlistener { } /** * 适用于listview的滑动距离监听 */ public class listviewonscrolllistener implements abslistview.onscrolllistener { @override public void onscrollstatechanged(abslistview abslistview, int i) { } @override public void onscroll(abslistview view, int firstvisibleitem, int visibleitemcount, int totalitemcount) { if (firstvisibleitem == 0) { view c = view.getchildat(0); if (c == null) { return; } int firstvisibleposition = view.getfirstvisibleposition(); int top = c.gettop(); int scrolledy = -top + firstvisibleposition * c.getheight(); setcontentviewoffset(scrolledy); } else { setcontentviewoffset(1); } } }
最后参考谷歌大大的swiperefreshlayout提供setrefreshing来开启或关闭刷新动画,至于openheader为啥要post(runnable)呢?相信用过swiperefreshlayout在oncreate的时候直接调用setrefreshing(true)没有小圆圈出来的都知道这个坑!
public void setrefreshing(boolean refreshing) { if (refreshing && mcurrentstate != refreshing) { // 强开刷新头部 openheader(); } else if (!refreshing) { closeheader(); } } private void openheader() { post(new runnable() { @override public void run() { mcurrentstate = release_to_refresh; mheaderanimator.setduration((long) (default_duration * 2.5)); mheaderanimator.setintvalues(mheader.getpaddingtop(), 0); mheaderanimator.start(); } }); } private void closeheader() { mheader.settext("刷新完毕,收回头部..."); mcurrentstate = hiding; mheaderanimator.setintvalues(mheader.getpaddingtop(), -mheaderheight); // 0~-mheaderheight用时default_duration mheaderanimator.setduration(default_duration); mheaderanimator.start(); }
3.3.效果展示
除了以上三个还有在demo中实现了listview、viewpager、scrollview、nestedscrollview,具体看代码即可
demo地址:github:refreshlayoutdemo,觉得还不错的话给个star哦。
以上所述是小编给大家介绍的android开发之无痕过渡下拉刷新控件的实现思路详解,希望对大家有所帮助