四步實現ChromeLikeSwipeLayout效果

先看最后效果

DemoPreview.gif

SETP1 水滴效果

看到水滴效果第一反應是畫一條閉合曲線,隨著MotionEvent事件,改變繪制過程中的半徑,完成拉伸效果。

1.1 畫一條曲線

在android如何畫一條曲線?

  • a) 使用canvas.drawCircle
  • b) 使用canvas.drawOval
  • c) 使用canvas.drawArc
  • d) 往path里添加貝塞爾曲線,使用canvas.drawPath畫出路徑

由于考慮到以后需要更換半徑參數,而且為了逼真的拉伸效果,一邊的半徑要短,一邊的半徑要長,所以方案a、b、c都得舍棄,好在d方案提供了更自由的繪制方案。

// android.graphics.Path
// quadTo為二次貝塞爾曲線,x1,y1點為控制點,x2,y2為結束點
public void quadTo(float x1, float y1, float x2, float y2) ;

// cubicTo為三次貝塞爾曲線,x1,y1點和x2,y2點為控制點,x3,y3為結束點 public void cubicTo(float x1, float y1, float x2, float y2, float x3, float y3);</code></pre>

與畫直線lineTo類似,需要提供結束點;不同的是貝塞爾曲線需要提供控制點,控制曲線的曲率(彎曲的程度),二次貝塞爾曲線提供一個控制點,三次貝塞爾曲線需要提供兩個點。

1.2 畫正圓

控制點是如何控制曲率的?

貝塞爾曲線有三個參數:起點、結束點、控制點,它們都是已經確定的定值,由這三個參數確定一條唯一的貝塞爾曲線。

二次貝塞爾曲線如下圖1所示,P0為起點,P2為結束點,P1為控制點,P0、P1、P2依次連線,把線段P0P1和線段P1P2平均分為n段,Q0點從P0點向P1點以步長P0P1/n開始勻速運動,Q1點從P1點向P2點以步長P1P2/n開始勻速運動,線Q0Q1的連線即為曲線的切點,所有切點連起來即為二次貝塞爾曲線。

圖1 二次貝塞爾曲線 ref[1]

圖2 二次貝塞爾曲線 ref[1]

三次貝塞爾曲線同理,三次貝塞爾曲線多了一層R0、R1,R0R1的連線即為曲線的切點。

圖3 三次貝塞爾曲線 ref[1]

圖4 三次貝塞爾曲線 ref[1]

  • 如何用貝塞爾曲線畫正圓?

    通過 使用貝塞爾曲線擬合圓 這篇文章,我們可以知道只要使用三次貝塞爾曲線,且控制點P1在(x1,0),控制點P2在(1,y1),即可繪制出1/4正圓,而這個x1點是一個定值0.55228475,y1為(1-0.55228475)。

 

圖5 擬合正圓 ref[2]

推廣一下,畫出四條三次貝塞爾曲線,即可繪制出正圓

圖6 擬合正圓控制點坐標

繪制圖6的四條貝塞爾曲線,代碼實現如下:

private void updatePath(){
    mPath.reset();
    mPath.lineTo(0, -radius);
    mPath.cubicTo(radius  sMagicNumber, -radius
            , radius, -radius  sMagicNumber
            , radius, 0);
    mPath.lineTo(0, 0);

mPath.lineTo(0, radius);
mPath.cubicTo(radius * sMagicNumber, radius
        , radius, radius * sMagicNumber
        , radius, 0);
mPath.lineTo(0, 0);

mPath.lineTo(0, -radius);
mPath.cubicTo(-radius * sMagicNumber, -radius
        , -radius, -radius * sMagicNumber
        , -radius, 0);
mPath.lineTo(0, 0);

mPath.lineTo(0, radius);
mPath.cubicTo(-radius * sMagicNumber, radius
        , -radius, radius * sMagicNumber
        , -radius, 0);
mPath.lineTo(0, 0);

invalidate();

}

@Override protected void onDraw(Canvas canvas) { canvas.save(); canvas.translate(centerX, centerY); canvas.drawPath(mPath, mPaint); canvas.restore(); }</code></pre>

1.3 拉伸效果

既然已經實現了圖6中的貝塞爾曲線繪制正圓,那么拉伸效果只需要減少左半邊r長度,增加右半邊r長度,即可實現拉伸效果,點坐標如圖7所示,lr為較長邊半徑,sr為較短邊半徑。

圖7 拉伸效果控制點坐標

1.4 擴展:360旋轉一下

在ACTION_DOWN時記錄下按下點(prevX,prevY),在ACTION_MOVE時某一點為(currentX,currentY),計算兩點的距離即為lr的長度,順便換算出sr長度

private void updatePath(float x, float y ){
    float distance = distance(mPrevX,mPrevY,x,y);
    float longRadius = radius  + distance;
    float shortRadius = radius - distance * 0.1f;
    ...
}
public static float distance(float x1,float y1, float x2, float y2){
    return (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}

同時,可以根據(prevX,prevY)、(currentX,currentY)計算出兩點的水平夾角,即:

private static float points2Degrees(float x1, float y1, float x2, float y2){
    double angle = Math.atan2(y2-y1,x2-x1);
    return (float) Math.toDegrees(angle);
}

剩下只需要在onDraw時把畫布rotate一下,就可以實現簡單的水滴效果了,效果如下:

圖8 demo.gif

完整代碼如下:

/**

  • Created by hzqiujiadi on 15/11/18.
  • hzqiujiadi ashqalcn@gmail.com */ public class ChromeLikeView extends View { private static final String TAG = "ChromeLikeView"; private static final float sMagicNumber = 0.55228475f; private Paint mPaint; private Path mPath; private float mPrevX; private float mPrevY; private float mDegrees; private boolean mIsDown; private int radius = 120;

    public ChromeLikeView(Context context) {

     super(context);
     init();
    

    }

    public ChromeLikeView(Context context, AttributeSet attrs) {

     super(context, attrs);
     init();
    

    }

    public ChromeLikeView(Context context, AttributeSet attrs, int defStyleAttr) {

     super(context, attrs, defStyleAttr);
     init();
    

    }

    private void init() {

     mPaint = new Paint();
     mPaint.setColor(0xFFDD0011);
     mPaint.setStyle(Paint.Style.STROKE);
     mPaint.setStrokeWidth(10);
     mPath = new Path();
     updatePath(0, 0);
    

    }

    private void updatePath(float x, float y ){

     float distance = distance(mPrevX,mPrevY,x,y);
     float longRadius = radius  + distance;
     float shortRadius = radius - distance * 0.1f;
     mDegrees = points2Degrees(mPrevX,mPrevY,x,y);
    
     mPath.reset();
    
     mPath.lineTo(0, -radius);
     mPath.cubicTo(radius * sMagicNumber, -radius
             , longRadius, -radius * sMagicNumber
             , longRadius, 0);
     mPath.lineTo(0, 0);
    
     mPath.lineTo(0, radius);
     mPath.cubicTo(radius * sMagicNumber, radius
             , longRadius, radius * sMagicNumber
             , longRadius, 0);
     mPath.lineTo(0, 0);
    
     mPath.lineTo(0, -radius);
     mPath.cubicTo(-radius * sMagicNumber, -radius
             , -shortRadius, -radius * sMagicNumber
             , -shortRadius, 0);
     mPath.lineTo(0, 0);
    
     mPath.lineTo(0, radius);
     mPath.cubicTo(-radius * sMagicNumber, radius
             , -shortRadius, radius * sMagicNumber
             , -shortRadius, 0);
     mPath.lineTo(0, 0);
    
     invalidate();
    

    }

    @Override public boolean onTouchEvent(MotionEvent event) {

     int action = event.getAction();
     switch ( action ){
         case MotionEvent.ACTION_DOWN:
             mIsDown = true;
             mPrevX = event.getX();
             mPrevY = event.getY();
             break;
         case MotionEvent.ACTION_MOVE:
             if ( !mIsDown ) break;
             updatePath( event.getX(), event.getY());
             break;
         case MotionEvent.ACTION_UP:
         case MotionEvent.ACTION_CANCEL:
             if ( !mIsDown ) break;
             float endX = event.getX();
             float endY = event.getY();
             mIsDown = false;
             updatePath(mPrevX,mPrevY);
             break;
     }
     return true;
    

    }

    public static float distance(float x1,float y1, float x2, float y2){

     return (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
    

    }

    @Override protected void onDraw(Canvas canvas) {

     int centerX = getMeasuredWidth() >> 1;
     int centerY = getMeasuredHeight() >> 1;
    
     canvas.drawColor(0xFFDDDDDD);
     canvas.save();
     canvas.translate(centerX, centerY);
     canvas.rotate(mDegrees);
     canvas.drawPath(mPath, mPaint);
     canvas.restore();
    

    }

    private static float points2Degrees(float x1, float y1, float x2, float y2){

     double angle = Math.atan2(y2-y1,x2-x1);
     return (float) Math.toDegrees(angle);
    

    } }</code></pre>

    STEP2 為何回調不到onInterceptTouchEvent?

    ChromeLikeSwipeLayout要實現一個類似下拉刷新的功能,需要在ChromeLikeSwipeLayout的onInterceptTouchEvent中判斷是否到了下拉的觸發時機:比如ScrollView到了頂端并且一直在往下拉,這時需要攔截ScrollView的MotionEvent.MOVE事件,把事件回調到ChromeLikeSwipeLayout的onTouchEvent中,設置ScrollView的TopOffset,完成下拉操作。

    如果ChromeLikeSwipeLayout要下拉的子View不是ScrollView、ListView,就會發現ChromeLikeSwipeLayout的onInterceptTouchEvent只會在MotionEvent.ACTION_DOWN時回調,之后不管怎么滑動都不會調用到onInterceptTouchEvent。

    2.1 ViewGroup dispatchTouchEvent流程

    由于我之前的文章 Android TouchEvent之requestDisallowInterceptTouchEvent 閱讀源代碼不仔細,漏掉了一個重要的判斷條件,為解決這個問題,不得不再仔細閱讀下ViewGroup dispatchTouchEvent的代碼源代碼,更新后的ViewGroup dispatchTouchEvent流程圖如下:

    圖9 ViewGroup dispatchTouchEvent流程

    在TouchEvent dispatchTouchEvent到某ViewGroup中時,會有四步判斷,如上圖淺綠色所示。

    1. ACTION_DOWN or mFirstTouchTarget != null

      ACTION_DOWN是一個TOUCH事件的開始節點,在ACTION_DOWN事件時就確定了哪些View會來處理后續的一整串TOUCH事件。確定好的對象以鏈式數據結構存儲在mFirstTouchTarget中,如果mFirstTouchTarget不為null,則說明這個ViewGroup的子View處理過TOUCH事件,后續的事件也會進入到此ViewGroup的OnInterceptTouchEvent便于事件攔截。

    2. disallowIntercept?

      disallowIntercept的作用

      ViewGroup有一個disallowIntercept開關,可以設置此ViewGroup是否屏蔽onInterceptTouchEvent事件。如果開啟此開關,則此ViewGroup跳過自身的onInterceptTouchEvent事件,直接dispatchTouchEvent到子View。

      重置disallowIntercept

      disallowIntercept,會在每次ACTION_DOWN被重置,默認為允許調用onInterceptTouchEvent。

      每次用戶的按下滑動抬起操作為一組完整的操作。新一組操作開始,即當用戶開始點擊屏幕的時候,ViewGroup會重置當前的disallowIntercept開關,恢復到允許調用onInterceptTouchEvent狀態。

    3. intercept?

      onInterceptTouchEvent返回值為true

      當調用ViewGroup的onInterceptTouchEvent后返回值為true,則表示當前ViewGroup攔截了此TouchEvent事件,此ViewGroup的onTouchEvent會收到回調;

      onInterceptTouchEvent返回值為false

      如果返回值為false,則調用dispatchTransformedTouchEvent,去尋找此Point上hit到的子View,如果尋找到子View,則調用子View的dispatchTouchEvent事件,否則就調用super.dispatchTouchEvent,即調用View的dispatchTouchEvent實現,在此會調用到onTouchEvent函數去處理此TouchEvent事件。

    4. handled?

      onTouchEvent返回值為true

      如果返回值為true,則此TouchEvent被處理完畢

      onTouchEvent返回值為false

      如果為false,則return給父ViewGroup,父ViewGroup會繼續交給此ViewGroup的兄弟View處理。

    另外值得注意的是,

    • 只有在ACTION_DOWN、ACTION_POINTER_DOWN、ACTION_HOVER_MOVE時,才會有機會遍歷此ViewGroup的子View去生成mFirstTouchTarget,隨后的事件都會交給mFirstTouchTarget處理,而不是再次遍歷子View。
    • 由于在某ViewGroup中,覆蓋在較上層的View理應最先處理TOUCH事件,所以在ViewGroup遍歷子View時從childrenCount - 1遍歷到0,代碼如下:
    // ViewGroup#dispatchTouchEvent

if (actionMasked == MotionEvent.ACTION_DOWN || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN) || actionMasked == MotionEvent.ACTION_HOVER_MOVE) { ... if (newTouchTarget == null && childrenCount != 0) { .... for (int i = childrenCount - 1; i >= 0; i--) { final int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i; final View child = (preorderedList == null) ? children[childIndex] : preorderedList.get(childIndex); .... if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { // Child wants to receive touch within its bounds. ... mLastTouchDownX = ev.getX(); mLastTouchDownY = ev.getY(); newTouchTarget = addTouchTarget(child, idBitsToAssign); alreadyDispatchedToNewTouchTarget = true; break; } ... } ... } ...

}</code></pre>

2.2 onInterceptTouchEvent總結

  • ACTION_DOWN
    遍歷所有的父ViewGroup->子ViewGroup->孫ViewGroup->目標View,此時在目標View的onTouchEvent中返回true后,父ViewGroup、子ViewGroup、孫ViewGroup都會記錄各自子View的mFirstTouchTarget
  • ACTION_MOVE
    如果之后有ACTION_MOVE過來,此時上述ViewGroup的mFirstTouchTarget都不為空,onInterceptTouchEvent流程為父ViewGroup->子ViewGroup->孫ViewGroup,如果其中一個ViewGroup攔截了事件,則此ViewGroup就會處理onTouchEvent事件,且TouchEvent不在往下dispatch,而是開始return;如果沒有任何一個ViewGroup在onInterceptTouchEvent時攔截了這個事件,則會調用到目標View中,不管返回true or false,新來的事件也會調用進來。

所以想要在ChromeLikeSwipeLayout中回調onInterceptTouchEvent,在ACTION_DOWN時子View就需要在onTouchEvent中return true,ScrollView、ListView這些可以處理滑動的View都是這么做的,而LinearLayout、RelativeLayout則默認return false,所以我們需要在這些不處理onTouchEvent事件的View外面嵌套一個TouchAlwaysTrueLayout,這樣就所有類型的子View都可以處理下拉了,代碼如下:

/**

  • Created by hzqiujiadi on 15/11/23.
  • hzqiujiadi ashqalcn@gmail.com */ public class TouchAlwaysTrueLayout extends ViewGroup { public TouchAlwaysTrueLayout(Context context) {

     super(context);
    

    }

    @Override public boolean onTouchEvent(MotionEvent event) {

     return true;
    

    }

    public static ViewGroup wrap(View view){

     Context context = view.getContext();
     TouchAlwaysTrueLayout wrapper = new TouchAlwaysTrueLayout(context);
     wrapper.addView(view, LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);
     return wrapper;
    

    }

    @Override protected void onLayout(boolean changed, int l, int t, int r, int b) {

     for ( int i = 0 ; i < getChildCount() ; i++ ){
         getChildAt(i).layout(l,t,r,b);
     }
    

    }

    @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

     super.onMeasure(widthMeasureSpec, heightMeasureSpec);
     measureChildren(widthMeasureSpec,heightMeasureSpec);
    

    } }</code></pre>

    STEP3 從零到一

    所有動畫都是從0到1的變化,然后使用插值器Interpolator對從0到1的過程中產生的值進行變化,可以得到不同的動畫效果。

    3.1 回彈效果

    在松開水滴的拖拽后,水滴有個回彈效果,就是利用動畫讓updatePath(newX,newY)運動到updatePath(mPrevX,mPrevY)即可,再利用BounceInterpolator進行數值上的來回抖動,就可以產生回彈效果,值分布曲線為:

    圖10 BounceInterpolator彈跳插值器 ref[3]

    3.2 差異化放大

    假設下拉過程時從0到1的變化,則icon需要從0.5時刻開始變大,水滴則從0.7時刻開始變大,這樣就產生了動畫的層次感,變化過程可以描述為:

    下拉程度 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1
    icon scale 0 0 0 0 0 0 0.2 0.4 0.6 0.8 1
    水滴 scale 0 0 0 0 0 0 0 0 0.3 0.7 1

     

    差異變化傳入0到1,獲得上表中的數值變化,代碼如下:

    private static final float sFactorScaleCircle = 0.75f;
    private static final float sFactorScaleIcon = 0.3f;

private float circleOffsetFraction( float fraction ){ return offsetFraction(fraction, sFactorScaleCircle); }

private float iconOffsetFraction( float fraction ){ return offsetFraction(fraction, sFactorScaleIcon); }

private float offsetFraction(float fraction, float factor){ float result = (fraction - factor) / (1 - factor); result = result > 0 ? result : 0; return result; }</code></pre>

STEP4 完善

再隨便添加點代碼,就完成了。

Reference

[1] wiki Bézier curve

[2] 江一郎 使用貝塞爾曲線擬合圓

[3] 李海珍 android動畫(一)Interpolator

 

 

來自:http://www.jianshu.com/p/d6b4a9ad022e

 

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