一步步自定义下拉刷新上拉加载——自定义刷新组件优化

本篇文章是我对自定义下拉刷新组件的优化思路。github中有很多优秀的框架为什么还要自己写呢? 学习技术不仅仅在于会用,还要会写。自己动手撸一遍,才会发现其中的乐趣。
本篇还包括下面两个扩展内容

  • SwipeRefreshLayout源码解析
  • 深入了解自定义属性

好了下面和我一起撸代码吧。

优化

首先我们来看下效果图

相较于上一篇,我们添加了箭头变化的效果,以及文字变化效果。

箭头动画

如果不了解ObjectAnimator,可以参看这篇文章ObjectAnimator详解
为了实现箭头变化效果我们添加下面的代码。 我们的箭头初始化时为0,在下拉过程中,我们让箭头旋转180度。

1
2
3
4
5
public void rotateArrow() {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(ivArrowPullDown, "rotation", ivArrowPullDown.getRotation(), ivArrowPullDown.getRotation() + 180);
objectAnimator.setDuration(135);
objectAnimator.start();
}

首先我们要滑动起来才能根据 getScrollerY判断是上拉还是下拉。在下拉的过程中,我们还可以上滑,同理在上拉的过程中,我们可以下滑。因此我们需要根据这些情况,修改目前的状态。

首先定义如下状态:

1
2
3
4
5
6
7
static final int PULL_IDLE = -1;//无状态
static final int PULL_DOWN_NORMAL = 0;//下拉刷新
static final int PULL_DOWN_RELEASE = 1;//释放刷新
static final int PULL_DOWN_REFRESH = 2;//正在刷新
static final int PULL_UP_NORMAL = 3;//上拉加载更多
static final int PULL_UP_RELEASE = 4;//上拉释放
static final int PULL_UP_REFRESH = 5;//正在加载

根据不同的滑动方式,更新当前状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//滑动过程中的变化
private void doScroll(int deltaY) {
if (Math.abs(deltaY) > mTouchSlop) {//超过最小滑动距离
if (deltaY < 0) {//下拉
if (getScrollY() < 0) {//顶部向下拉
if (!pullDownEnable) {
return;
}
isPullDown = true;
if (Math.abs(getScrollY()) <= mPullHeader.getMeasuredHeight() / 2) {
if (Math.abs(getScrollY()) >= mEffectiveScrollY) {
deltaY /= SCROLL_RESISTANCE;//滑动阻力
updateState(PULL_DOWN_RELEASE);
} else {
updateState(PULL_DOWN_NORMAL);
}
}
} else { //底部向下滑动时
if (Math.abs(getScrollY()) < mEffectiveScrollY) {
updateState(PULL_UP_NORMAL);
}
}
} else {//上拉
if (getScrollY() < 0) {//顶部向上滑动
if (Math.abs(getScrollY()) < mEffectiveScrollY) {
updateState(PULL_DOWN_NORMAL);
}
} else {//底部上拉
if (!pullUpEnable) {
return;
}
isPullDown = false;
if (Math.abs(getScrollY()) + Math.abs(deltaY) < mPullFooter.getMeasuredHeight() / 2) {
if (Math.abs(getScrollY()) >= mEffectiveScrollY) {
updateState(PULL_UP_RELEASE);
deltaY /= SCROLL_RESISTANCE;//添加滑动阻力
} else {
updateState(PULL_UP_NORMAL);
}
}
}
}
scrollBy(0, deltaY);
}
}

在解决滑动冲突时,我们用了大量的代码判断内部代码是否为ListView,RecycleView,ScrollView以及是否到达顶部和底部。那么有没有简单的判断的方式呢? SwipeRefreshLayout是Android系统提供我们的原生刷新框架,我们首先来了解下SwipeRefreshLayout的使用,然后在看看SwipeRefreshLayout的源码,我们再进一步修改我们的代码。

SwipeRefreshLayout的使用

SwipeRefreshLayout是Google官方推出的一款下拉刷新组件,位于v4兼容包下,android.support.v4.widget.SwipeRefreshLayout,Support Library 必须19.1以上。

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</android.support.v4.widget.SwipeRefreshLayout>

主要代码如下,。通过设置OnRefreshListener来监听界面的滑动从而实现刷新。,在刷新监听中处理我们的刷新数据 和 刷新进度的关闭。
其中:
setRefreshing(true),展开刷新动画。
setRefreshing(false),取消刷新动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class RefreshActivity extends AppCompatActivity {
private List<String> data = new ArrayList<>();
private boolean isRefresh = false;
private SwipeRefreshLayout swipeRefreshLayout;
private ArrayAdapter<String> arrayAdapter;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_refresh_layout);
initListView();
swipeRefreshLayout = findViewById(R.id.refresh_layout);
swipeRefreshLayout.setColorSchemeColors(Color.BLUE, Color.GREEN, Color.RED);//设置刷新动画的颜色
swipeRefreshLayout.setOnRefreshListener(refreshListener);//设置刷新监听
}

private SwipeRefreshLayout.OnRefreshListener refreshListener = new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
if (!isRefresh) {
isRefresh = true;
//3秒后刷新内容,并关闭动画
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
swipeRefreshLayout.setRefreshing(false); //关闭动画
data.add("这是新的数据");
arrayAdapter.notifyDataSetChanged();
isRefresh = false;
}
}, 3000);
}
}
};

private void initListView() {
initData();
ListView listView = findViewById(R.id.list_view);
arrayAdapter = new ArrayAdapter<String>(this, android.R.layout.simple_expandable_list_item_1, data);
listView.setAdapter(arrayAdapter);
}

private void initData() {
for (int i = 0; i < 20; i++) {
data.add("hehe->" + i);
}
}
}

SwipeRefreshLayout 提供的方法

  • isRefreshing(): 判断当前的状态是否是刷新状态。

  • setColorSchemeResources(int… colorResIds):设置下拉进度条的颜色主题,参数为可变参数,并且是资源id,可以设置多种不同的颜色,每转一圈就显示一种颜色。

  • setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener listener): 设置监听,需要重写onRefresh()方法,顶部下拉时会调用这个方法,在里面实现请求数据的逻辑,设置下拉进度条消失等等。

  • setProgressBackgroundColorSchemeResource(int colorRes):设置下拉进度条的背景颜色,默认白色。

  • setRefreshing(boolean refreshing): 设置刷新状态,true表示正在刷新,false表示取消刷新。

SwipeRefreshLayout 源码解析

这篇文章对 SwipeRefreshLayout进行了源码解析

在解析源码的过程中,我们的关注点放在,SwipeRefreshLayout是如何解决滑动冲突的。我们直接锁定在了OnInterceptTouchEvent的源码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
ensureTarget();

final int action = ev.getActionMasked();
int pointerIndex;

if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
mReturningToStart = false;
}

if (!isEnabled() || mReturningToStart || canChildScrollUp()
|| mRefreshing || mNestedScrollInProgress) { //不可用,子类可滑动,正在刷新
// Fail fast if we're not in a state where a swipe is possible
return false;
}

switch (action) {
case MotionEvent.ACTION_DOWN:
setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop());
mActivePointerId = ev.getPointerId(0);
mIsBeingDragged = false;

pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
return false;
}
mInitialDownY = ev.getY(pointerIndex);
break;

case MotionEvent.ACTION_MOVE:
if (mActivePointerId == INVALID_POINTER) {
Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
return false;
}

pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
return false;
}
final float y = ev.getY(pointerIndex);
startDragging(y);
break;

case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
break;
}

return mIsBeingDragged;
}

来看这几行代码

1
2
3
4
5
if (!isEnabled() || mReturningToStart || canChildScrollUp() 
|| mRefreshing || mNestedScrollInProgress) { //不可用,子类可滑动,正在刷新
// Fail fast if we're not in a state where a swipe is possible
return false;
}

在不可用,以及子类可滑动和正在刷新的过程中,不拦截。
再看这句代码

1
ensureTarget();

一开始使用ensureTarget是什么意思呢?我们看下这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
private void ensureTarget() {
// Don't bother getting the parent height if the parent hasn't been laid
// out yet.
if (mTarget == null) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (!child.equals(mCircleView)) {
mTarget = child; //只获取第一个不为CircleView的Child,并让他全屏显示
break;
}
}
}
}

注意到break,获取到我们内容布局中第一个布局就结束循环了。为什么只获取第一个布局呢?再来看看 onMeasure方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mTarget == null) {
ensureTarget();
}
if (mTarget == null) {
return;
}
//全屏显示
mTarget.measure(MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY));
mCircleViewIndex = -1;
// Get the index of the circleview.
for (int index = 0; index < getChildCount(); index++) {
if (getChildAt(index) == mCircleView) {
mCircleViewIndex = index;
break;
}
}
}

注意这几行代码,将我们的mTarget设置为了铺满全屏

1
2
3
4
5
//全屏显示
mTarget.measure(MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));

综上,我们终于明白了,为什么只获取得一个内容布局了。因为SwipeRefreshLayout只针对获取到的第一个布局,并让他铺满全屏。
回到正题,如何进行拦截呢?再来看看这几行代码

1
2
3
4
5
if (!isEnabled() || mReturningToStart || canChildScrollUp() 
|| mRefreshing || mNestedScrollInProgress) { //不可用,子类可滑动,正在刷新
// Fail fast if we're not in a state where a swipe is possible
return false;
}

关注到 cnChildScrollUp(),并来看下这个函数的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @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.
*/
public boolean canChildScrollUp() {
if (mChildScrollUpCallback != null) {
return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
}
if (mTarget instanceof ListView) {
return ListViewCompat.canScrollList((ListView) mTarget, -1);
}
return mTarget.canScrollVertically(-1);
}

在上一篇我们判断RecycleView的时候,用到了View.canScrollVertically()的方法来判断是否到达顶部和底部,这里直接使用了这个方式。

-1表示向上滑动,1表示向下滑动。由于SwipeRefreshLayout中只使用到下拉刷新,所以这里仅需判断子类是否能够向上滑动即可。

注意到ListView单独列出来了吗?为什么要把ListView单独列出来呢?

在ListViewCompat中我们看到,ListView存在不同版本,在低版本(API<19)时,需要根据firstVisiblePosition否到达顶部,而高版本进行改进后调用canScrollList()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static boolean canScrollList(@NonNull ListView listView, int direction) {
if (Build.VERSION.SDK_INT >= 19) {
// Call the framework version directly
return listView.canScrollList(direction);
} else {
// provide backport on earlier versions
final int childCount = listView.getChildCount();
if (childCount == 0) {
return false;
}

final int firstPosition = listView.getFirstVisiblePosition();
if (direction > 0) {
final int lastBottom = listView.getChildAt(childCount - 1).getBottom();
final int lastPosition = firstPosition + childCount;
return lastPosition < listView.getCount()
|| (lastBottom > listView.getHeight() - listView.getListPaddingBottom());
} else {
final int firstTop = listView.getChildAt(0).getTop();
return firstPosition > 0 || firstTop < listView.getListPaddingTop();
}
}
}

通过上面的解析,我们基本掌握SwipeRefreshLayout的工作原理,那么我们就可以这样修改拦截机制。

优化onInterceptTouchEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//滑动过程中的变化
private void doScroll(int deltaY) {
if (Math.abs(deltaY) > mTouchSlop) {//超过最小滑动距离
if (deltaY < 0) {//下拉
if (getScrollY() < 0) {//顶部向下拉
if (!pullDownEnable) {
return;
}
isPullDown = true;
if (Math.abs(getScrollY()) <= mPullHeader.getMeasuredHeight() / 2) {
if (Math.abs(getScrollY()) >= mEffectiveScrollY) {
deltaY /= SCROLL_RESISTANCE;//滑动阻力
updateState(PULL_DOWN_RELEASE);
} else {
updateState(PULL_DOWN_NORMAL);
}
}
} else { //底部向下滑动时
if (!pullUpEnable) {
return;
}
if (Math.abs(getScrollY()) < mEffectiveScrollY) {
updateState(PULL_UP_NORMAL);
}
}
} else {//上拉
if (getScrollY() < 0) {//顶部向上滑动
if (!pullDownEnable) {
return;
}
if (Math.abs(getScrollY()) < mEffectiveScrollY) {
updateState(PULL_DOWN_NORMAL);
}
} else {//底部上拉
if (!pullUpEnable) {
return;
}
isPullDown = false;
if (Math.abs(getScrollY()) + Math.abs(deltaY) < mPullFooter.getMeasuredHeight() / 2) {
if (Math.abs(getScrollY()) >= mEffectiveScrollY) {
updateState(PULL_UP_RELEASE);
deltaY /= SCROLL_RESISTANCE;//添加滑动阻力
} else {
updateState(PULL_UP_NORMAL);
}
}
}
}
scrollBy(0, deltaY);
}
}

为了安全性,使用mTarget时需要判断是否为null。并且设置我们的mTarget为铺面全屏,且只获取第一个mTarget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mTarget == null) {
return;
}
//测量子类,设置为铺面全屏
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).measure(
MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(),
MeasureSpec.EXACTLY));
}
}

添加头部或底部可选

我们默认情况下是头部和底部可用的,有些时候我们只需要用到下拉刷新或者上拉加载,为了灵活性,我们给自定义刷新组件中添加 布局文件更改的方式以及代码更改的方式。
在布局中选择 头部或底部 是否可用,就需要用到自定义属性文件。那么我们好好了解下属性文件到底是什么?

自定义属性

可以看这篇文章 鸿洋 深入理解自定义属性 以及 自定义属性文件属性文件
对此方面的知识大概总结一下
自定义属性的使用步骤为:

  • 自定义View
  • 在values/attrs.xml文件中编写styleable和item 标签元素
  • 在布局文件中使用自定义属性
  • 在自定义View的构造中 通过TypedArray获取,使用完毕后需要回收recycle。

注意
我们自定义View的时候一定要有构造函数,一定要有参数AttributeSet

1
2
3
public SimpleRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}

为了了解AttributeSet的作用我们举个小例子

  1. 在values/attrs.xml中添加下面内容

    1
    2
    3
    4
    <declare-styleable name="test_style">
    <attr name="test_name" format="string" />
    <attr name="test_color" format="color" />
    </declare-styleable>
  2. 自定义View

1
2
3
4
5
6
7
8
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
Log.d(TAG, "TestView: attrName"+attrName+" : attrValue "+ attrValue);
}
  1. 在布局文件中共添加自定义属性
1
2
3
4
5
<com.example.com.myapplication.view.TestView
app:test_color="#007825"
app:test_name="呵呵"
android:layout_width="300dp"
android:layout_height="200dp" />

打印结果如下

可以从这里了解到,当我们的XML布局创建视图的时候,XML中的属性会通过AttributeSet传递到 构造器中。
LayoutInflater在inflater布局时会通过反射去调用View的(Context context, AttributeSet attrs)构造器。
因此我们自定义View时一定要添加这个构造函数。

1
public TestView(Context context, @Nullable AttributeSet attrs) {}

如果不添加 会导致属性资源无法解析,样式不可用。最直观的表现是程序崩了。

一般地,我们使用TypedArray来获取属性值,TypedArray帮我们做了很多事,他相当于一个工具类,通过context.obtainStyledAttributes方法,将AttributeSet的属性加工成对象的属性封装到TypedArray中。

使用方式一

1
2
3
4
5
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.test_style);
int color = typedArray.getColor(R.styleable.test_style_test_color, Color.BLACK);
String name = typedArray.getString(R.styleable.test_style_test_name);
typedArray.recycle();
Log.d(TAG, "TestView: name "+name+" : color "+color);

使用方式二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.test_style);
int count = typedArray.getIndexCount();
for (int i = 0; i < count; i++) {
int attr = typedArray.getIndex(i);
switch (attr) {
case R.styleable.test_style_test_color:
int color = typedArray.getColor(attr, Color.BLACK);
Log.d(TAG, "TestView: color " + color);
break;
case R.styleable.test_style_test_name:
String name = typedArray.getString(attr);
Log.d(TAG, "TestView: name " + name);
break;
}
}
typedArray.recycle();

结果

1
TestView: name 呵呵 : color -16746459

为什么要调用recycle呢?

这篇文章讲解了使用recycle的原因

实际上当我们调用ObtainAttributeSet()的方法时,调用了TypeArray的Obtain方法,这个方法是静态的,TypedArray是在array pool中获取到的。下面就是源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static TypedArray obtain(Resources res, int len) {
TypedArray attrs = res.mTypedArrayPool.acquire();
if (attrs == null) {
attrs = new TypedArray(res);
}

attrs.mRecycled = false;
// Reset the assets, which may have changed due to configuration changes
// or further resource loading.
attrs.mAssets = res.getAssets();
attrs.mMetrics = res.getDisplayMetrics();
attrs.resize(len);
return attrs;
}

通过这段代码可以得到结论:程序在运行时维护了一个 TypedArray的池,程序调用时,会向该池中请求一个实例,用完之后,调用 recycle() 方法来释放该实例,从而使其可被其他模块复用。

添加可选属性
了解的自定义属性的方式,那么来为我们自定义View添加这部分内容吧

attr文件

1
2
3
4
<declare-styleable name="SimpleRefreshLayout" >
<attr name="upEnable" format="boolean" />
<attr name="downEnable" format="boolean" />
</declare-styleable>

获取自定义属性

1
2
3
4
5
6
7
8
9
10

public SimpleRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
//获取自定义属性
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SimpleRefreshLayout
);
pullUpEnable = typedArray.getBoolean(R.styleable.SimpleRefreshLayout_upEnable, true);
pullDownEnable = typedArray.getBoolean(R.styleable.SimpleRefreshLayout_downEnable, true);
typedArray.recycle();
}

动态代码设置

1
2
3
4
5
6
7
8
9
//动态设置下拉刷新是否可用
public void setPullDownEnable(boolean pullDownEnable) {
this.pullDownEnable = pullDownEnable;
}

//动态设置上拉加载是否可用
public void setPullUpEnable(boolean pullUpEnable) {
this.pullUpEnable = pullUpEnable;
}

我们来看下设置下拉不可用的效果图

好了,下拉刷新上拉加载方式已经优化的差不多了。github上优秀的SmartRefreshLayout,实现了许多炫酷的效果。后续还会参考优秀项目进行优化修改。

完整代码下载github

参考文章:

SwipeRefreshLayout使用

0%