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