Android實現支援所有View的通用的下拉重新整理控制元件

Android實現支援所有View的通用的下拉重新整理控制元件

下拉重新整理對於一個app來說是必不可少的一個功能,在早期大多數使用的是chrisbanes的PullToRefresh,或是修改自該框架的其他庫。而到現在已經有了更多的選擇,github上還是有很多體驗不錯的下拉重新整理。

而下拉重新整理主要有兩種實現方式:
1. 在ListView中新增header和footer,監聽ListView的滑動事件,動態設定header/footer的高度,但是這種方式只適用於ListView,RecyclerView。
2. 第二種方式則是繼承ViewGroup或其子類,監聽事件,通過scroll或Layout的方式移動child。如圖(又分兩種情況)

Layout時將header放到螢幕外面,target則填充滿螢幕。這個也是SwipeRefreshLayout的實現原理(第二種,只下拉header)

這裡寫圖片描述

這兩種(指的是繼承ListView或繼承ViewGroup)下拉重新整理的實現方式主要有以下區別

而今天,我打算先講第二種方式實現方式,繼承ViewGroup,程式碼可以直接參考SwipeRefreshLayout,或者pullToRefresh,或者ultra-pull-to-refresh

一、思考和需求

下拉重新整理需要幾個狀態:Reset–> Pull – > Refreshing – >Completed –>Reset

為了應對各式各樣的下拉重新整理設計,我們應該提供設定自定義的Header,開發者可以通過實現介面從而自定義自己的header。

而且header可以有兩種顯示方式,一種是隻下拉header,另外一種則是header和target一起下拉。

二、著手實現程式碼

2.1 定義Header的介面,建立自定義Layout


/**
* Created by AItsuki on 2016/6/13.
* 
*/
public enum State {
RESET, PULL, LOADING, COMPLETE
}

/**
* Created by AItsuki on 2016/6/13.
*
*/
public interface RefreshHeader {
/**
* 鬆手,頭部隱藏後會回撥這個方法
*/
void reset();
/**
* 下拉出頭部的一瞬間呼叫
*/
void pull();
/**
* 正在重新整理的時候呼叫
*/
void refreshing();
/**
* 頭部滾動的時候持續呼叫
* @param currentPos target當前偏移高度
* @param lastPos target上一次的偏移高度
* @param refreshPos 可以鬆手重新整理的高度
* @param isTouch 手指是否按下狀態(通過scroll自動滾動時需要判斷)
* @param state 當前狀態
*/
void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state);
/**
* 重新整理成功的時候呼叫
*/
void complete();
}


package com.aitsuki.custompulltorefresh;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.ImageView;
/**
* Created by AItsuki on 2016/6/13.
* -
*/
public class RefreshLayout extends ViewGroup {
private View refreshHeader;
private View target;
private int currentTargetOffsetTop; // target偏移距離
private boolean hasMeasureHeader; // 是否已經計算頭部高度
private int touchSlop; 
private int headerHeight; // header高度
private int totalDragDistance; // 需要下拉這個距離才進入鬆手重新整理狀態,預設和header高度一致
public RefreshLayout(Context context) {
this(context, null);
}
public RefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
// 新增預設的頭部,先簡單的用一個ImageView代替頭部
ImageView imageView = new ImageView(context);
imageView.setImageResource(R.drawable.one_piece);
imageView.setBackgroundColor(Color.BLACK);
setRefreshHeader(imageView);
}
/**
* 設定自定義header
*/
public void setRefreshHeader(View view) {
if (view != null && view != refreshHeader) {
removeView(refreshHeader);
// 為header新增預設的layoutParams
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
if (layoutParams == null) {
layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
view.setLayoutParams(layoutParams);
}
refreshHeader = view;
addView(refreshHeader);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (target == null) {
ensureTarget();
}
if (target == null) {
return;
}
// ----- measure target -----
// target佔滿整屏
target.measure(MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
// ----- measure refreshView-----
measureChild(refreshHeader, widthMeasureSpec, heightMeasureSpec);
if (!hasMeasureHeader) { // 防止header重複測量
hasMeasureHeader = true;
headerHeight = refreshHeader.getMeasuredHeight(); // header高度
totalDragDistance = headerHeight; // 需要pull這個距離才進入鬆手重新整理狀態
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();
if (getChildCount() == 0) {
return;
}
if (target == null) {
ensureTarget();
}
if (target == null) {
return;
}
// onLayout執行的時候,要讓target和header加上偏移距離(初始0),因為有可能在滾動它們的時候,child請求重新佈局,從而導致target和header瞬間回到原位。
// target鋪滿螢幕
final View child = target;
final int childLeft = getPaddingLeft();
final int childTop = getPaddingTop()   currentTargetOffsetTop; 
final int childWidth = width - getPaddingLeft() - getPaddingRight();
final int childHeight = height - getPaddingTop() - getPaddingBottom();
child.layout(childLeft, childTop, childLeft   childWidth, childTop   childHeight);
// header放到target的上方,水平居中
int refreshViewWidth = refreshHeader.getMeasuredWidth();
refreshHeader.layout((width / 2 - refreshViewWidth / 2),
-headerHeight   currentTargetOffsetTop,
(width / 2   refreshViewWidth / 2),
currentTargetOffsetTop);
}
/**
* 將第一個Child作為target
*/
private void ensureTarget() {
// Don't bother getting the parent height if the parent hasn't been laid
// out yet.
if (target == null) {
for (int i = 0; i < getChildCount(); i  ) {
View child = getChildAt(i);
if (!child.equals(refreshHeader)) {
target = child;
break;
}
}
}
}
}

MainActivity中的佈局如下,先用一個TextView作為Target


<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.aitsuki.custompulltorefresh.MainActivity">
<com.aitsuki.custompulltorefresh.RefreshLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Target"
android:textSize="30sp"
android:gravity="center"
android:background="#FFDAB9"
/>
</com.aitsuki.custompulltorefresh.RefreshLayout>
</FrameLayout>

執行後結果如圖如下,但是我們還沒有監聽事件,所以此時還無法滑動。

這裡寫圖片描述

2.2 處理事件分發

控制元件已經測量佈局好了,現在就開始處理事件分發,對於事件分發還不瞭解的應該先去複習下……

對於多點觸控的處理:
記錄活動手指的id(activePointerId),通過此ID獲取move事件的座標。
 1.在手指按下的時候,記錄下activePointerId
 2.第二根手指按下的時候,更新activePointerId。(我們讓第二根手指作為活動手指,忽略第一個手指的move)
 3.當其中一根手指擡起時,如果是第一根手指,那麼不做處理,如果是第二根手指擡起,也就是活動手指擡起的話,將活動手指改回第一根。

對於事件分發一般有兩種處理方式
1. 在onIntercept onTouchEvnet中處理
2. 在dispatchTouchEvent中處理
在這裡我選擇了第二種方式

首先了解DispatchTouchEvent返回值的含義
重寫dispatchTouchEvent的時候,無論你是return true,亦或是return false都會導致child接受不到事件。
return true : 告訴parent,這個事件我消費了。如果這個是down事件,那麼我就會作為一個target或者說handle(事件持有者),後續的move事件或者up事件等,都會直接分發到我這裡,不繼續往下分發。
return false:告訴parent,這個事件我不需要,那麼會交回給parent的onTouchEvnet處理
只有return super.dispatchTouchEvent的時候才會將事件繼續往下傳遞。

上面只說了最簡單的一點,如果對事件分發不瞭解的話需要看看,真的很重要。

分析
在dispatch中,即使child響應了事件,我們也能拿到所有事件。
這樣我們就可以很簡單的控制頭部是否能下拉,那麼如何攔截child的事件呢?
可以在合適的時候分發一個cancel事件給child,那麼就相當於攔截了!

雖然我們一直都響應著事件,但肯定是不能所有事件都接收的,以下情況是需要我們處理的
 1.如果是下拉,並且child不能往上滾動
 2.如果上劃,並且target不在頂部的時候
 3.如果是這些時候,我們攔截child的事件(派發cancel事件)

程式碼如下


@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!isEnabled() || target == null) {
return super.dispatchTouchEvent(ev);
}
final int actionMasked = ev.getActionMasked(); // support Multi-touch
switch (actionMasked) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG, "ACTION_DOWN");
activePointerId = ev.getPointerId(0);
isTouch = true; // 手指是否按下
hasSendCancelEvent = false;
mIsBeginDragged = false; // 是否開始下拉
lastTargetOffsetTop = currentTargetOffsetTop; // 上一次target的偏移高度
currentTargetOffsetTop = target.getTop(); // 當前target偏移高度
initDownX = lastMotionX = ev.getX(0); // 手指按下時的座標
initDownY = lastMotionY = ev.getY(0);
super.dispatchTouchEvent(ev);
return true; // return true,否則可能接收不到move和up事件
case MotionEvent.ACTION_MOVE:
if (activePointerId == INVALID_POINTER) {
Log.e(TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
return super.dispatchTouchEvent(ev);
}
lastEvent = ev; // 最後一次move事件
float x = ev.getX(MotionEventCompat.findPointerIndex(ev,activePointerId));
float y = ev.getY(MotionEventCompat.findPointerIndex(ev,activePointerId));
float xDiff = x - lastMotionX;
float yDiff = y - lastMotionY;
float offsetY = yDiff * DRAG_RATE;
lastMotionX = x;
lastMotionY = y;
if(!mIsBeginDragged && Math.abs(y - initDownY) > touchSlop) {
mIsBeginDragged = true;
}
if (mIsBeginDragged) {
boolean moveDown = offsetY > 0; // ↓
boolean canMoveDown = canChildScrollUp();
boolean moveUp = !moveDown; // ↑
boolean canMoveUp = currentTargetOffsetTop > START_POSITION;
// 判斷是否攔截事件
if ((moveDown && !canMoveDown) || (moveUp && canMoveUp)) {
moveSpinner(offsetY);
return true;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
isTouch = false;
activePointerId = INVALID_POINTER;
break;
case MotionEvent.ACTION_POINTER_DOWN:
int pointerIndex = MotionEventCompat.getActionIndex(ev);
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
return super.dispatchTouchEvent(ev);
}
lastMotionX = ev.getX(pointerIndex);
lastMotionY = ev.getY(pointerIndex);
activePointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
break;
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
lastMotionY = ev.getY(ev.findPointerIndex(activePointerId));
lastMotionX = ev.getX(ev.findPointerIndex(activePointerId));
break;
}
return super.dispatchTouchEvent(ev);
}

private void onSecondaryPointerUp(MotionEvent ev) {
final int pointerIndex = MotionEventCompat.getActionIndex(ev);
final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
if (pointerId == activePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
lastMotionY = ev.getY(newPointerIndex);
lastMotionX = ev.getX(newPointerIndex);
activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
}
}

public boolean canChildScrollUp() {
if (android.os.Build.VERSION.SDK_INT < 14) {
if (target instanceof AbsListView) {
final AbsListView absListView = (AbsListView) target;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return ViewCompat.canScrollVertically(target, -1) || target.getScrollY() > 0;
}
} else {
return ViewCompat.canScrollVertically(target, -1);
}
}

以上就是事件的處理,我們還需要在header下拉之前傳送cancel事件給child


private void moveSpinner(float diff) {
int offset = Math.round(diff);
if (offset == 0) {
return;
}
// 傳送cancel事件給child
if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
sendCancelEvent();
hasSendCancelEvent = true;
}
int targetY = Math.max(0, currentTargetOffsetTop   offset); // target不能移動到小於0的位置……
offset = targetY - currentTargetOffsetTop;
setTargetOffsetTopAndBottom(offset);
}

private void setTargetOffsetTopAndBottom(int offset) {
if (offset == 0) {
return;
}
target.offsetTopAndBottom(offset);
refreshHeader.offsetTopAndBottom(offset);
lastTargetOffsetTop = currentTargetOffsetTop;
currentTargetOffsetTop = target.getTop();
invalidate();
}

private void sendCancelEvent() {
if (lastEvent == null) {
return;
}
MotionEvent ev = MotionEvent.obtain(lastEvent);
ev.setAction(MotionEvent.ACTION_CANCEL);
super.dispatchTouchEvent(ev);
}

程式碼有點多,不過沒關係,其實很多都是從SwipeRefreshLayout中複製過來的。
我們來看看程式碼執行後的效果,很不錯,就是模擬器錄屏有點卡=。=

這裡寫圖片描述

換成ListView試試, 也沒有問題。

這裡寫圖片描述

多點觸控也是可以的,但是模擬器我沒法演示了。

2.3 新增自動滾動

頭雖然可以下拉了, 但是拉下來後就不會回去了啊,我們需要在手指鬆開讓頭部自動回到原位。
可以使用動畫,可以使用ValueAnimator計算距離移動,也可以使用Scroller計算距離移動。

但是選擇第三種是比較好的,為什麼呢。
首先如果使用動畫,在回去的過程中我們無法下拉,我們想做的是一個可以在任何時候都能上下拉的,就像ListView新增頭的哪種效果。
valueAnimator也是,不好停止。
但是scroller卻可以使用forceFinish強行停止計算。

鬆開手指時,我們通過scroller計算每次移動的offset,然後呼叫moveSpinner即可。
在手指按下的時候,需要停止scroller。

我們先寫一個內部類,封裝一下滾動功能


private class AutoScroll implements Runnable {
private Scroller scroller;
private int lastY;
public AutoScroll() {
scroller = new Scroller(getContext());
}
@Override
public void run() {
boolean finished = !scroller.computeScrollOffset() || scroller.isFinished();
if (!finished) {
int currY = scroller.getCurrY(); 
int offset = currY - lastY;
lastY = currY;
moveSpinner(offset); // 呼叫此方法移動header和target
post(this);
onScrollFinish(false);
} else {
stop();
onScrollFinish(true);
}
}
public void scrollTo(int to, int duration) {
int from = currentTargetOffsetTop;
int distance = to - from;
stop();
if (distance == 0) {
return;
}
scroller.startScroll(0, 0, 0, distance, duration);
post(this);
}
private void stop() {
removeCallbacks(this);
if (!scroller.isFinished()) {
scroller.forceFinished(true);
}
lastY = 0;
}
}

然後這個是回撥,暫時使用者不上,但還是先寫好吧。


/**
* 在scroll結束的時候會回撥這個方法
* @param isForceFinish 是否是強制結束的
*/
private void onScrollFinish(boolean isForceFinish) {
}

我們在構造中初始化AutoScroll,然後分別在ActionDown和ActionUp中分別呼叫stop和scrollto即可,如下


case MotionEvent.ACTION_DOWN:
//...
autoScroll.stop();
//...
break

case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
//...
if(currentTargetOffsetTop > START_POSITION) {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
//...

執行效果如下圖

這裡寫圖片描述

2.4 新增重新整理狀態

最開始的時候我們也新建了一個列舉,設定了幾種狀態,分別是 RESET, PULL, LOADING, COMPLETE
而我們的初始狀態應該為RESET
private State state = State.RESET;

再分析一下,這幾種狀態什麼時候互相切換:
1. 在RESET狀態時,第一次下拉出現header的時候,設定狀態變成PULL
2. 在PULL或者COMPLETE狀態時,header回到頂部的時候,狀態變回RESET
3. 如果是從底部回到頂部的過程(往上滾動),並且手指是鬆開狀態, 並且當前是PULL狀態,狀態變成LOADING,這時候我們需要強制停止autoScroll。並且正在重新整理中的偵聽器也在這裡呼叫(onRefresh())
4. 在LOADING狀態中,想變成其他狀態,需要提供公共方法給外部呼叫

首先,我們先寫一個改變狀態的方法,在狀態改變的同時要回撥給header。


private void changeState(State state) {
this.state = state;
RefreshHeader refreshHeader = this.refreshHeader instanceof RefreshHeader ? ((RefreshHeader) this.refreshHeader) : null;
if (refreshHeader != null) {
switch (state) {
case RESET:
refreshHeader.reset();
break;
case PULL:
refreshHeader.pull();
break;
case LOADING:
refreshHeader.refreshing();
break;
case COMPLETE:
refreshHeader.complete();
break;
}
}
}

還有,提供外部設定重新整理成功的方法。
因為重新整理成功後需要將header滾動回原位,所以需要做以下判斷
1. 如果已經在原位,那麼直接將狀態改成Reset
2. 如果不在原位,延時500毫秒後自動滾動回原位。這裡延時500毫秒是為了展示重新整理成功的提示,否則在網速很快的情況下,重新整理成功後header立即回到原位體驗性不好,感覺就像是下拉後立即就自動回去了。
3. 在自動回滾時還需要判斷當前手指是否在觸控狀態,如果正在觸控,代表使用者可能並不想header回去,所以這時候我們不能讓頭部滾動。
4. 再者就是,如果在延時的500內,使用者按下了手指,我們需要將這個runnable取消,在ActionDown中RemoveCallBack即可。總的來說一句話就是,使用者必須持有header的絕對控制權,在手指按下時,header不應該出現自動滾動的情況。


public void refreshComplete() {
changeState(State.COMPLETE);
// if refresh completed and the target at top, change state to reset.
if (currentTargetOffsetTop == START_POSITION) {
changeState(State.RESET);
} else {
// waiting for a time to show refreshView completed state.
// at next touch event, remove this runnable
if (!isTouch) {
postDelayed(delayToScrollTopRunnable, SHOW_COMPLETED_TIME);
}
}
}
// 重新整理成功,顯示500ms成功狀態再滾動回頂部,這個runnalbe需要在ActionDown事件中Remove
private Runnable delayToScrollTopRunnable = new Runnable() {
@Override
public void run() {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
};

提供設定正在重新整理回撥的方法
當使用者鬆開手指,進入重新整理狀態時我們需要回撥這個方法。


// 定義一個偵聽器
public interface OnRefreshListener {
void onRefresh();
}
// 提供外部設定方法
public void setRefreshListener(OnRefreshListener refreshListener) {
this.refreshListener = refreshListener;
}

做完以上幾部,我們算是完成了LOADING到COMPLETE的狀態切換,餘下的幾個狀態我們則需要在movespinner這個方法中控制,上面也已經分析過了邏輯,那麼可以直接看程式碼了。


private void moveSpinner(float diff) {
int offset = Math.round(diff);
if (offset == 0) {
return;
}
// 傳送cancel事件給child
if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
sendCancelEvent();
hasSendCancelEvent = true;
}
int targetY = Math.max(0, currentTargetOffsetTop   offset); // target不能移動到小於0的位置……
offset = targetY - currentTargetOffsetTop;
// 1. 在RESET狀態時,第一次下拉出現header的時候,設定狀態變成PULL
if (state == State.RESET && currentTargetOffsetTop == START_POSITION && targetY > 0) {
changeState(State.PULL);
}
// 2. 在PULL或者COMPLETE狀態時,header回到頂部的時候,狀態變回RESET
if (currentTargetOffsetTop > START_POSITION && targetY <= START_POSITION) {
if (state == State.PULL || state == State.COMPLETE) {
changeState(State.RESET);
}
}
// 3. 如果是從底部回到頂部的過程(往上滾動),並且手指是鬆開狀態, 並且當前是PULL狀態,狀態變成LOADING,這時候我們需要強制停止autoScroll
if (state == State.PULL && !isTouch && currentTargetOffsetTop > totalDragDistance && targetY <= totalDragDistance) {
autoScroll.stop();
changeState(State.LOADING);
if (refreshListener != null) {
refreshListener.onRefresh();
}
// 因為判斷條件targetY <= totalDragDistance,會導致不能回到正確的重新整理高度(有那麼一丁點偏差),調整change
int adjustOffset = totalDragDistance - targetY;
offset  = adjustOffset;
}
setTargetOffsetTopAndBottom(offset);
// 別忘了回撥header的位置改變方法。
if(refreshHeader instanceof RefreshHeader) {
((RefreshHeader) refreshHeader)
.onPositionChange(currentTargetOffsetTop, lastTargetOffsetTop, totalDragDistance, isTouch,state);
}
}

而ActionUp的時候也不能單純的讓header回到頂部了,而是需要通過判斷狀態,回到重新整理高度亦或是回到頂部。
1. 重新整理狀態,回到重新整理高度
2. 否則,回到頂部
我們將原本在ActionUp中的autoScroll.scrollto(…)抽取成一個方法再呼叫,如下


private void finishSpinner() {
if (state == State.LOADING) {
if (currentTargetOffsetTop > totalDragDistance) {
autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION);
}
} else {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
}

好了,大功告成!在changeState方法中新增Toast列印一下狀態,來執行下!

Toast.makeText(getContext(), state.toString(), Toast.LENGTH_SHORT).show();

別忘記在Activity中呼叫refreshComplete方法,我們延時三秒後設定重新整理成功!
以下是Activity中的呼叫:


final RefreshLayout refreshLayout = (RefreshLayout) findViewById(R.id.refreshLayout);
if (refreshLayout != null) {
// 重新整理狀態的回撥
refreshLayout.setRefreshListener(new RefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
// 延遲3秒後重新整理成功
refreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
refreshLayout.refreshComplete();
}
}, 3000);
}
});
}

執行結果:我們演示幾種情況
下拉 – >回到頂部 (pull –> reset)

這裡寫圖片描述

下拉 –>重新整理 –> 重新整理成功 –> 回到頂部(pull–>loading–>complete–>reset)

這裡寫圖片描述

下拉 –>重新整理 –> 重新整理成功 –> 回到頂部(手指按下,不讓header回到頂部)

這裡寫圖片描述

完全沒有問題,體驗還是可以的!這樣我們就完成了一個下拉重新整理控制元件了!

三、自定義預設的Header

下拉重新整理是弄好了,但是我們的header也太寒磣太敷衍了吧!
現在我們就來自定義一個header,包含一個旋轉的箭頭,還有文字提示!但是我不準備提供時間提示了~普通點,和QQ一樣的這裡寫圖片描述

首先我們需要一些圖片資源,從QQ的apk解壓獲取到

這裡寫圖片描述

先來定義幾個旋轉動畫

rotate_down.xml


<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fillAfter="true"
android:fromDegrees="-180"
android:pivotX="50%"
android:pivotY="50%"
android:repeatCount="0"
android:toDegrees="0" />

rotate_up.xml


<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fillAfter="true"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="180" />

rotate_infinite.xml


<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fillAfter="true"
android:fromDegrees="180"
android:interpolator="@android:anim/linear_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:repeatCount="0"
android:toDegrees="0" />

header程式碼如下


import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.TextView;
/**
* Created by AItsuki on 2016/6/15.
*
*/
public class QQRefreshHeader extends FrameLayout implements RefreshHeader {
private Animation rotate_up;
private Animation rotate_down;
private Animation rotate_infinite;
private TextView textView;
private View arrowIcon;
private View successIcon;
private View loadingIcon;
public QQRefreshHeader(Context context) {
this(context, null);
}
public QQRefreshHeader(Context context, AttributeSet attrs) {
super(context, attrs);
// 初始化動畫
rotate_up = AnimationUtils.loadAnimation(context , R.anim.rotate_up);
rotate_down = AnimationUtils.loadAnimation(context , R.anim.rotate_down);
rotate_infinite = AnimationUtils.loadAnimation(context , R.anim.rotate_infinite);
inflate(context, R.layout.header_qq, this);
textView = (TextView) findViewById(R.id.text);
arrowIcon = findViewById(R.id.arrowIcon);
successIcon = findViewById(R.id.successIcon);
loadingIcon = findViewById(R.id.loadingIcon);
}
@Override
public void reset() {
textView.setText(getResources().getText(R.string.qq_header_reset));
successIcon.setVisibility(INVISIBLE);
arrowIcon.setVisibility(VISIBLE);
arrowIcon.clearAnimation();
loadingIcon.setVisibility(INVISIBLE);
loadingIcon.clearAnimation();
}
@Override
public void pull() {
}
@Override
public void refreshing() {
arrowIcon.setVisibility(INVISIBLE);
loadingIcon.setVisibility(VISIBLE);
textView.setText(getResources().getText(R.string.qq_header_refreshing));
arrowIcon.clearAnimation();
loadingIcon.startAnimation(rotate_infinite);
}
@Override
public void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state) {
// 往上拉
if (currentPos < refreshPos && lastPos >= refreshPos) {
if (isTouch && state == State.PULL) {
textView.setText(getResources().getText(R.string.qq_header_pull));
arrowIcon.clearAnimation();
arrowIcon.startAnimation(rotate_down);
}
// 往下拉
} else if (currentPos > refreshPos && lastPos <= refreshPos) {
if (isTouch && state == State.PULL) {
textView.setText(getResources().getText(R.string.qq_header_pull_over));
arrowIcon.clearAnimation();
arrowIcon.startAnimation(rotate_up);
}
}
}
@Override
public void complete() {
loadingIcon.setVisibility(INVISIBLE);
loadingIcon.clearAnimation();
successIcon.setVisibility(VISIBLE);
textView.setText(getResources().getText(R.string.qq_header_completed));
}
}

我們來看看執行結果,完美~

這裡寫圖片描述

四、自動下拉重新整理

是不是覺得還少了點什麼?沒錯,就是自動重新整理了!
很多時候,我們進入某個頁面,初始化是需要自動重新整理資料,這時候就需要用到自動重新整理了,不需要使用者手動。

分析:
1. 重新整理狀態都是在moveSpinner中變更的,而autoScroll正好是呼叫moveSpinner實現滾動
2. 我們可以呼叫autoScroll方法,讓它滾動到重新整理高度,然後再呼叫finishSpinner方法,讓控制元件進入Loading狀態
3. 自動重新整理一般是在Activity的onCreate的這個生命週期執行,此時介面可能還沒有繪製完畢,可以通過postDelay方法延遲個幾百毫秒,保證介面顯示正常。
4. 而如果在postDelay的延遲時間中,使用者如果點選了介面,我們應該將自動重新整理功能移除。

首先我們定義公共方法:


public void autoRefresh() {
autoRefresh(500);
}
/**
* 在onCreate中呼叫autoRefresh,此時View可能還沒有初始化好,需要延長一段時間執行。
*
* @param duration 延時執行的毫秒值
*/
public void autoRefresh(long duration) {
if (state != State.RESET) {
return;
}
postDelayed(autoRefreshRunnable, duration);
}

runnable


// 自動重新整理,需要等View初始化完畢才呼叫,否則頭部不會滾動出現
private Runnable autoRefreshRunnable = new Runnable() {
@Override
public void run() {
// 標記當前是自動重新整理狀態,finishScroll呼叫時需要判斷
// 在actionDown事件中重新標記為false
isAutoRefresh = true;
changeState(State.PULL);
autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION);
}
};

當autoScroll滾動結束的時候,會回撥這個方法,判斷如果是自動重新整理,將狀態設定為Loading,並且呼叫finishSpinner方法。


/**
* 滾動結束回撥
*
* @param isForceFinish 是否強制停止
*/
private void onScrollFinish(boolean isForceFinish) {
if (isAutoRefresh && !isForceFinish) {
isAutoRefresh = false;
changeState(State.LOADING);
if (refreshListener != null) {
refreshListener.onRefresh();
}
finishSpinner();
}
}

搞定,在Activity中呼叫
refreshLayout.autoRefresh();

這裡寫圖片描述

五、新增滑動阻力

目前還有個問題,控制元件可以無限下拉(多點觸控),我們應該讓阻力隨著滑動距離的增大而逐漸增加,直到劃不動為止。

我們可以用到這個方程

y是阻力,控制在0~1。
x是target偏移量超出重新整理高度的百分比,控制在0~2。

程式碼如下,寫在moveSpinnner中。


// y = x - (x/2)^2
float extraOS = targetY - totalDragDistance;
float slingshotDist = totalDragDistance;
float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) / slingshotDist);
float tensionPercent = (float) (tensionSlingshotPercent - Math.pow(tensionSlingshotPercent / 2, 2));
if(offset > 0) { // 下拉的時候才新增阻力
offset = (int) (offset * (1f - tensionPercent));
targetY = Math.max(0, currentTargetOffsetTop   offset);
}

那麼,一個體驗還算不錯的下拉重新整理控制元件就這麼完成了
部分程式碼參考自SwipeRefreshLayout和UltraPullToRefresh
這是Demo下載地址:
https://github.com/AItsuki/CustomPullToRefresh

下一篇博文不出意外應該會實現ListView和Recycler的下拉重新整理和載入更多的功能,主要特點就是,他們都可以直接使用本篇文中實現的QQheader。
出處: http://blog.csdn.net/u010386612/article/details/51372696
本文出自:【AItsuki的部落格】
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援指令碼之家。

您可能感興趣的文章:

Android PullToRefreshLayout下拉重新整理控制元件的終結者Android下拉重新整理上拉載入控制元件(適用於所有View)Android官方下拉重新整理控制元件SwipeRefreshLayout使用詳解Android自定義組合控制元件之自定義下拉重新整理和左滑刪除例項程式碼Android下拉重新整理控制元件SwipeRefreshLayout原始碼解析Android自定義控制元件開發實戰之實現ListView下拉重新整理例項程式碼Android控制元件RefreshableView實現下拉重新整理Android自定義View控制元件實現重新整理效果Android開發之無痕過渡下拉重新整理控制元件的實現思路詳解親自動手編寫Android通用重新整理控制元件