四步实现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);

与画直线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();
}

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);
    }
}

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;
            }
            ...
        }
        ...
    }
    ...

}

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);
    }
}

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;
}

STEP4 完善

再随便添加点代码,就完成了。

Reference

[1] wiki Bézier curve
[2] 江一郎 使用贝塞尔曲线拟合圆
[3] 李海珍 android动画(一)Interpolator

Github

https://github.com/ashqal/ChromeLikeSwipeLayout

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容