相信大家已經對下拉刷新熟悉得不能再熟悉了,市面上的下拉刷新琳瑯滿目,然而有很多在我看來略有缺陷,接下來我將說明一下存在的缺陷問題,然后提供一種思路來解決這一缺陷,廢話不多說!往下看嘞!
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;// 頭部,為了方便演示選取了TextViewprivate 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() {@Overridepublic 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() {@Overridepublic 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() {@Overridepublic 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);}});}在填充完布局后取出內容控件
@Overrideprotected 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讓內容頁重新定下滑動起點,會有一瞬間滑動一大段距離的坑爹效果。
@Overridepublic 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();// 控制頭部位置最大不超過-mHeaderHeightif (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;@Overridepublic void onScrolled(RecyclerView recyclerView, int dx, int dy) {super.onScrolled(recyclerView, dx, dy);offset += dy;setContentViewOffset(offset);}}/*** 適用于NestedScrollView的滑動距離監聽*/public class NestedScrollViewOnScrollChangeListener implements NestedScrollView.OnScrollChangeListener {@Overridepublic void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {setContentViewOffset(scrollY);}}/*** 適用于WebView的滑動距離監聽*/public class WebViewOnTouchListener implements View.OnTouchListener {@Overridepublic boolean onTouch(View view, MotionEvent motionEvent) {setContentViewOffset(view.getScrollY());return false;}}/*** 適用于ScrollView的滑動距離監聽*/public class ScrollViewOnTouchListener extends WebViewOnTouchListener {}/*** 適用于ListView的滑動距離監聽*/public class ListViewOnScrollListener implements AbsListView.OnScrollListener {@Overridepublic void onScrollStateChanged(AbsListView absListView, int i) {}@Overridepublic 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() {@Overridepublic 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_DURATIONmHeaderAnimator.setDuration(DEFAULT_DURATION);mHeaderAnimator.start();}3.3.效果展示



除了以上三個還有在Demo中實現了ListView、ViewPager、ScrollView、NestedScrollView,具體看代碼即可
Demo地址:Github:RefreshLayoutDemo,覺得還不錯的話給個Star哦。
以上所述是小編給大家介紹的Android開發之無痕過渡下拉刷新控件的實現思路詳解,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對武林網網站的支持!
新聞熱點
疑難解答