編輯:關於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開發之無痕過渡下拉刷新控件的實現思路詳解,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對本站網站的支持!
基礎知識 繞Z軸旋轉角度:Azimuth,我稱之為正北轉角(指南針的磁北方向) 繞X軸旋轉角度:Pitch,我稱
本文實例講述了Android編程實現網絡圖片查看器和網頁源碼查看器。分享給大家供大家參考,具體如下:網絡圖片查看器清單文加入網絡訪問權限:<!-- 訪問intern
之前實現過一次這種效果的ExpandableListView:http://www.jb51.net/article/38482.htm,帶效果比較挫,最近,在參考聯系人
Tab標簽頁是UI設計時經常使用的UI控件,可以實現多個分頁之間的快速切換,每個分頁可以顯示不同的內容。 TabHost相當於浏覽器中標簽頁分布的集合,而Tabspec