打造通用的Android下拉重新整理元件 適用於ListView GridView等各類View
分享一下我老師大神的人工智慧教程!零基礎,通俗易懂!http://blog.csdn.net/jiangjunshow
也歡迎大家轉載本篇文章。分享知識,造福人民,實現我們中華民族偉大復興!
前言
最近在做專案時,使用了一個開源的下拉重新整理ListView元件,極其的不穩定,bug還多。穩定的元件又寫得太複雜了,jar包較大。在我的一篇部落格中也講述過下拉重新整理的實現,即
基本原理
原理就是自定義一個ViewGroup,將Header View, Content View, Footer View從上到下依次佈局,如圖1 (紅色區域為螢幕的顯示區域)。在初始時通過滾動,使得該元件在Y軸方向上滾動HeaderView的高度的距離,這樣HeaderView就被隱藏掉了,如圖2。而Content View的寬度和高度都是match_parent的,因此此時螢幕上只顯示Content View, HeaderView 和 FooterView都被隱藏在螢幕外了。當元件被滾動到頂端時,如果使用者繼續下拉,那麼攔截觸控事件,然後通過Scroller來滾動y軸的偏移量,實現逐步的顯示HeaderView,從而到達下拉的效果,如圖3。當用戶滑動到最底部時會觸發載入更多的操作。
圖 1 (紅色區域為螢幕) 圖2 (紅色區域為螢幕) 圖 3(紅色區域為螢幕)
通過使用Scroller使得整個滾動更加的平滑,而使用Margin來實現的話需要自己來計算滾動時間和margin值,並不是很流暢,而且頻繁的修改佈局引數效率也不高。使用Scroller只是滾動位置,而沒有修改佈局引數,因此有點較為突出。
Scroller的使用
為了更好的理解下拉刷的實現,我們先要了解Scroller的作用以及如何使用。這裡我們將做一個簡單的示例來說明。
Scroller是一個幫助View滾動的輔助類,在使用它之前使用者需要通過startScroll來設定滾動的引數,即起始點座標和x,y軸上要滾動的距離。Scroller它封裝了滾動時間、要滾動的目標x軸和y軸,以及在每個時間內view應該滾動到的x,y軸的座標點,這樣使用者就可以在有效的滾動週期內通過Scroller的getCurX()和getCurY()來獲取當前時刻View應該滾動的位置,然後通過呼叫View的scrollTo或者ScrollBy方法進行滾動。那麼如何判斷滾動是否結束呢 ? 我們只需要覆寫View類的computeScroll方法,該方法會在View繪製的時候被呼叫,在裡面呼叫Scroller的computeScrollOffset來判斷滾動是否完成,如果返回true表明滾動未完成,否則滾動完成。上述說的scrollTo或者ScrollBy的呼叫就是在computeScrollOffset為true的情況下呼叫,並且最後還要呼叫目標view的postInvalidate()或者invalidate()以實現View的重繪。View的重繪又會導致computeScroll方法被呼叫,從而繼續整個滾動過程,直至computeScrollOffset返回false, 即滾動結束。整個過程有點繞,我們看一個例子吧。
public class ScrollLayout extends FrameLayout { private String TAG = ScrollLayout.class.getSimpleName(); Scroller mScroller ; public ScrollLayout(Context context) { super(context); mScroller = new Scroller(context) ; } // 該函式會在View重繪之時被呼叫 @Override public void computeScroll() { if ( mScroller.computeScrollOffset() ) { // 滾動到此刻View應該滾動到的x,y座標上. this.scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); // 請求重繪該View,從而又會導致computeScroll被呼叫,然後繼續滾動,直到computeScrollOffset返回false this.postInvalidate(); } } // 呼叫這個方法進行滾動,這裡我們只滾動豎直方向, public void scrollTo(int y) { // 引數1和引數2分別為滾動的起始點水平、豎直方向的滾動偏移量 // 引數3和引數4為在水平和豎直方向上滾動的距離 mScroller.startScroll(getScrollX(), getScrollY(), 0, y); this.invalidate(); }}
滾動該檢視的程式碼 :
ScrollLayout scrollView = new ScrollLayout(getContext()) ; scrollView.scrollTo(100);
通過上面這段程式碼會讓scrollView在y軸上向下滾動100個畫素點。我們結合程式碼來分析一下。首先呼叫scrollTo(int y)方法,然後我們在該方法中通過mScroller.startScroll()方法來設定了滾動的引數,然後呼叫invalidate()方法使得該View重繪。重繪時會呼叫computeScroll方法,在該方法中通過mScroller.computeScrollOffset()判斷滾動是否完成,如果返回true那代表沒有滾動完成,此時把該View滾動到此刻View應該滾動到的x, y位置,這個位置通過mScroller的getCurX, getCurY獲得。然後繼續呼叫重繪方法,繼續執行滾動過程,直至滾動完成。瞭解了Scroller原理後,我們繼續看通用的下拉重新整理元件的實現吧。
下拉重新整理實現
程式碼量不算多,但是也挺長的,我們這裡只拿出重要的點來分析,完成的原始碼在博文最後會給出。以下是重要的程式碼段 :
/** * @author mrsimple */public abstract class RefreshLayoutBase<T extends View> extends ViewGroup implements OnScrollListener { /** * */ protected Scroller mScroller; /** * 下拉重新整理時顯示的header view */ protected View mHeaderView; /** * 上拉載入更多時顯示的footer view */ protected View mFooterView; /** * 本次觸控滑動y座標上的偏移量 */ protected int mYOffset; /** * 內容檢視, 即使用者觸控導致下拉重新整理、上拉載入的主檢視. 比如ListView, GridView等. */ protected T mContentView; /** * 最初的滾動位置.第一次佈局時滾動header的高度的距離 */ protected int mInitScrollY = 0; /** * 最後一次觸控事件的y軸座標 */ protected int mLastY = 0; /** * 空閒狀態 */ public static final int STATUS_IDLE = 0; /** * 下拉或者上拉狀態, 還沒有到達可重新整理的狀態 */ public static final int STATUS_PULL_TO_REFRESH = 1; /** * 下拉或者上拉狀態 */ public static final int STATUS_RELEASE_TO_REFRESH = 2; /** * 重新整理中 */ public static final int STATUS_REFRESHING = 3; /** * LOADING中 */ public static final int STATUS_LOADING = 4; /** * 當前狀態 */ protected int mCurrentStatus = STATUS_IDLE; /** * 下拉重新整理監聽器 */ protected OnRefreshListener mOnRefreshListener; /** * header中的箭頭圖示 */ private ImageView mArrowImageView; /** * 箭頭是否向上 */ private boolean isArrowUp; /** * header 中的文字標籤 */ private TextView mTipsTextView; /** * header中的時間標籤 */ private TextView mTimeTextView; /** * header中的進度條 */ private ProgressBar mProgressBar; /** * */ private int mScreenHeight; /** * */ private int mHeaderHeight; /** * */ protected OnLoadListener mLoadListener; /** * @param context */ public RefreshLayoutBase(Context context) { this(context, null); } /** * @param context * @param attrs */ public RefreshLayoutBase(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * @param context * @param attrs * @param defStyle */ public RefreshLayoutBase(Context context, AttributeSet attrs, int defStyle) { super(context, attrs); // 初始化Scroller物件 mScroller = new Scroller(context); // 獲取螢幕高度 mScreenHeight = context.getResources().getDisplayMetrics().heightPixels; // header 的高度為螢幕高度的 1/4 mHeaderHeight = mScreenHeight / 4; // 初始化整個佈局 initLayout(context); } /** * 初始化整個佈局 * * @param context */ private final void initLayout(Context context) { // header view setupHeaderView(context); // 設定內容檢視 setupContentView(context); // 設定佈局引數 setDefaultContentLayoutParams(); // addView(mContentView); // footer view setupFooterView(context); } /** * 初始化 header view */ protected void setupHeaderView(Context context) { mHeaderView = LayoutInflater.from(context).inflate(R.layout.pull_to_refresh_header, this, false); mHeaderView .setLayoutParams(new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, mHeaderHeight)); mHeaderView.setBackgroundColor(Color.RED); // header的高度整個為1/4的螢幕高度,但是它只有100px是有效的顯示區域,取餘取餘為paddingTop,這樣是為了達到下拉的效果 mHeaderView.setPadding(0, mHeaderHeight - 100, 0, 0); addView(mHeaderView); // HEADER VIEWS mArrowImageView = (ImageView) mHeaderView.findViewById(R.id.pull_to_arrow_image); mTipsTextView = (TextView) mHeaderView.findViewById(R.id.pull_to_refresh_text); mTimeTextView = (TextView) mHeaderView.findViewById(R.id.pull_to_refresh_updated_at); mProgressBar = (ProgressBar) mHeaderView.findViewById(R.id.pull_to_refresh_progress); } /** * 初始化Content View, 子類覆寫. */ protected abstract void setupContentView(Context context); /** * 與Scroller合作,實現平滑滾動。在該方法中呼叫Scroller的computeScrollOffset來判斷滾動是否結束。如果沒有結束, * 那麼滾動到相應的位置,並且呼叫postInvalidate方法重繪介面,從而再次進入到這個computeScroll流程,直到滾動結束。 */ @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); postInvalidate(); } } /* * 在適當的時候攔截觸控事件,這裡指的適當的時候是當mContentView滑動到頂部,並且是下拉時攔截觸控事件,否則不攔截,交給其child * view 來處理。 * @see * android.view.ViewGroup#onInterceptTouchEvent(android.view.MotionEvent) */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { /* * This method JUST determines whether we want to intercept the motion. * If we return true, onTouchEvent will be called and we do the actual * scrolling there. */ final int action = MotionEventCompat.getActionMasked(ev); // Always handle the case of the touch gesture being complete. if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { // Do not intercept touch event, let the child handle it return false; } switch (action) { case MotionEvent.ACTION_DOWN: mLastY = (int) ev.getRawY(); break; case MotionEvent.ACTION_MOVE: // int yDistance = (int) ev.getRawY() - mYDown; mYOffset = (int) ev.getRawY() - mLastY; // 如果拉到了頂部, 並且是下拉,則攔截觸控事件,從而轉到onTouchEvent來處理下拉重新整理事件 if (isTop() && mYOffset > 0) { return true; } break; } // Do not intercept touch event, let the child handle it return false; } /** * 是否已經到了最頂部,子類需覆寫該方法,使得mContentView滑動到最頂端時返回true, 如果到達最頂端使用者繼續下拉則攔截事件; * * @return */ protected abstract boolean isTop(); /** * 是否已經到了最底部,子類需覆寫該方法,使得mContentView滑動到最底端時返回true;從而觸發自動載入更多的操作 * * @return */ protected abstract boolean isBottom(); /** * 顯示footer view */ private void showFooterView() { startScroll(mFooterView.getMeasuredHeight()); mCurrentStatus = STATUS_LOADING; } /** * 設定滾動的引數 * * @param yOffset */ private void startScroll(int yOffset) { mScroller.startScroll(getScrollX(), getScrollY(), 0, yOffset); invalidate(); } /* * 在這裡處理觸控事件以達到下拉重新整理或者上拉自動載入的問題 * @see android.view.View#onTouchEvent(android.view.MotionEvent) */ @Override public boolean onTouchEvent(MotionEvent event) { Log.d(VIEW_LOG_TAG, "@@@ onTouchEvent : action = " + event.getAction()); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mLastY = (int) event.getRawY(); break; case MotionEvent.ACTION_MOVE: int currentY = (int) event.getRawY(); mYOffset = currentY - mLastY; if (mCurrentStatus != STATUS_LOADING) { // changeScrollY(mYOffset); } rotateHeaderArrow(); changeTips(); mLastY = currentY; break; case MotionEvent.ACTION_UP: // 下拉重新整理的具體操作 doRefresh(); break; default: break; } return true; } /** * 修改y軸上的滾動值,從而實現header被下拉的效果 * @param distance * @return */ private void changeScrollY(int distance) { // 最大值為 scrollY(header 隱藏), 最小值為0 ( header 完全顯示). int curY = getScrollY(); // 下拉 if (distance > 0 && curY - distance > getPaddingTop()) { scrollBy(0, -distance); } else if (distance < 0 && curY - distance <= mInitScrollY) { // 上拉過程 scrollBy(0, -distance); } curY = getScrollY(); int slop = mInitScrollY / 2; // if (curY > 0 && curY < slop) { mCurrentStatus = STATUS_RELEASE_TO_REFRESH; } else if (curY > 0 && curY > slop) { mCurrentStatus = STATUS_PULL_TO_REFRESH; } } /** * 重新整理結束,恢復狀態 */ public void refreshComplete() { mCurrentStatus = STATUS_IDLE; // 隱藏header view mScroller.startScroll(getScrollX(), getScrollY(), 0, mInitScrollY - getScrollY()); invalidate(); updateHeaderTimeStamp(); // 200毫秒後處理arrow和progressbar,免得太突兀 this.postDelayed(new Runnable() { @Override public void run() { mArrowImageView.setVisibility(View.VISIBLE); mProgressBar.setVisibility(View.GONE); } }, 100); } /** * 載入結束,恢復狀態 */ public void loadCompelte() { // 隱藏footer startScroll(mInitScrollY - getScrollY()); mCurrentStatus = STATUS_IDLE; } /** * 手指擡起時,根據使用者下拉的高度來判斷是否是有效的下拉重新整理操作。如果下拉的距離超過header view的 * 1/2那麼則認為是有效的下拉重新整理操作,否則恢復原來的檢視狀態. */ private void changeHeaderViewStaus() { int curScrollY = getScrollY(); // 超過1/2則認為是有效的下拉重新整理, 否則還原 if (curScrollY < mInitScrollY / 2) { // 滾動到能夠正常顯示header的位置 mScroller.startScroll(getScrollX(), curScrollY, 0, mHeaderView.getPaddingTop() - curScrollY); mCurrentStatus = STATUS_REFRESHING; mTipsTextView.setText(R.string.pull_to_refresh_refreshing_label); mArrowImageView.clearAnimation(); mArrowImageView.setVisibility(View.GONE); mProgressBar.setVisibility(View.VISIBLE); } else { mScroller.startScroll(getScrollX(), curScrollY, 0, mInitScrollY - curScrollY); mCurrentStatus = STATUS_IDLE; } invalidate(); } /** * 執行下拉重新整理 */ private void doRefresh() { changeHeaderViewStaus(); // 執行重新整理操作 if (mCurrentStatus == STATUS_REFRESHING && mOnRefreshListener != null) { mOnRefreshListener.onRefresh(); } } /** * 執行下拉(自動)載入更多的操作 */ private void doLoadMore() { if (mLoadListener != null) { mLoadListener.onLoadMore(); } } /* * 丈量檢視的寬、高。寬度為使用者設定的寬度,高度則為header, content view, footer這三個子控制元件的高度只和。 * @see android.view.View#onMeasure(int, int) */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = MeasureSpec.getSize(widthMeasureSpec); int childCount = getChildCount(); int finalHeight = 0; for (int i = 0; i < childCount; i++) { View child = getChildAt(i); // measure measureChild(child, widthMeasureSpec, heightMeasureSpec); // 該view所需要的總高度 finalHeight += child.getMeasuredHeight(); } setMeasuredDimension(width, finalHeight); } /* * 佈局函式,將header, content view, * footer這三個view從上到下佈局。佈局完成後通過Scroller滾動到header的底部,即滾動距離為header的高度 + * 本檢視的paddingTop,從而達到隱藏header的效果. * @see android.view.ViewGroup#onLayout(boolean, int, int, int, int) */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int top = getPaddingTop(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); child.layout(0, top, child.getMeasuredWidth(), child.getMeasuredHeight() + top); top += child.getMeasuredHeight(); } // 計算初始化滑動的y軸距離 mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop(); // 滑動到header view高度的位置, 從而達到隱藏header view的效果 scrollTo(0, mInitScrollY); } /* * 滾動監聽,當滾動到最底部,且使用者設定了載入更多的監聽器時觸發載入更多操作. * @see android.widget.AbsListView.OnScrollListener#onScroll(android.widget. * AbsListView, int, int, int) */ @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // 使用者設定了載入更多監聽器,且到了最底部,並且是上拉操作,那麼執行載入更多. if (mLoadListener != null && isBottom() && mScroller.getCurrY() <= mInitScrollY && mYOffset <= 0 && mCurrentStatus == STATUS_IDLE) { showFooterView(); doLoadMore(); } }}
在建構函式中會呼叫initLayout來新增Header View, Content View, Footer View這三個區域的檢視, 其中Content View就是我們的核心元件,比如ListView、GridView,這個區域的檢視預設寬高都是match_parent的。Header的高度為螢幕寬度的1/4,但它的有效顯示區域只有100畫素,其他的都是paddingTop,這樣就是的內容顯示區域顯示在最下面。這樣當用戶一直下拉時,首先會顯示內容區域,繼續下拉則會顯示PaddingTop區域,此時就達到header view高度被拉伸的效果。如下圖 :
圖 4 圖5
不斷下拉,y軸的偏移量不斷減小,使得header越來越多的部分顯示出來。只有白色的內容顯示區域是有效的顯示區,上面的綠色都是paddingTop區,這樣就形成了被拉伸的效果。
新增這三個view之後,我們在onMeasure中對這幾個子view進行丈量。使得該元件的寬度為使用者設定的寬度,高度為header, content view, footer的高度之和。得到各個子檢視的寬高和該元件的總寬高以後,會進行佈局操作,即會呼叫onLayout方法。我們把這個幾個檢視從上到下排列。最後將該元件在y方向上滾動與header view的高度同樣大小的畫素值,使得header view隱藏掉,使得Content View完全顯示出來。
/* * 丈量檢視的寬、高。寬度為使用者設定的寬度,高度則為header, content view, footer這三個子控制元件的高度只和。 * @see android.view.View#onMeasure(int, int) */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = MeasureSpec.getSize(widthMeasureSpec); int childCount = getChildCount(); int finalHeight = 0; for (int i = 0; i < childCount; i++) { View child = getChildAt(i); // measure measureChild(child, widthMeasureSpec, heightMeasureSpec); // 該view所需要的總高度 finalHeight += child.getMeasuredHeight(); } setMeasuredDimension(width, finalHeight); } /* * 佈局函式,將header, content view, * footer這三個view從上到下佈局。佈局完成後通過Scroller滾動到header的底部,即滾動距離為header的高度 + * 本檢視的paddingTop,從而達到隱藏header的效果. * @see android.view.ViewGroup#onLayout(boolean, int, int, int, int) */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int top = getPaddingTop(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); child.layout(0, top, child.getMeasuredWidth(), child.getMeasuredHeight() + top); top += child.getMeasuredHeight(); } // 計算初始化滑動的y軸距離 mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop(); // 滑動到header view高度的位置, 從而達到隱藏header view的效果 scrollTo(0, mInitScrollY); }
然後就是下拉重新整理觸發點了。在onInterceptTouchEvent方法中,對於ACTION_MOVE事件我們會判斷,如果已經滑到了Content View的頂部,並且還繼續下拉,那麼攔截觸控事件,使得事件轉到onTouchEvent方法中處理。事件攔截的關鍵點如下 :
case MotionEvent.ACTION_MOVE: // int yDistance = (int) ev.getRawY() - mYDown; mYOffset = (int) ev.getRawY() - mLastY; // 如果拉到了頂部, 並且是下拉,則攔截觸控事件,從而轉到onTouchEvent來處理下拉重新整理事件 if (isTop() && mYOffset > 0) { return true; } break;
如果在onTouchEvent中我們根據使用者當前觸控事件的y軸位置與上一次的y軸位置的偏移量來修改該元件在y軸上的滾動值,呼叫的方法為changeScrollY()函式,並且會修改header中的文字內容。當用戶擡起手指時,會判斷使用者在y軸上滑動的距離是否大於header view的1/2, 如果大於header view的1/2那麼為有效的下拉重新整理,此時滾動到剛好顯示header view的內容y軸位置,然後觸發重新整理操作,直到使用者呼叫refreshCompete()位置,最後完全隱藏header。否則視為無效的下拉重新整理操作,然後通過Scroller滾動來隱藏header view。
而載入更多操作為使用者滑動到了最底部,並且繼續上拉,那麼會觸發載入更多的操作。在操作在onScroll方法中被觸發。
基本原理就是通過一個ViewGroup來組織header view, content view, footer view, 使它們從上到下排列,並且在初始化時滾動y軸,使得header 和 footer完全隱藏,只顯示content view。使用者下拉或者上拉時,通過判斷是否顯示header 或者 footer, 也是通過Scroller來滾動y軸的偏移量來實現HeaderView, Footer View的顯示和隱藏,不需要修改margin值,這樣效率更高,滾動也更平滑。當用戶的上拉或者下拉操作滿足了條件時,則會觸發相應的操作,即下拉重新整理、上拉載入更多。如有不明白的地方,就對比參考Android打造(ListView、GridView等)通用的下拉重新整理、上拉自動載入的元件吧,原理都差不多。
下拉重新整理的ListView
/** * @author mrsimple */public class RefreshListView extends RefreshLayoutBase<ListView> { /** * @param context */ public RefreshListView(Context context) { this(context, null); } /** * @param context * @param attrs */ public RefreshListView(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * @param context * @param attrs * @param defStyle */ public RefreshListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void setupContentView(Context context) { mContentView = new ListView(context); // 設定滾動監聽器 mContentView.setOnScrollListener(this); } @Override protected boolean isTop() { // Log.d(VIEW_LOG_TAG, // "### first pos = " + mContentView.getFirstVisiblePosition() // + ", getScrollY= " + getScrollY()); return mContentView.getFirstVisiblePosition() == 0 && getScrollY() <= mHeaderView.getMeasuredHeight(); } @Override protected boolean isBottom() { // Log.d(VIEW_LOG_TAG, "### last position = " + // contentView.getLastVisiblePosition() // + ", count = " + contentView.getAdapter().getCount()); return mContentView != null && mContentView.getAdapter() != null && mContentView.getLastVisiblePosition() == mContentView.getAdapter().getCount() - 1; }}
需要下拉重新整理的元件只需要實現isTop來判斷是否滑動到最頂端、isBottom是否滑動到最底部,已經通過setupContentView設定mContentView物件即可。
使用示例
final RefreshListView refreshLayout = new RefreshListView(this); String[] dataStrings = new String[20]; for (int i = 0; i < dataStrings.length; i++) {