自定義View——彈性滑動

滑動是Android開發中非常重要的UI效果,幾乎所有應用都包含了滑動效果,而本文將對滑動的使用以及原理進行介紹。

一、scrollTo與ScrollBy

View提供了專門的方法用于實現滑動效果,分別為scrollTo與scrollBy。先來看看它們的源碼:

/**
 * Set the scrolled position of your view. This will cause a call to
 * {@link #onScrollChanged(int, int, int, int)} and the view will be
 * invalidated.
 * @param x the x position to scroll to
 * @param y the y position to scroll to
 */
public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
        int oldX = mScrollX;
        int oldY = mScrollY;
        mScrollX = x;
        mScrollY = y;
        invalidateParentCaches();
        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
        if (!awakenScrollBars()) {
            postInvalidateOnAnimation();
        }
    }
}


/**
 * Move the scrolled position of your view. This will cause a call to
 * {@link #onScrollChanged(int, int, int, int)} and the view will be
 * invalidated.
 * @param x the amount of pixels to scroll by horizontally
 * @param y the amount of pixels to scroll by vertically
 */
public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

從源碼中可以看出scrollBy實際上是調用了scrollTo函數來實現它的功能。scrollBy實現的是輸入參數的相對滑動,而scrollTo是絕對滑動。需要說明的是mScrollX、mScrollY這兩個View的屬性,這兩個屬性可以通過getScrollX、getScrollY獲得。

  • mScrollX : View的左邊緣與View內容的左邊緣在水平方向上的距離,即從右向左滑動時,為正值,反之為負值。
  • mScrollY : View的上邊緣與View內容的上邊緣在豎直方向上的距離,即從下向上滑動時,為正值,反之為負值。
  • 下面我們來實現一個滑動的效果:
public class HorizontalScroller extends ViewGroup {

    private int mTouchSlop;

    private float mLastXIntercept=0;
    private float mLastYIntercept=0;

    private float mLastX=0;
    private float mLastY=0;

    private int leftBorder;
    private int rightBorder;

    public HorizontalScroller(Context context) {
        super(context);
        init(context);
    }

    public HorizontalScroller(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public HorizontalScroller(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context){
        ViewConfiguration configuration = ViewConfiguration.get(context);
        // 獲取TouchSlop值
        mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        float xIntercept = ev.getX();
        float yIntercept = ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                float deltaX = xIntercept-mLastXIntercept;
                float deltaY = yIntercept-mLastYIntercept;
                // 當水平方向的滑動距離大于豎直方向的滑動距離,且手指拖動值大于TouchSlop值時,攔截事件
                if (Math.abs(deltaX)>Math.abs(deltaY) && Math.abs(deltaX)>mTouchSlop) {
                    intercept=true;
                }else {
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
            default:
                break;
        }

        mLastX = xIntercept;
        mLastY = yIntercept;
        mLastXIntercept = xIntercept;
        mLastYIntercept = yIntercept;

        return intercept;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float xTouch = event.getX();
        float yTouch = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                float deltaX = xTouch-mLastX;
                float deltaY = yTouch-mLastY;
                float scrollByStart = deltaX;
                if (getScrollX() - deltaX < leftBorder) {
                    scrollByStart = getScrollX()-leftBorder;
                } else if (getScrollX() + getWidth() - deltaX > rightBorder) {
                    scrollByStart = rightBorder-getWidth()-getScrollX();
                }
                scrollBy((int) -scrollByStart, 0);
                break;
            case MotionEvent.ACTION_UP:
                // 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
                int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
                int dx = targetIndex * getWidth() - getScrollX();
                scrollTo(getScrollX()+dx,0);
                break;
            default:
                break;
        }

        mLastX = xTouch;
        mLastY = yTouch;

        return super.onTouchEvent(event);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            // 測量每一個子控件的大小
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (changed) {
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childView = getChildAt(i);
                // 在水平方向上對子控件進行布局
                childView.layout(i * getMeasuredWidth(), 0, i * getMeasuredWidth()+childView.getMeasuredWidth()+getPaddingLeft(), childView.getMeasuredHeight());
            }
            // 初始化左右邊界值
            leftBorder = 0;
            rightBorder = getChildCount()*getMeasuredWidth();
        }
    }
}

現在我們來分析下這段代碼:

  • 首先在構造函數中獲取了最小滑動距離TouchSlop。
  • 重寫onInterceptTouchEvent攔截事件,記錄當前坐標。點下時,默認不攔截,只有當滑動還未完成的情況下,才繼續攔截。在移動時,對滑動沖突進行了處理,當水平方向的移動距離大于豎直方向的移動距離,并且移動距離大于最小滑動距離時,我們判斷此時為水平滑動,攔截事件自己處理;否則不攔截,交由子View處理。提起手指時,同樣不攔截事件。
  • 重寫onTouchEvent處理事件,記錄當前坐標。在手指按下時,與攔截事件時做相似處理。在ACTION_MOVE時,向左滑動,如果滑動距離超過左邊界,則對滑動距離進行處理,相對的滑動距離超出又邊界,也是一樣處理,之后把滑動的距離交給scrollBy進行處理。當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面,然后使用scrollTo滑動到那個子控件。
  • 重寫了onMeasure和onLayout方法,在onMeasure中測量每一個子控件的大小值,在onLayout中對每一個子view在水平方向上進行布局。子view的layout的right增加父類的paddingLeft參數,來處理設置padding的情況。這兩個函數的流程分析將會放在之后的文章中詳細說明。

這個類的使用方法如下 :

<?xml version="1.0" encoding="utf-8"?>
<com.idtk.customscroll.HorizontalScroller
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="10dp"
    tools:context="com.idtk.customscroll.MainActivity">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@drawable/zhiqinchun"
        android:clickable="true"/>

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/hanzhan"
        android:clickable="true"/>

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@drawable/shengui"
        android:clickable="true"/>

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@drawable/dayu"
        android:clickable="true"/>

</com.idtk.customscroll.HorizontalScroller>

HorizontalScroller設置全屏,padding為10dp。使用4個ImageView作為子View,并且都設置為可點擊狀態。示例效果圖如下:

二、Scroller

可以看到上面使用scrollTo與ScrollBy方法的滑動都是瞬時完成的,這有些無法滿足我們在切換子view時的需求。我們希望切換子View時,可以擁有滑動過程的效果,而Scroller正好可以完成這一點。

Scroller的使用方法:

* 1、創建Scroller實例

* 2、使用startScroll方法,對其進行初始化

* 3、重寫computeScroll()方法,在其內部調用scrollTo或ScrollBy方法,完成滑動過程。

//創建實例
mScroller = new Scroller(context);

public void smoothScrollTo(){
    int ScrollX = getScrollX();
    int ScrollY = getScrollY();
    //初始化,1000ms內緩慢滑動到deltaX
    mScroller.startScroll(ScrollX, 0, 0, deltaX, 1000);
    invalidate();
}

@Override
public void computeScroll() {
    if(mScroller.computeScrollOffset()){
        int currX = mScroller.getCurrX();
        int currY = mScroller.getCurrY();
        scrollTo(currX, currY);
        postInvalidate();
    }
}

上面的代碼是Scroller的典型用法,也就是傳說中的套路。當時Scroller使用startScroll方法時,只是對一系列參數進行了初始化。我們從下面的源碼中可以看出。

public void startScroll(int startX, int startY, int dx, int dy, int duration) {
    mMode = SCROLL_MODE;
    mFinished = false;
    mDuration = duration;
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mStartX = startX;
    mStartY = startY;
    mFinalX = startX + dx;
    mFinalY = startY + dy;
    mDeltaX = dx;
    mDeltaY = dy;
    mDurationReciprocal = 1.0f / (float) mDuration;
}

參數中,startX、startY是滑動的起點,dx、dy是滑動的距離,duration是滑動的時間系統設置為250ms。我們可以看到startScroll只是進行了滑動時間、是否滑動完成、起點、終點、滑動距離等的參數的設置,那么是如何調用computeScroll()函數的呢?其實computeScroll()的調用是由之后的invalidate()函數來完成的,invalidate可以請求View重繪,在View重繪時會調用draw方法,draw方法又會去調用computeScroll函數。但computeScroll()函數在view中是一個空的函數,需要我們去實現它。

computeScroll()函數的實現已經在上面給出了,有了computeScroll方法之后,就可以實現View的彈性滑動了。來看下computeScroll()的實現過程,首先要進行computeScrollOffset()的判斷,來看下它的源碼 :

public boolean computeScrollOffset() {
    if (mFinished) {
        return false;
    }
    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    if (timePassed < mDuration) {
        switch (mMode) {
        case SCROLL_MODE:
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        case FLING_MODE:
            final float t = (float) timePassed / mDuration;
            final int index = (int) (NB_SAMPLES * t);
            float distanceCoef = 1.f;
            float velocityCoef = 0.f;
            if (index < NB_SAMPLES) {
                final float t_inf = (float) index / NB_SAMPLES;
                final float t_sup = (float) (index + 1) / NB_SAMPLES;
                final float d_inf = SPLINE_POSITION[index];
                final float d_sup = SPLINE_POSITION[index + 1];
                velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
                distanceCoef = d_inf + (t - t_inf) * velocityCoef;
            }
            mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;

            mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
            // Pin to mMinX <= mCurrX <= mMaxX
            mCurrX = Math.min(mCurrX, mMaxX);
            mCurrX = Math.max(mCurrX, mMinX);

            mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
            // Pin to mMinY <= mCurrY <= mMaxY
            mCurrY = Math.min(mCurrY, mMaxY);
            mCurrY = Math.max(mCurrY, mMinY);
            if (mCurrX == mFinalX && mCurrY == mFinalY) {
                mFinished = true;
            }
            break;
        }
    }
    else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

computeScrollOffset()首先檢測scroller是否完成滑動,完成則返回false,未完成則繼續AnimationUtils.currentAnimationTimeMillis獲取當前的毫秒值,減去之前startScroll方法時獲得毫秒值,就是當前滑動的執行時間。之后判斷執行時間是否小于設置的總時間,如果小于,根據startScroll時設置的模式SCROLL_MODE,然后根據Interpolator計算出當前滑動的mcurrX、mcurrY(順便提一下在實例化scroller的時候,是可以設置動畫插值器。);如果執行時間大于或者等于設置的總時間,則直接設置mcurrX、mcurrY為終點值,并且設置mFinished,表示動畫已經完成。

Scroller彈性滑動的流程如下

現在使用Scroller方法來更改一下上面的代碼,當ACTION_UP時,子View的滑動可以有一個過程,而不是瞬時完成。

    private Scroller mScroller;
    ...
    private void init(Context context){
        // 第一步,創建Scroller的實例
        mScroller = new Scroller(context);
        ...
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float xTouch = event.getX();
        float yTouch = event.getY();
        switch (event.getAction()) {
            ...
            case MotionEvent.ACTION_UP:
                // 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
                int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
                int dx = targetIndex * getWidth() - getScrollX();
                // 第二步,使用startScroll方法,對其進行初始化
                mScroller.startScroll(getScrollX(), 0, dx, 0);
                invalidate();
                break;
            default:
                break;
        }

        mLastX = xTouch;
        mLastY = yTouch;

        return super.onTouchEvent(event);
    }
    ...
    @Override
    public void computeScroll() {
        // 第三步,重寫computeScroll()方法,在其內部調用scrollTo或ScrollBy方法,完成滑動過程
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }
}

上面就是代碼中需要增加和修改的部分,我們來簡單分析下。

  • 在構造函數中增加對 Scroller進行了實例化
  • 替換onTouchEvent中手指抬起后的方法,改為 使用startScroll方法,對mScroller進行初始化 ,之后invalidate請求重繪。
  • 增加 重寫的computeScroll()方法 ,在其內部調用scrollTo或ScrollBy方法,完成滑動過程,之后使用postInvalidate()請求view重繪。

示例效果圖如下 :

三、回彈效果

從上面的效果圖可以看出,我們已經實現了view的平滑滾動,滑動位置超過當前view的1/2時,松手之后變會自動滑出此item的View。可是如果想要在首位兩端實現回彈效果,該如何做呢?其實只要修改onTouchEvent方法即可。

@Override
public boolean onTouchEvent(MotionEvent event) {
    float xTouch = event.getX();
    float yTouch = event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!mScroller.isFinished())
                mScroller.abortAnimation();
            break;
        case MotionEvent.ACTION_MOVE:
            float deltaX = xTouch-mLastX;
            float deltaY = yTouch-mLastY;
            float scrollByStart = deltaX;
            //如果超出邊界,則把滑動距離縮小到1/3
            if (getScrollX() - deltaX < leftBorder) {
                scrollByStart = deltaX/3;
            } else if (getScrollX() + getWidth() - deltaX > rightBorder) {
                scrollByStart = deltaX/3;
            }
            scrollBy((int) -scrollByStart, 0);
            break;
        case MotionEvent.ACTION_UP:
            // 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
            int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
            //如果超過右邊界,則回彈到最后一個View
            if (targetIndex>getChildCount()-1){
                targetIndex = getChildCount()-1;
            //如果超過左邊界,則回彈到第一個View
            }else if (targetIndex<0){
                targetIndex =0;
            }
            int dx = targetIndex * getWidth() - getScrollX();
            // 第二步,使用startScroll方法,對其進行初始化
            mScroller.startScroll(getScrollX(), 0, dx, 0);
            invalidate();
            break;
        default:
            break;
    }
    mLastX = xTouch;
    mLastY = yTouch;
    return super.onTouchEvent(event);
}

來簡單分析下修改的onTouchEvent方法:

在滑動的過程中,如果滑動的位置超過了試圖的左、右邊界,則縮小View的滑動距離,使之為手指滑動距離的1/3。當手指離開時,如果通過view寬度獲得的當前inder小與0,則index為第一個View;如果獲得的當前index超過了子View的數量-1,則index為最后一個View。View的回彈效果如下:

四、小結

本文介紹彈性滑動的實現方法,并對彈性滑動的過程進行了詳細分析。在之后通過例子實現了view的彈性滑動以及回彈效果,但 最后還留有兩個問題,即invalidate與postInvalidate的區別又在哪里呢?invalidate是如何調用computeScroll()函數的呢? ,這些問題我將在下一篇文章中進行詳細的分析。

來自:http://www.idtkm.com/customview/customview8/

 

 本文由用戶 ypmi4003 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!