想要親手實現一個刷新控件,你只需要掌握這些知識
現在Android陣營里面的刷新控件很多,稂莠不齊。筆者試圖從不一樣的角度,在它的個性化和滾動上下一些功夫。筆者期望,這個刷新控件能像Google的SwipeRefreshLayout一樣,支持大多數列表控件,有加載更多功能,最好是要很方便的支持個性化,滾動中能夠越界是不是也會帶來比普通的刷新控件更好的交互體驗。開源庫在這,TwinklingRefreshLayout,如果喜歡請star,筆者的文章也是圍繞著這個控件的實現來說的。
為了方便,筆者將TwinklingRefreshLayout直接繼承自FrameLayout而不是ViewGroup,可以省去onMeasure、onLayout等一些麻煩,Header和Footer則是通過LayoutParams來設置View的Gravity屬性來做的。
1. View的onAttachedToWindow()方法
首先View沒有明顯的生命周期,我們又不能再構造函數里面addView()給控件添加頭部和底部,因此這個操作比較合適的時機就是在onDraw()之前——onAttachedToWindow()方法中。
此時View被添加到了窗體上,View有了一個用于顯示的Surface,將開始繪制。因此其保證了在onDraw()之前調用,但可能在調用 onDraw(Canvas) 之前的任何時刻,包括調用 onMeasure(int, int) 之前或之后。比較適合去執(zhí)行一些初始化操作。(此外在屏蔽Home鍵的時候也會回調這個方法)
- onDetachedFromWindow()與onAttachedToWindow()方法相對應。
 - ViewGroup先是調用自己的onAttachedToWindow()方法,再調用其每個child的onAttachedToWindow()方法,這樣此方法就在整個view樹中遍布開了,而visibility并不會對這個方法產生影響。
 - onAttachedToWindow方法是在Activity resume的時候被調用的,也就是act對應的window被添加的時候,且每個view只會被調用一次,父view的調用在前,不論view的visibility狀態(tài)都會被調用,適合做些view特定的初始化操作;
 - onDetachedFromWindow方法是在Activity destroy的時候被調用的,也就是act對應的window被刪除的時候,且每個view只會被調用一次,父view的調用在后,也不論view的visibility狀態(tài)都會被調用,適合做最后的清理操作;
 
就TwinklingRefreshLayout來說,Header和Footer需要及時顯示出來,View又沒有明顯的生命周期,因此在onAttachedToWindow()中進行設置可以保證在onDraw()之前添加了刷新控件。
- @Override
 - protected void onAttachedToWindow() {
 - super.onAttachedToWindow();
 - //添加頭部
 - FrameLayout headViewLayout = new FrameLayout(getContext());
 - LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
 - layoutParams.gravity = Gravity.TOP;
 - headViewLayout.setLayoutParams(layoutParams);
 - mHeadLayout = headViewLayout;
 - this.addView(mHeadLayout);//addView(view,-1)添加到-1的位置
 - //添加底部
 - FrameLayout bottomViewLayout = new FrameLayout(getContext());
 - LayoutParams layoutParams2 = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
 - layoutParams2.gravity = Gravity.BOTTOM;
 - bottomViewLayout.setLayoutParams(layoutParams2);
 - mBottomLayout = bottomViewLayout;
 - this.addView(mBottomLayout);
 - //...其它步驟
 - }
 
但是當TwinklingRefreshLayout應用在Activity或Fragment中時,可能會因為執(zhí)行onResume重新觸發(fā)了onAttachedToWindow()方法而導致重復創(chuàng)建Header和Footer擋住原先添加的View,因此需要加上判斷:
- @Override
 - protected void onAttachedToWindow() {
 - super.onAttachedToWindow();
 - System.out.println("onAttachedToWindow綁定窗口");
 - //添加頭部
 - if (mHeadLayout == null) {
 - FrameLayout headViewLayout = new FrameLayout(getContext());
 - LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
 - layoutParams.gravity = Gravity.TOP;
 - headViewLayout.setLayoutParams(layoutParams);
 - mHeadLayout = headViewLayout;
 - this.addView(mHeadLayout);//addView(view,-1)添加到-1的位置
 - if (mHeadView == null) setHeaderView(new RoundDotView(getContext()));
 - }
 - //...
 - }
 
2. View的事件分發(fā)機制
事件的分發(fā)過程由dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent三個方法來共同完成的。由于事件的傳遞是自頂向下的,對于ViewGroup,筆者覺得最重要的就是onInterceptTouchEvent方法了,它關系到事件是否能夠繼續(xù)向下傳遞。看如下偽代碼:
- public boolean dispatchTouchEvent(MotionEvenet ev){
 - boolean consume = false;
 - if (onInterceptTouchEvent(ev)) {
 - consume = onTouchEvent(ev);
 - }else{
 - consume = child.dispatchTouchEvent(ev);
 - }
 - return consume;
 - }
 
如代碼所示,如果ViewGroup攔截了(onInterceptTouchEvent返回true)事件,則事件會在ViewGroup的onTouchEvent方法中消費,而不會傳到子View;否則事件將交給子View去分發(fā)。
我們需要做的就是在子View滾動到頂部或者底部時及時的攔截事件,讓ViewGroup的onTouchEvent來交接處理滑動事件。
3. 判斷子View滾動達到邊界
在什么時候對事件進行攔截呢?對于Header,當手指向下滑動也就是 dy>0 且子View已經滾動到頂部(不能再向上滾動)時攔截;對于bottom則是 dy<0 且子View已經滾動到底部(不能再向下滾動)時攔截:
- @Override
 - public boolean onInterceptTouchEvent(MotionEvent ev) {
 - switch (ev.getAction()) {
 - case MotionEvent.ACTION_DOWN:
 - mTouchY = ev.getY();
 - break;
 - case MotionEvent.ACTION_MOVE:
 - float dy = ev.getY() - mTouchY;
 - if (dy > 0 && !canChildScrollUp()) {
 - state = PULL_DOWN_REFRESH;
 - return true;
 - } else if (dy < 0 && !canChildScrollDown() && enableLoadmore) {
 - state = PULL_UP_LOAD;
 - return true;
 - }
 - break;
 - }
 - return super.onInterceptTouchEvent(ev);
 - }
 
判斷View能不能繼續(xù)向上滾動,對于sdk14以上版本,v4包里提供了方法:
- public boolean canChildScrollUp() {
 - return ViewCompat.canScrollVertically(mChildView, -1);
 - }
 
其它情況,直接交給子View了,ViewGroup這里也管不著。
4. ViewGroup 的 onTouchEvent 方法
走到這一步,子View的滾動已經交給子View自己去搞了,ViewGroup需要處理的事件只有兩個臨界狀態(tài),也就是用戶在下拉可能想要刷新的狀態(tài)和用戶在上拉可能想要加載更多的狀態(tài)。也就是上面state記錄的狀態(tài)。接下來的事情就簡單咯,監(jiān)聽一下ACTION_MOVE和ACTION_UP就好了。
首先在ACTION_DOWN時需要記錄下最原先的手指按下的位置 mTouchY,然后在一系列ACTION_MOVE過程中,獲取當前位移(ev.getY()-mTouchY),然后通過 某種計算方式 不斷計算當前的子View應該位移的距離offsetY,調用mChildView.setTranslationY(offsetY)來不斷設置子View的位移,同時需要給HeadLayout申請布局高度來完成頂部控件的顯示。這其中筆者使用的計算方式就是插值器(Interpolator)。
在ACTION_UP時,需要判斷子View的位移有沒有達到進入刷新或者是加載更多狀態(tài)的要求,即mChildView.getTranslationY() >= mHeadHeight - mTouchSlop,mTouchSlop是為了防止發(fā)生抖動而存在。判斷進入了刷新狀態(tài)時,當前子View的位移在HeadHeight和maxHeadHeight之間,所以需要讓子View的位移回到HeadHeight處,否則就直接回到0處。
5. Interpolator插值器
Interpolator用于動畫中的時間插值,其作用就是把0到1的浮點值變化映射到另一個浮點值變化。上面提到的計算方式如下:
- float offsetY = decelerateInterpolator.getInterpolation(dy / mWaveHeight / 2) * dy / 2;
 
其中(dy / mWaveHeight / 2)是一個0~1之間的浮點值,隨著下拉高度的增加,這個值越來越大,通過decelerateInterpolator獲取到的插值也越來越大,只不過這些值的變化量是越來越小(decelerate效果)。dy表示的是手指移動的距離。這只是筆者為了滑動的柔和性使用的一種計算方式,頭部位移的最大距離是mWaveHeight = dy/2,這樣看的話可以發(fā)現 dy / mWaveHeight / 2 會從0到1變化。Interpolator繼承自TimeInterpolator接口,源碼如下:
- public interface TimeInterpolator {
 - /**
 - * Maps a value representing the elapsed fraction of an animation to a value that represents
 - * the interpolated fraction. This interpolated value is then multiplied by the change in
 - * value of an animation to derive the animated value at the current elapsed animation time.
 - *
 - * @param input A value between 0 and 1.0 indicating our current point
 - * in the animation where 0 represents the start and 1.0 represents
 - * the end
 - * @return The interpolation value. This value can be more than 1.0 for
 - * interpolators which overshoot their targets, or less than 0 for
 - * interpolators that undershoot their targets.
 - */
 - float getInterpolation(float input);
 - }
 
getInterpolation接收一個0.0~1.0之間的float參數,0.0代表動畫的開始,1.0代表動畫的結束。返回值則可以超過1.0,也可以小于0.0,比如OvershotInterpolator。所以getInterpolation()是用來實現輸入0~1返回0~1左右的函數值的一個函數。
6. 屬性動畫
上面說到了手指抬起的時候,mChildView的位移要么回到mHeadHeight處,要么回到0處。直接setTranslationY()不免太不友好,所以我們這里使用屬性動畫來做。本來是直接可以用mChildView.animate()方法來完成屬性動畫的,因為需要兼容低版本并回調一些參數,所以這里使用ObjectAnimator:
- private void animChildView(float endValue, long duration) {
 - ObjectAnimator oa = ObjectAnimator.ofFloat(mChildView, "translationY", mChildView.getTranslationY(), endValue);
 - oa.setDuration(duration);
 - oa.setInterpolator(new DecelerateInterpolator());//設置速率為遞減
 - oa.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
 - @Override
 - public void onAnimationUpdate(ValueAnimator animation) {
 - int height = (int) mChildView.getTranslationY();//獲得mChildView當前y的位置
 - height = Math.abs(height);
 - mHeadLayout.getLayoutParams().height = height;
 - mHeadLayout.requestLayout();
 - }
 - });
 - oa.start();
 - }
 
傳統的補間動畫只能夠實現移動、縮放、旋轉和淡入淡出這四種動畫操作,而且它只是改變了View的顯示效果,改變了畫布繪制出來的樣子,而不會真正去改變View的屬性。比如用補間動畫對一個按鈕進行了移動,只有在原位置點擊按鈕才會發(fā)生響應,而屬性動畫則可以真正的移動按鈕。屬性動畫最簡單的一種使用方式就是使用ValueAnimator:
- ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
 - anim.start();
 
它可以傳入多個參數,如ValueAnimator.ofFloat(0f, 5f, 3f, 10f),他會根據設置的插值器依次計算,比如想做一個心跳的效果,用ValueAnimator來控制心的當前縮放值大小就是個不錯的選擇。除此之外,還可以調用setStartDelay()方法來設置動畫延遲播放的時間,調用setRepeatCount()和setRepeatMode()方法來設置動畫循環(huán)播放的次數以及循環(huán)播放的模式等。
如果想要實現View的位移,ValueAnimator顯然是比較麻煩的,我們可以使用ValueAnimator的子類ObjectAnimator,如下:
- ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "alpha", 1f, 0f, 1f);
 - animator.setDuration(5000);
 - animator.start();
 
傳入的第一個值是Object,不局限于View,傳入的第二個參數為Object的一個屬性,比如傳入"abc",ObjectAnimator會去Object里面找有沒有 getAbc() 和 setAbc(...) 這兩個方法,如果沒有,動畫就沒有效果,它內部應該是處理了相應的異常。另外還可以用AnimatorSet來實現多個屬性動畫同時播放,也可以在xml中寫屬性動畫。
7. 個性化Header和Footer的接口
要實現個性化的Header和Footer,最最重要的當然是把滑動過程中系數都回調出來啦。在ACTION_MOVE的時候,在ACTION_UP的時候,還有在mChildView在執(zhí)行屬性動畫的時候,而且mChildView當前所處的狀態(tài)都是很明確的,寫個接口就好了。
- public interface IHeaderView {
 - View getView();
 - void onPullingDown(float fraction,float maxHeadHeight,float headHeight);
 - void onPullReleasing(float fraction,float maxHeadHeight,float headHeight);
 - void startAnim(float maxHeadHeight,float headHeight);
 - void onFinish();
 - }
 
getView()方法保證在TwinklingRefreshLayout中可以取到在外部設置的View,onPullingDown()是下拉過程中ACTION_MOVE時的回調方法,onPullReleasing()是下拉狀態(tài)中ACTION_UP時的回調方法,startAnim()則是正在刷新時回調的方法。其中fraction=mChildView.getTranslationY()/mHeadHeight,fraction=1 時,mChildView的位移恰好是HeadLayout的高度,fraction>1 時則超過了HeadLayout的高度,其最大高度可以到達 mWaveHeight/mHeadHeight。這樣我們只需要寫一個View來實現這個接口就可以實現個性化了,該有的參數都有了!
8. 實現越界回彈
不能在手指快速滾動到頂部時對越界做出反饋,這是一個繼承及ViewGroup的刷新控件的通病。沒有繼承自具體的列表控件,它沒辦法獲取到列表控件的Scroller,不能獲取到列表控件的當前滾動速度,更是不能預知列表控件什么時候能滾動到頂部;同時ViewGroup除了達到臨界狀態(tài)的事件被攔截了,其它事件全都交給了子View去處理。我們能獲取到的有關于子View的操作,只有簡簡單單的手指的觸摸事件。so, let's do it!
- mChildView.setOnTouchListener(new OnTouchListener() {
 - @Override
 - public boolean onTouch(View v, MotionEvent event) {
 - return gestureDetector.onTouchEvent(event);
 - }
 - });
 
我們把在mChildView上的觸摸事件交給了一個工具類GestureDetector去處理,它可以輔助檢測用戶的單擊、滑動、長按、雙擊、快速滑動等行為。我們這里只需要重寫onFling()方法并獲取到手指在Y方向上的速度velocityY,要是再能及時的發(fā)現mChildView滾動到了頂部就可以解決問題了。
- GestureDetector gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
 - @Override
 - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
 - mVelocityY = velocityY;
 - }
 - });
 
此外獲取速度還可以用VelocityTracker,比較麻煩一些:
- VelocityTracker tracker = VelocityTracker.obtain();
 - tracker.addMovement(ev);
 - //然后在恰當的位置使用如下方法獲取速度
 - tracker.computeCurrentVelocity(1000);
 - mVelocityY = (int)tracker.getYVelocity();
 
繼續(xù)來實現越界回彈。對于RecyclerView、AbsListView,它們提供有OnScrollListener可以獲取一下滾動狀態(tài):
- if (mChildView instanceof RecyclerView) {
 - ((RecyclerView) mChildView).addOnScrollListener(new RecyclerView.OnScrollListener() {
 - @Override
 - public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
 - if (!isRefreshing && !isLoadingmore && newState == RecyclerView.SCROLL_STATE_IDLE) {
 - if (mVelocityY >= 5000 && ScrollingUtil.isRecyclerViewToTop((RecyclerView) mChildView)) {
 - animOverScrollTop();
 - }
 - if (mVelocityY <= -5000 && ScrollingUtil.isRecyclerViewToBottom((RecyclerView) mChildView)) {
 - animOverScrollBottom();
 - }
 - }
 - super.onScrollStateChanged(recyclerView, newState);
 - }
 - });
 - }
 
筆者選取了一個滾動速度的臨界值,Y方向的滾動速度大于5000時才允許越界回彈,RecyclerView的OnScrollListener可以讓我們獲取到滾動狀態(tài)的改變,滾動到頂部時滾動完成,狀態(tài)變?yōu)镾CROLL_STATE_IDLE,執(zhí)行越界回彈動畫。這樣的策略也還有一些缺陷,不能獲取到mChildView滾動到頂部時的滾動速度,也就不能根據不同的滾動速度來實現更加友好的越界效果?,F在的越界高度是固定的,還需要后面進行優(yōu)化,比如采用加速度來計算,是否可行還待驗證。
9. 滾動的延時計算策略
上面的方法對于RecyclerView和AbsListView都好用,對于ScrollView、WebView就頭疼了,只能使用延時計算一段時間看有沒有到達頂部的方式來判斷的策略。延時策略的思想就是通過發(fā)送一系列的延時消息從而達到一種漸進式計算的效果,具體來說可以使用Handler或View的postDelayed方法,也可以使用線程的sleep方法。另外提一點,需要不斷循環(huán)計算一個數值,比如自定義View需要實現根據某個數值變化的動效,最好不要使用Thread + while 循環(huán)的方式計算,使用ValueAnimator會是更好的選擇。這里筆者選擇了Handler的方式。
- @Override
 - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
 - mVelocityY = velocityY;
 - if (!(mChildView instanceof AbsListView || mChildView instanceof RecyclerView)) {
 - //既不是AbsListView也不是RecyclerView,由于這些沒有實現OnScrollListener接口,無法回調狀態(tài),只能采用延時策略
 - if (Math.abs(mVelocityY) >= 5000) {
 - mHandler.sendEmptyMessage(MSG_START_COMPUTE_SCROLL);
 - } else {
 - cur_delay_times = ALL_DELAY_TIMES;
 - }
 - }
 - return false;
 - }
 
在滾動速度大于5000的時候發(fā)送一個重新計算的消息,Handler收到消息后,延時一段時間繼續(xù)給自己發(fā)送消息,直到時間用完或者mChildView滾動到頂部或者用戶又進行了一次Fling動作。
- private Handler mHandler = new Handler() {
 - @Override
 - public void handleMessage(Message msg) {
 - switch (msg.what) {
 - case MSG_START_COMPUTE_SCROLL:
 - cur_delay_times = -1; //這里沒有break,寫作-1方便計數
 - case MSG_CONTINUE_COMPUTE_SCROLL:
 - cur_delay_times++;
 - if (!isRefreshing && !isLoadingmore && mVelocityY >= 5000 && childScrollToTop()) {
 - animOverScrollTop();
 - cur_delay_times = ALL_DELAY_TIMES;
 - }
 - if (!isRefreshing && !isLoadingmore && mVelocityY <= -5000 && childScrollToBottom()) {
 - animOverScrollBottom();
 - cur_delay_times = ALL_DELAY_TIMES;
 - }
 - if (cur_delay_times < ALL_DELAY_TIMES)
 - mHandler.sendEmptyMessageDelayed(MSG_CONTINUE_COMPUTE_SCROLL, 10);
 - break;
 - case MSG_STOP_COMPUTE_SCROLL:
 - cur_delay_times = ALL_DELAY_TIMES;
 - break;
 - }
 - }
 - };
 
ALL_DELAY_TIMES是最多可以計算的次數,當Handler接收到MSG_START_COMPUTE_SCROLL消息時,如果mChildView沒有滾動到邊界處,則會在10ms之后向自己發(fā)送一條MSG_CONTINUE_COMPUTE_SCROLL的消息,然后繼續(xù)進行判斷。然后在合適的時候越界回彈就好了。
10. 實現個性化Header
這里筆者來演示一下,怎么輕輕松松的做一個個性化的Header,比如新浪微博樣式的刷新Header(如下面第1圖)。
- 創(chuàng)建 SinaRefreshView 繼承自 FrameLayout 并實現 IHeaderView 接口
 - getView()方法中返回this
 - 在onAttachedToWindow()方法中獲取一下需要用到的布局(筆者寫到了xml中,也可以直接在代碼里面寫)
- @Override
 - protected void onAttachedToWindow() {
 - super.onAttachedToWindow();
 - if (rootView == null) {
 - rootView = View.inflate(getContext(), R.layout.view_sinaheader, null);
 - refreshArrow = (ImageView) rootView.findViewById(R.id.iv_arrow);
 - refreshTextView = (TextView) rootView.findViewById(R.id.tv);
 - loadingView = (ImageView) rootView.findViewById(R.id.iv_loading);
 - addView(rootView);
 - }
 - }
 
 - 實現其它方法
- @Override
 - public void onPullingDown(float fraction, float maxHeadHeight, float headHeight) {
 - if (fraction < 1f) refreshTextView.setText(pullDownStr);
 - if (fraction > 1f) refreshTextView.setText(releaseRefreshStr);
 - refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
 - }
 - @Override
 - public void onPullReleasing(float fraction, float maxHeadHeight, float headHeight) {
 - if (fraction < 1f) {
 - refreshTextView.setText(pullDownStr);
 - refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
 - if (refreshArrow.getVisibility() == GONE) {
 - refreshArrow.setVisibility(VISIBLE);
 - loadingView.setVisibility(GONE);
 - }
 - }
 - }
 - @Override
 - public void startAnim(float maxHeadHeight, float headHeight) {
 - refreshTextView.setText(refreshingStr);
 - refreshArrow.setVisibility(GONE);
 - loadingView.setVisibility(VISIBLE);
 - }
 
 - 布局文件
- <?xml version="1.0" encoding="utf-8"?>
 - <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 - android:orientation="horizontal" android:layout_width="match_parent"
 - android:layout_height="match_parent"
 - android:gravity="center">
 - <ImageView
 - android:id="@+id/iv_arrow"
 - android:layout_width="wrap_content"
 - android:layout_height="wrap_content"
 - android:src="@drawable/ic_arrow"/>
 - <ImageView
 - android:id="@+id/iv_loading"
 - android:visibility="gone"
 - android:layout_width="34dp"
 - android:layout_height="34dp"
 - android:src="@drawable/anim_loading_view"/>
 - <TextView
 - android:id="@+id/tv"
 - android:layout_width="wrap_content"
 - android:layout_height="wrap_content"
 - android:layout_marginLeft="16dp"
 - android:textSize="16sp"
 - android:text="下拉刷新"/>
 - </LinearLayout>
 
 
注意fraction的使用,比如上面的代碼 refreshArrow.setRotation(fraction headHeight / maxHeadHeight 180),fraction * headHeight表示當前頭部滑動的距離,然后算出它和最大高度的比例,然后乘以180,可以使得在滑動到最大距離時Arrow恰好能旋轉180度。startAnim()方法是在onRefresh之后會自動調用的方法。
要想實現如圖2所示效果,可以具體查看筆者的開源庫TwinklingRefreshLayout。
總結
至此,筆者實現這個刷新控件的所有核心思想都講完了,其中并沒有用到多么高深的技術,只是需要我們多一點耐心,多去調試,不要逃避bug,多挑戰(zhàn)一下自己。


















 
 
 





 
 
 
 