簡述
其實想法很簡單,為了能夠在絕大部分場景使用一個通用的控件來進(jìn)行上拉加載和下拉刷新,避免因為Listview切換RecyclerView之類的情況導(dǎo)致需要重新寫一套代碼。
效果圖
先看一下基礎(chǔ)的效果,demo做得比較簡陋

思路
1.自定義一個ViewGroup
2.ViewGroup里面應(yīng)該有三個控件,一個是下拉刷新的時候要顯示的視圖,一個是內(nèi)容控件,一個是上拉加載的時候要顯示的視圖
3.然后按照一定的方式進(jìn)行視圖的擺放:

4.那么在豎直滑動的時候,需要處理的就是記錄當(dāng)前的偏移量,只要偏移量達(dá)到一定的程度,觸發(fā)一些對應(yīng)的回調(diào)即可
從xml中加載
目前使用的時候希望做的是在xml中使用,因為如果addView的話不符合設(shè)計,在LayoutInflater加載完布局的時候,會進(jìn)行onFinishInflate回調(diào),所以說在這里進(jìn)行判斷,即可知道xml中設(shè)置的子視圖情況。
@Override
protected void onFinishInflate() {
super.onFinishInflate();
int childCount = getChildCount();
switch (childCount) {
case 1://這種時候默認(rèn)只有一個內(nèi)容視圖
mContentView = getChildAt(0);
break;
case 2://默認(rèn)優(yōu)先支持頂部刷新
mContentView = getChildAt(0);
mHeaderView = getChildAt(1);
break;
case 3:
mContentView = getChildAt(0);
mHeaderView = getChildAt(1);
mFooterView = getChildAt(2);
break;
default:
throw new IllegalArgumentException("必須包括1到3個子視圖");
}
checkHeaderAndFooterAndAddListener();
}
測量
測量的時候主要考慮margin即可,其它方面正常測量即可
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildWithMargins(mContentView, widthMeasureSpec, 0, heightMeasureSpec, 0);
MarginLayoutParams lp = null;
if (null != mHeaderView) {
measureChildWithMargins(mHeaderView, widthMeasureSpec, 0, heightMeasureSpec, 0);
lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
mHeaderHeight = mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
if (null != mFooterView) {
measureChildWithMargins(mFooterView, widthMeasureSpec, 0, heightMeasureSpec, 0);
lp = (MarginLayoutParams) mFooterView.getLayoutParams();
mFooterHeight = mFooterView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
}
目前不支持wrap_content的模式,因為正常使用來說基本上可以認(rèn)為高度都是固定的。
ViewGroup默認(rèn)的onMeasure只會計算并且設(shè)置自身的測量寬高,所以說需要額外測量ViewGroup里面的子視圖,而在這里,里面最多就三個子視圖。
布局
布局的初始狀態(tài)如上圖,因為布局本身存在滑動的狀態(tài),那么也就是說偏移量本身也要作為布局的考量:

圖中藍(lán)色和灰色部分重疊的部分其實就是偏移量,這個標(biāo)示當(dāng)前ViewGroup從初始位置移動的偏移量。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left, top;
MarginLayoutParams lp;
lp = (MarginLayoutParams) mContentView.getLayoutParams();
left = (getPaddingLeft() + lp.leftMargin);
if (mOption.isContentFixed()) {
top = (getPaddingTop() + lp.topMargin);
}else{
top = (getPaddingTop() + lp.topMargin) + mCurrentOffset;
}
mContentView.layout(left, top, left + mContentView.getMeasuredWidth(), top + mContentView.getMeasuredHeight());
if (null != mHeaderView) {
lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
left = (getPaddingLeft() + lp.leftMargin);
top = (getPaddingTop() + lp.topMargin) - mHeaderHeight + mCurrentOffset;
mHeaderView.layout(left, top, left + mHeaderView.getMeasuredWidth(), top + mHeaderView.getMeasuredHeight());
}
if (null != mFooterView) {
lp = (MarginLayoutParams) mFooterView.getLayoutParams();
left = (getPaddingLeft() + lp.leftMargin);
top = (b - t - getPaddingBottom() + lp.topMargin) + mCurrentOffset;
mFooterView.layout(left, top, left + mFooterView.getMeasuredWidth(), top + mFooterView.getMeasuredHeight());
}
}
其實從一些場景上面考慮,比方說SwipeRefreshLayout這種視圖內(nèi)容不變,有刷新組件進(jìn)入的情況也是非常常見的,所以說一共支持兩種布局方式。
1.布局固定模式,這種模式下偏移量不影響內(nèi)容視圖的布局位置即可,偏移量只會影響頭部和底部視圖。
2.布局跟隨滑動模式,這種模式下偏移量會影響所有子視圖的布局。
手勢攔截
先看事件的攔截,因為我認(rèn)為這個布局一般位于頂層,所以我沒有默認(rèn)添加禁止父布局?jǐn)r截事件,需要的可以自行添加。
默認(rèn)的情況下我也沒有支持fling操作,因為我認(rèn)為絕大多數(shù)情況下滑動的主體應(yīng)該是內(nèi)容視圖。
對于這個控件來說,重點只是是處理滑動的手勢,其實攔截事件的邏輯也非常簡單,結(jié)合當(dāng)前視圖能否上拉/下拉和當(dāng)前滑動手勢的方向判斷即可。
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
return false;
}
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_MOVE:
int x = (int) event.getX();
int y = (int) event.getY();
int deltaY = (y - mLastPoint.y);
int dy = Math.abs(deltaY);
int dx = Math.abs(x - mLastPoint.x);
Log.d(getClass().getSimpleName(), "dx-->" + dx + "--dy-->" + dy + "--touchSlop-->" + mTouchSlop);
if (dy > mTouchSlop && dy >= dx) {
canUp = mOption.canUpToDown();
canDown = mOption.canDownToUp();
Log.d(getClass().getSimpleName(), "canUp-->" + canUp + "--canDown-->" + canDown + "--deltaY-->" + deltaY);
canUpIntercept = (deltaY > 0 && canUp);
canDownIntercept = (deltaY < 0 && canDown);
return canUpIntercept || canDownIntercept;
}
return false;
}
mLastPoint.set((int) event.getX(), (int) event.getY());
return false;
}
手勢處理
當(dāng)確定要攔截手勢之后,接著就是處理手勢,從功能上面來說,主要就是滑動的時候移動視圖和松手的時候嘗試觸發(fā)刷新這兩塊。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
return false;
}
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_MOVE:
isOnTouch = true;
updatePos((int) (event.getY() - mLastPoint.y));
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
isOnTouch = false;
if (mCurrentOffset > 0) {
tryPerformRefresh();
} else if(mCurrentOffset < 0){
tryPerformLoading();
}
break;
}
mLastPoint.set((int) event.getX(), (int) event.getY());
return true;
}
可以看到,主要就是在MOVE的時候進(jìn)行了視圖位置變化的處理,接著看邏輯:
private void updatePos(int deltaY) {
if (!hasHeaderOrFooter() || deltaY == 0) {//不需要偏移
return;
}
if (isOnTouch) {
if (!canUp && (mCurrentOffset + deltaY > 0)) {//此時偏移量不應(yīng)該>0
deltaY = (0 - mCurrentOffset);
} else if (!canDown && (mCurrentOffset + deltaY < 0)) {//此時偏移量不應(yīng)該<0
deltaY = (0 - mCurrentOffset);
}
}
mPrevOffset = mCurrentOffset;
mCurrentOffset += deltaY;
mCurrentOffset = Math.max(Math.min(mCurrentOffset, mOption.getMaxDownOffset()), mOption.getMaxUpOffset());
deltaY = mCurrentOffset - mPrevOffset;
if (deltaY == 0) {//不需要偏移
return;
}
callUIPositionChangedListener(mPrevOffset, mCurrentOffset);
if (mCurrentOffset >= mOption.getRefreshOffset()) {
callCanRefreshListener();
} else if (mCurrentOffset <= mOption.getLoadMoreOffset()) {
callCanLoadMoreListener();
}
if (!mOption.isContentFixed()) {
mContentView.offsetTopAndBottom(deltaY);
}
if (null != mHeaderView) {
mHeaderView.offsetTopAndBottom(deltaY);
}
if (null != mFooterView) {
mFooterView.offsetTopAndBottom(deltaY);
}
invalidate();
}
其實主要是做幾件事情:
1.確定當(dāng)前視圖最多可以的滑動距離,從而得出實際滑動距離
2.進(jìn)行一系列的回調(diào),這個后面會說
3.通過View的offsetTopAndBottom來進(jìn)行豎直方向的移動,如果是內(nèi)容固定模式,則內(nèi)容視圖不應(yīng)該移動
這樣就可以實現(xiàn)視圖的上下滑動
回調(diào)
實際上這個控件的核心應(yīng)該是在回調(diào)處理當(dāng)中,因為無論是怎么滑動,最重要的就是實現(xiàn)滑動過程中的交互和觸發(fā)刷新之類的回調(diào),這樣可以讓使用的人實現(xiàn)不同的效果。
首先看回調(diào)接口
public interface IRefreshListener {
void onBeforeRefresh();//當(dāng)前偏移量沒有達(dá)到刷新的標(biāo)準(zhǔn)時松手,然后頭部開始回彈的回調(diào)
void onRefreshBegin();//開始刷新的回調(diào)
void onUIPositionChanged(int oldOffset, int newOffset, int refreshOffset);//視圖滑動過程中的回調(diào)
void onRefreshComplete();//刷新完成的回調(diào)
void onCanRefresh();//當(dāng)前偏移量已經(jīng)超過刷新的標(biāo)準(zhǔn)的時候,還在滑動的話會觸發(fā)的回調(diào)
}
這里是頂部刷新的回調(diào),底部加載的回調(diào)和這個類似。
使用的時候主要是頂部和底部視圖通過實現(xiàn)這個接口,根據(jù)不同的狀態(tài)實現(xiàn)樣式上面的變化即可。
刷新處理
因為刷新回調(diào)處理和底部其實是類似的,這里只說明刷新
重點看一下頂部刷新回調(diào)觸發(fā)的條件:
private void tryPerformRefresh() {
if (isOnTouch || isRefreshing || isNestedScrolling) {//觸摸中或者刷新中不進(jìn)行回調(diào)
return;
}
if (mCurrentOffset >= mOption.getRefreshOffset()) {
startRefreshing();
} else {//沒有達(dá)到刷新條件,還原狀態(tài)
mScroller.trySmoothScrollToOffset(0);
if(mCurrentOffset > 0) {
callBeforeRefreshListener();
}
}
}
其實就是當(dāng)前偏移量超過刷新觸發(fā)標(biāo)準(zhǔn)的時候開始刷新處理,然后在里面進(jìn)行回調(diào)
默認(rèn)的情況下頂部刷新和底部加載同時只能回調(diào)一個,并且回彈的時候默認(rèn)是通過Scroller來進(jìn)行緩慢滑動
private void startRefreshing() {
isRefreshing = true;
callRefreshBeginListener();
mScroller.trySmoothScrollToOffset(mOption.getRefreshOffset());
}
1.標(biāo)記當(dāng)前刷新中
2.進(jìn)行刷新開始回調(diào)
3.因為當(dāng)前偏移量可能大于刷新觸發(fā)的大小,這里會通過緩慢滑動回到刷新觸發(fā)的位置
在一般的場景中,這里的回調(diào)會進(jìn)行網(wǎng)絡(luò)請求,然后在請求完成后要恢復(fù)原狀,那么這個時候應(yīng)該手動通知視圖
public void refreshComplete() {
if (!isRefreshing) {
return;
}
callRefreshCompleteListener();
postDelayed(new Runnable() {
@Override
public void run() {
if(null != getContext()) {
isRefreshing = false;
mScroller.trySmoothScrollToOffset(0);
}
}
},mOption.getRefreshCompleteDelayed());
}
1.進(jìn)行刷新完成回調(diào)
2.根據(jù)設(shè)置的參數(shù)進(jìn)行延遲完成回調(diào),這里可以用于在刷新完成之后顯示1s的刷新完成提示之類的需求
3.最終的處理就是標(biāo)志當(dāng)前未處于刷新中,并且通過緩慢移動將視圖恢復(fù)原樣
緩慢滑動
緩慢滑動的意思就是在一定時間間隔內(nèi)從某一個位置滑動到另一個位置,這樣對于用戶的體驗會好很多。
在Android中一般通過Scroller作為計算器,通過Scroller可以比較方便的進(jìn)行分段和計算,那么需要做的就是在一些特定的時機(jī)里面進(jìn)行處理。
實際上就是把一段時間的移動分割成為非常多的一小段的移動,至于移動多少,這個在Scroller里面有計算。
private class ScrollerWorker implements Runnable {
public static final int DEFAULT_SMOOTH_TIME = 400;//ms
public static final int AUTO_REFRESH_SMOOTH_TIME = 200;//ms,自動刷新和自動加載時布局彈出時間
private int mSmoothScrollTime;
private int mLastY;//上次的Y坐標(biāo)偏移量
private Scroller mScroller;//間隔計算執(zhí)行者
private Context mContext;//上下文
private boolean isRunning;//當(dāng)前是否運(yùn)行中
public ScrollerWorker(Context mContext) {
this.mContext = mContext;
mScroller = new Scroller(mContext);
mSmoothScrollTime = DEFAULT_SMOOTH_TIME;
}
public void setSmoothScrollTime(int mSmoothScrollTime) {
this.mSmoothScrollTime = mSmoothScrollTime;
}
@Override
public void run() {
boolean isFinished = (!mScroller.computeScrollOffset() || mScroller.isFinished());
if (isFinished) {
if(mScroller.getCurrY() != mLastY){//Scroller會在一些情況下突然結(jié)束,這里就是處理這個情況
checkScrollerAndRun();
}
end();
} else {
checkScrollerAndRun();
}
}
private void checkScrollerAndRun(){
int y = mScroller.getCurrY();
int deltaY = (y - mLastY);
boolean isDown = ((mPrevOffset == mOption.getRefreshOffset()) && deltaY > 0);
boolean isUp = ((mPrevOffset == mOption.getLoadMoreOffset()) && deltaY < 0);
if (isDown || isUp) {//不需要進(jìn)行多余的滑動
end();
return;
}
updatePos(deltaY);
mLastY = y;
post(this);
}
/**
* 嘗試緩慢滑動到指定偏移量
*
* @param targetOffset 需要滑動到的偏移量
*/
public void trySmoothScrollToOffset(int targetOffset) {
if (!hasHeaderOrFooter()) {
return;
}
endScroller();
removeCallbacks(this);
mLastY = 0;
int deltaY = (targetOffset - mCurrentOffset);
mScroller.startScroll(0, 0, 0, deltaY, mSmoothScrollTime);
isRunning = true;
post(this);
}
/**
* 結(jié)束Scroller
*/
private void endScroller() {
if (!mScroller.isFinished()) {
mScroller.forceFinished(true);
}
mScroller.abortAnimation();
}
/**
* 停止并且還原滑動工作
*/
public void end() {
removeCallbacks(this);
endScroller();
isRunning = false;
mLastY = 0;
}
}
這里就是通過post不斷的進(jìn)行回調(diào),然后Scroller不斷的進(jìn)行計算,在有效滑動的過程中,通過之前的滑動方法來進(jìn)行一小段距離的滑動,最后產(chǎn)生的效果就是一段時間的緩慢移動。
嵌套滑動
有的時候可能內(nèi)容視圖是RecyclerView、NestedScrollView這些支持嵌套滑動的視圖,那么作為父視圖可能也要接受嵌套滑動會好一點。
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
//只接收豎直方向上面的嵌套滑動
boolean isVerticalScroll = (nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL);
boolean canTouchMove = isEnabled() && hasHeaderOrFooter();
return !disabledNestedScrolling && isVerticalScroll && canTouchMove;
}
@Override
public void onStopNestedScroll(View child) {
if(disabledNestedScrolling){
return;
}
mParentHelper.onStopNestedScroll(child);
if (isNestedScrolling) {
isNestedScrolling = false;
isOnTouch = false;
if (mCurrentOffset >= mOption.getRefreshOffset()) {
startRefreshing();
} else if(mCurrentOffset <= mOption.getLoadMoreOffset()){
startLoading();
} else {//沒有達(dá)到刷新條件,還原狀態(tài)
mScroller.trySmoothScrollToOffset(0);
if(mCurrentOffset < 0){
callBeforeLoadMoreListener();
}else if(mCurrentOffset > 0){
callBeforeRefreshListener();
}
}
}
}
@Override
public void onNestedScrollAccepted(View child, View target, int axes) {
mParentHelper.onNestedScrollAccepted(child, target, axes);
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
if(disabledNestedScrolling){
return;
}
if (isNestedScrolling) {
canUp = mOption.canUpToDown();
canDown = mOption.canDownToUp();
int minOffset = canDown?mOption.getMaxUpOffset():0;
int maxOffset = canUp?mOption.getMaxDownOffset():0;
int nextOffset = (mCurrentOffset - dy);
int sureOffset = Math.min(Math.max(minOffset,nextOffset),maxOffset);
int deltaY = sureOffset - mCurrentOffset;
consumed[1] = (-deltaY);
updatePos(deltaY);
}
dispatchNestedPreScroll(dx, dy, consumed, null);
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
if(disabledNestedScrolling){
return;
}
boolean canTouch = !isLoading && !isRefreshing && !isOnTouch;
if (dyUnconsumed != 0 && canTouch) {
canUp = mOption.canUpToDown();
canDown = mOption.canDownToUp();
boolean canUpToDown = (canUp && dyUnconsumed < 0);
boolean canDownToUp = (canDown && dyUnconsumed > 0);
if(canUpToDown || canDownToUp){
isOnTouch = true;
isNestedScrolling = true;
updatePos(-dyUnconsumed);
dyConsumed = dyUnconsumed;
dyUnconsumed = 0;
}
}
dispatchNestedScroll(dxConsumed,dxUnconsumed,dyConsumed,dyUnconsumed,null);
}
這里自定義了一個標(biāo)記來作為是否可以進(jìn)行嵌套滑動(并沒有使用系統(tǒng)的)
1.只處理豎直方向的嵌套滑動
2.當(dāng)前只有在子視圖還有沒消費(fèi)的偏移量的前提下,并且當(dāng)前可以觸發(fā)刷新的條件下才進(jìn)行嵌套滑動,比方說RecyclerView滑動到頂部,然后接著滑,此時頂部視圖就會出現(xiàn)
3.一旦開始嵌套滑動,后續(xù)子視圖在滑動之前,滑動偏移量都會被當(dāng)前視圖先使用
4.嵌套滑動結(jié)束后,要進(jìn)行刷新回調(diào)等的判斷,從而進(jìn)行一些回調(diào)或者緩慢滑動回一些位置的操作,類似手指松開
總結(jié)
目前個人已經(jīng)在項目中使用,主要是兩個場景:
1.首頁的頂部刷新,首頁本身是一個沉浸式交互的頁面,內(nèi)容主題是FrameLayout,但是其實重點是里面的RecyclerView,這個時候使用這個視圖就非常方便
2.列表加載,用于在加載完成后直接在頭部提示用戶當(dāng)前加載了多少新的數(shù)據(jù)
可能還有很多細(xì)節(jié)沒有說到以及一些實現(xiàn)上細(xì)節(jié)的漏洞,有點興趣的可以去看源碼以及源碼里面的demo
最后附上源碼地址:https://github.com/dda135/PullRefreshLayout