正如标题所示,今天主要带大家如何写一款自定义的下拉刷新控件,顺带讲解下拉刷新中最主要的头等大事:如何解决滑动冲突问题。好了,下面就让我们开始吧!
关于下拉刷新相信其实大家已经接触过也使用过不少了,网上有很多的第三方下拉刷新框架而且Android也有一个原生的下拉刷新,比如博主本人在开发过程中,以上两种我都有使用过,感受各不相同,网上第三方的下拉刷新框架功能强大,特效炫酷,缺点是占用空间也很大,代码庞杂,Android原生的下拉刷新控件占用内存小,但是特效朴素。两者各有自己的不足和优势。
后来本人就开始自己动手尝试开发下拉刷新,自己的开发的东西好处就是自己可以随意定制,而且可以随意的做出修改。这篇博文主要是给和我一样想法和认同的小伙伴准备的,如果你感觉网上的第三方下拉刷新框架,或者Android原生的下拉刷新控件可以满足你,那么你已经没有必要看这篇博文。
好了,下面我们就开始来做下拉刷新把~
首先老规矩,我们先分析实现下拉刷新的思路:
所谓的下拉刷新就是手指在页面向下滑动会逐渐的拉出顶部的隐藏布局,那么该怎么让隐藏在顶部的布局随着手指下滑逐渐拉出呢?这里给大家一个参考,例如QQ的下拉刷新,我们在这里要实现的就是QQ的这种效果。
首先很快就能想到,既然要求随着手指滑动逐渐拉出,那么一定要做的就是监听手势事件!当我们监听手指向下滑动的时候,我们就在MOVE事件中不断的动态改变UI布局,使下滑时和UI改变同步。这里确定了需要监听的事件,下面我们来分析一下布局UI上的代码:首先我们确定顶部的下拉刷新控件在一开始应该是隐藏起来的,只有触发了时间它才会出现。它出现的方式是从顶部开始,随之手势的滑动逐渐向下扩展,那么也就是说它的高度起码是逐渐变化的,当然这里我们不会考虑宽度的事情。那么这里我们就可以基本的确定它的父布局是一个LinearLayout,同时父布局的LinearLayout设置的是竖向排列,因为当它的高度逐渐变大的时候,它下面的控件,或者说子布局便会被顶下去,给人在视觉上的效果就是它被拉了出来!
分析到这里,我们基本可以确定,看似神秘的高大上下拉刷新,其实它的构成主要有下面三个部分:
1.监听手势事件;
2.父布局是Linear Layout,排列方式为竖向;
3.大多数情况下需要处理一下滑动冲突问题。
我们先来说第三个部分:处理滑动冲突问题!
为什么要先讲滑动冲突问题?原因很简单,因为它是整个下拉刷新组成中最重要的一块了。首先我们要先明白什么是滑动冲突?
滑动冲突从字面上就可以看出来,指滑动事件相互冲突,主要是在一个布局中,如果存在两个或者多个的控件需要去进行滑动操作,监听他们的滑动事件,那么这里就会产生一个滑动冲突。具体表现为无法滑动或者滑动卡顿。当然不单单只有滑动才会导致滑动冲突,点击也是同样的。比如你在一个滚动控件的子item的布局内你又加了一个点击事件,那么你就会发现这个item的点击事件无法被监听到。为什么?很简单,我们都知道,点击事件也是一种滑动监听事件,因为事件被它的父控件滚动控件给拦截了,自然无法到达里面子item,所以item的点击事件无效。这也是一种滑动冲突。
一般用到下拉刷新的地方都是加载大量的数据需要滚动去展示,可能是ScrollView,或者是RecyclerView,他们都是可以上下滑动的。当下拉需要刷新的时候需要监听滚动控件的滑动事件,因为我们要确定只有滚动控件滑动到顶部的时候,继续向下滑动我们才能把顶部布局拉出来,这里需要监听第二个滑动事件,用来处理顶部布局的高度。所以在这里已经出现了滑动冲突,因为存在两个滑动事件,一个是布局的滑动监听,一个就是布局内部滚动控件的滑动事件监听。
下面我们就开始去如何解决滑动冲突问题:
关于如何解决滑动冲突问题,我们首先就要去了解Android滑动事件的分发机制。
具体的源码分析我相信网上有好多,这里我不会去给你分析源码,毕竟滑动事件的分发机制源码比较复杂,分析起来会比较麻烦,有兴趣的小伙伴可以自行搜索去查看源码分析。这里我只是给大家大致的说一下分发机制的执行策略,以及我们该怎么写代码来保证事件被正确的分发下去。
这里给大家准备一份伪代码:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean result=false;
if(onInterceptTouchEvent(ev)){
result=onTouchEvent();
}else{
result=child.dispatchTouchEvent(ev);
}
return result;
}
上面的伪代码很好的展示了滑动事件的分发机制。
首先事件是从上向下传递的,顶层的布局首先拿到这个事件,事件进入顶层布局的dispatchTouchEvent()方法中,然后调用了onInterceptTouchEvent()方法来判断这个事件需不需要拦截下来,需要拦截下来就返回true,不要拦截就返回false,如果需要拦截,那么就会调用onTouchEvent()方法来消耗这个事件,也就是说这个事件机会得到执行,如果不拦截的话,那么就会把事件交给它的子布局,通过调用子布局的dispatchTouchEvent()方法,把这个事件继续传递下去,直到找到它真正的消耗者。
滑动冲突的原因就很明显了,因为一次产生的事件只有一个,但是有两个控件的onInterceptTouchEvent()方法都表示需要拦截下来,那么自然就会导致一方消耗了事件而另外一方没有事件可以消耗,自然就会产生了冲突。
解决的思路也很简单,那就是让他们两个和平相处,什么时候谁需要事件就把事件给谁,谁不需要事件就不给它。
具体在程序中该怎么做这个事件合理分发,我们结合代码细致的讲一下:
首先我们需要自定义一个布局,在这里面进行事件的分发,代码为:
/**
* Created by 王将 on 2018/7/26.
*/
public class MyLinearLayout extends LinearLayout {
int downY = 0;
int scrollY=0;
public MyLinearLayout(Context context) {
super(context);
}
public MyLinearLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MyLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public MyLinearLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public void setScrollY(int scrollY) {
this.scrollY = scrollY;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept=false;
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
downY=(int)ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (scrollY==0){
if (ev.getY()>downY){
intercept=true;
}else {
intercept=false;
}
}
break;
}
return intercept;
}
public int getDownY(){
return downY;
}
}
这里我们自定义一个类MyLinearLayout,它继承了LinearLayout ,主要的事件分发在它的onInterceptTouchEvent()方法中:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept=false;
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
downY=(int)ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (scrollY==0){
if (ev.getY()>downY){
intercept=true;
}else {
intercept=false;
}
}
break;
}
return intercept;
}
我们可以看到,首先我们定义了一个布尔型的变量intercept,初始值为false,代表的是linearLayout是否需要拦截下事件。
然后进入状态判断,首先我们记录下手指按下的Y坐标,然后进入MOVE状态,我们首先判断scrollY是否为0,这里的scrollY指的是滚动控件滚动的高度,如果滚动高度为0,那么我们就确定此时已经滚动到顶部,然后进入接下来的判断,判断手指是否向下滑动,也就是下拉动作。如果出现了下拉动作,那么也就是说他想拉出顶部的刷新布局,那么我们就决定拦截下来,把intercept设置为true,由我们的Layout消耗滑动事件;如果是想上滑动,也就是上拉操作,那么表示他想上拉滚动控件以便查看下面的信息,那么我们就不拦截,把intercept设置为false,由滚动控件去消耗滑动事件。最后返回我们是否要拦截的决定。
就这样我们解决了滑动冲突问题。
下面我们去看一下具体的布局文件:
<?xml version="1.0" encoding="utf-8"?>
<com.example.slidingconflicttest.MyLinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/parent_id">
<View
android:id="@+id/view_id"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#9f5353"/>
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/scroll_id">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#d4c981"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#a9d481"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#51976d"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#ab755b"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#2dc496"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#f2d517"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#8192d4"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#47acd1"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#ab81d4"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#d553e1"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#ed5d92"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#312d0f"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#27de13"/>
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#d4c981"/>
</LinearLayout>
</android.support.v4.widget.NestedScrollView>
</com.example.slidingconflicttest.MyLinearLayout>
在这里,我为了方便简单,所以顶部的刷新布局我用了一个View来代替,滚动控件我使用的是NestedScrollView,便于监听滚动位置。在实际的开发中,大家需要把这个View换成自己的刷新布局就好了。
在布局文件代码中,我们可以看出MyLinearLayout是顶层的父布局,NestedScrollView是MyLinearLayout布局中的子空间,在我刚才讲解的滑动冲突处理中,处理的就是他们两个的滑动冲突。因为MyLinearLayout是顶层的父布局,所以滑动事件会首先传递给它,理所当然我们需要在MyLinearLayout的onInterceptTouchEvent()方法中做出具体的事件分发操作。
下面我们开始写主活动中具体的逻辑操作:
public class MainActivity extends AppCompatActivity implements NestedScrollView.OnScrollChangeListener, View.OnTouchListener {
NestedScrollView nestedScrollView;
View view;
MyLinearLayout linearLayout;
int moveY,height=0,nowHeight=0;
LinearLayout.LayoutParams layoutParams;
boolean isBacking=false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
nestedScrollView=(NestedScrollView) findViewById(R.id.scroll_id);
view=(View) findViewById(R.id.view_id);
linearLayout=new MyLinearLayout(this);
linearLayout=(MyLinearLayout) findViewById(R.id.parent_id);
nestedScrollView.setOnScrollChangeListener(this);
linearLayout.setOnTouchListener(this);
layoutParams=(LinearLayout.LayoutParams) view.getLayoutParams();
}
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
moveY=(int) event.getY();
if (isBacking){
isBacking=false;
}
height=nowHeight+moveY-linearLayout.getDownY();
layoutParams.height=height;
view.setLayoutParams(layoutParams);
break;
case MotionEvent.ACTION_UP:
isBacking=true;
new BackTop().execute();
break;
}
return true;
}
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
linearLayout.setScrollY(v.getScrollY());
}
class BackTop extends AsyncTask{
@Override
protected Object doInBackground(Object[] objects) {
while (isBacking){
publishProgress(height);
SystemClock.sleep(3);
height--;
nowHeight=height;
if (height==0){
break;
}
}
return true;
}
@Override
protected void onProgressUpdate(Object[] values) {
layoutParams.height=(int) values[0];
view.setLayoutParams(layoutParams);
}
@Override
protected void onPostExecute(Object o) {
}
}
}
代码中,我们分别设置了nestedScrollView的监听事件和linearLayout的监听事件。
我们先去看一下nestedScrollView的监听事件:
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
linearLayout.setScrollY(v.getScrollY());
}
非常简单,就只有一句代码,设置linearLayout中scrollY变量的值。scrollY上面我们已经说过了,代表的是NestedScrollView 的滚动位置,在代码中也可以看到,我们传入的参数为NestedScrollView.getScrollY()方法,getScrollY()返回的就是滚动的高度。
下面我们看linearLayout的监听事件:
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
moveY=(int) event.getY();
if (isBacking){
isBacking=false;
}
height=nowHeight+moveY-linearLayout.getDownY();
layoutParams.height=height;
view.setLayoutParams(layoutParams);
break;
case MotionEvent.ACTION_UP:
isBacking=true;
new BackTop().execute();
break;
}
return true;
}
我们明确一下,只有MylinearLayout确定要拦截并消耗滑动事件,才会执行它的onTouch()方法。所以在它的onTouch()方法中,处理的逻辑主要为随着手指下滑的过程逐渐设置顶部刷新布局的高度。
细心的你可能看出来了,我们没有在ACTION_DOWN状态下写任何的处理,这里的原因是因为根本不会进入到ACTION_DOWN的状态。为什么?滑动事件肯定会有ACTION_DOWN状态的啊!别急,不知道你忘没忘之前MyLinearLayout的onInterceptTouchEvent()方法,忘了你可以再去看一下,之所以这里不会进入ACTION_DOWN状态,那是因为程序在MyLinearLayout的onInterceptTouchEvent()方法已经消费过ACTION_DOWN状态了,所以当然不会再消费一次了!
ACTION_DOWN状态下的处理代码为:
downY=(int)ev.getY();
标记了手指按下的Y坐标!
然后我们在ACTION_MOVE状态下,通过调用MyLinearLayout的getDownY()方法来获取了手指按下的Y坐标。下面我们就去分析下主要的ACTION_MOVE状态执行的代码:
case MotionEvent.ACTION_MOVE:
moveY=(int) event.getY();
if (isBacking){
isBacking=false;
}
height=nowHeight+moveY-linearLayout.getDownY();
layoutParams.height=height;
view.setLayoutParams(layoutParams);
break;
首先我们获取手指触摸的Y坐标值,然后我们做了一个判断,这里先不讲这个判断,我们接着往下看。通过目前的Y坐标值减去起初按下时候的Y坐标值,获取手指在屏幕上向下移动的距离,然后把这个距离设置为顶部刷新布局的高度,这里通过layoutParams来动态设置布局高度的。其中还有一个nowHeight,我们也是先不关注它,下面讲到。就这样,我们实现了随着手指向下滑动顶部的刷新布局逐渐被拉出的效果。
下面我们进入松开手指后的操作吧,代码;
case MotionEvent.ACTION_UP:
isBacking=true;
new BackTop().execute();
break;
这里我们开启了一个线程。这个线程的主要功能是做什么的呢?显而易见,是重新把拉出来的顶层刷新布局给弹回去的~
BackTop的代码具体如下:
class BackTop extends AsyncTask{
@Override
protected Object doInBackground(Object[] objects) {
while (isBacking){
publishProgress(height);
SystemClock.sleep(3);
height--;
nowHeight=height;
if (height==0){
break;
}
}
return true;
}
@Override
protected void onProgressUpdate(Object[] values) {
layoutParams.height=(int) values[0];
view.setLayoutParams(layoutParams);
}
@Override
protected void onPostExecute(Object o) {
isBack=false;
}
}
BackTop继承自AsyncTask框架,我们这里只重写了doInBackground()方法和onProgressUpdate()方法。通过在doInBackground()方法中不断的回调onProgressUpdate()方法来达到更新UI的效果。这里我们是完整的把拉出来的布局给弹了回去,你可以看到height一直减到了0。在实际的下拉刷新中,松开手指首先会回弹到一个固定的高度,比如回弹到200px,然后开始执行刷新操作,刷新结束后,再把布局给完全弹回去。这里因为我们并没有可刷新的东西,主要是给大家讲一下怎么做下拉刷新,所以我这里采用了直接完全弹回去。
在BackTop中你也可以设置回弹的速度,回弹的速度主要收到系统沉睡的时间影响,这里我写的是SystemClock.sleep(3);沉睡3毫秒,你设置沉睡时间越大,那么它回弹的速度就越慢,沉睡时间越小,回弹速度越快。
好了,下面就讲一下刚才忽略的isBack变量和nowHeight变量。
首先先说一下可能存在这样的情况,在执行刷新的时候,用户依旧去下拉页面,那么这个时候如果不去处理这种情况,在刷新的时候继续下拉页面就会导致页面大变形的情况。你可能会说谁会这么无聊啊,已经在刷新了还要下拉,你还别说,真的有,可能人家在等待刷新的时候无聊再拉拉页面。即使没有这种情况,我们依旧要把这种情况考虑进去,毕竟这是作为一名合格的开发人员的职业素养。
所以,我设置了一个布尔变量,让它来代表目前进行的状态。true为正在回弹的时候,false为没有回弹的时候。
当正在回弹的时候,继续下拉就会进入ACTION_MOVE状态,我们判断出当前是正在回弹的状态,然后就把isBack设置为false,注意,这里设置成false后,回弹的线程就立刻停止了,为什么?回去看一下代码:
@Override
protected Object doInBackground(Object[] objects) {
while (isBacking){
publishProgress(height);
SystemClock.sleep(3);
height--;
nowHeight=height;
if (height==0){
break;
}
}
return true;
}
因为在线程方法中,我们拿isBack当while循环的判断条件,当isBack为false时,不符合循环条件,线程方法自然就会停止了。
我们接着看ACTION_MOVE状态下的代码,height值的计算加上了一个newHeight值,这个newHeight值就是在线程程序中不断地被赋值,目的只有一个,在回弹的过程中不断地记录每一刻height的数值。为什么要这样做?
试想一下,如果没有这个newHeight值,正在回弹的时候再次下拉会出现什么状况?你会发现刷新布局竟然重新从顶端被拉下!原因很好理解,因为moveY-linearLayout.getDownY()的值是从0开始逐渐的增大的!
没有记录height的数值,例如,当height的值减到200px,再次下拉,height的值就会直接从200变成了0,重新开始递增。这样de UI效果是不符合正常思维观念的,因为你绝对不希望从0开始拉出,而是在它回弹到的那个地方被再次拉出。所以我们需要记录下height的数据变化,当正在回弹的时候再次下拉,这个时候moveY-linearLayout.getDownY()加上它的回弹数值,才是真正的高度!
进行到这里,讲解已经基本结束了。你可以运行一下看看效果。还有一些小地方,比如拉出到一定的高度,就改变提示信息,比如刚开始拉出来提示信息是“下拉刷新”,拉出300的px高度后,就把提示信息改为“松开立即刷新”。这些都非常容易实现,只需要在ACTION_MOVE状态下加上判断height的大小就可以了。还有一些炫酷的刷新布局,这些都是需要聪明的你自己去设计了,我在这里主要讲解的是下拉刷新的一些关键难题。
好了,本文进行到这里基本就要结尾了。希望我的这篇博文能够给你启示和帮助,激发你无尽的创新能力。
有需要引用本文的地方请标明出处,谢谢!