Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android開發之無痕過渡下拉刷新控件的實現思路詳解

Android開發之無痕過渡下拉刷新控件的實現思路詳解

編輯:關於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開發之無痕過渡下拉刷新控件的實現思路詳解,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對本站網站的支持!

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved