Android实现圆弧滑动效果之ArcSlidingHelper篇

前言

我们平时在开发中,难免会遇到一些比较特殊的需求,就比如我们这篇文章的主题,一个关于圆弧滑动的,一般是比较少见的。其实在遇到这些东西时,不要怕(反而觉得很好玩),一步步分析他实现原理,问题便能迎刃而解。

前几天一位群友发了一张图,问类似这种要怎么实现:
1. 要支持手势旋转
2. 旋转后惯性滚动
3. 滚动后自动选中
这里写图片描述

初步分析

首先我们看下设计图,Item绕着一个半圆旋转,如果我们是自定义ViewGroup的话,那么在onLayout之后,就要把这些Item按一定的角度旋转了。如果直接继承View,这个比较方便,可以直接用Canvas的rotate方法。不过如果继承View的话,做起来是简单,也能满足上面的需求,但局限性就比较大了: 只能draw,而且Item内容不宜过多。所以这次我们打算自定义ViewGroup,它的好处呢就是:什么都能放,我不管你Item里面是什么,反正我就负责显示。
惯性滚动的话,这个很容易,我们可以用Scroller配合VelocityTracker来完成。
旋转手势,无非就是计算手指滑动的角度。

选择旋转方案

说起View的动画播放,大家肯定都是轻车熟路了,如果一个View,它有监听点击事件,那么在播放位移动画后,监听的位置按道理,也应该在它最新的位置上(即位移后的位置),在这种情况下我们用View的startAnimation就不奏效了:

        TranslateAnimation translateAnimation = new TranslateAnimation(0, 150, 0, 300);
        translateAnimation.setDuration(500);
        translateAnimation.setFillAfter(true);
        mView.startAnimation(translateAnimation);

这里写图片描述
可以看到,在View位移之后,监听点击事件的区域还是在原来的地方。
我们再看下用属性动画的:

        mView.animate().translationX(150).translationY(300).setDuration(500).start();

这里写图片描述
监听点击事件的区域随着View的移动而更新了。
嘻嘻,我们通过实践来验证了这个说法。

那么我们做的这个是要支持触摸事件的,肯定是使用第二种方法。
ViewPropertyAnimator的源码分析相信大家之前也都已经看过其他大佬们的文章了,这里就只讲讲关键代码:
ViewPropertyAnimator它不是ValueAnimator的子类,哈哈,这个有点意外吧,我们直接看startAnimation方法(这个方法是start()里面调用的):

     private void startAnimation() {
        ...
        //可以看到这里创建了ValueAnimator对象
        ValueAnimator animator = ValueAnimator.ofFloat(1.0f);
        ...
        animator.addUpdateListener(mAnimatorEventListener);
        ...
        animator.start();
    }

中间那里addUpdateListener(mAnimatorEventListener),我们来看看这个listener里面做了什么:

    @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            ...
            ...
            ArrayList<NameValuesHolder> valueList = propertyBundle.mNameValuesHolder;
            if (valueList != null) {
                int count = valueList.size();
                for (int i = 0; i < count; ++i) {
                    NameValuesHolder values = valueList.get(i);
                    float value = values.mFromValue + fraction * values.mDeltaValue;
                    if (values.mNameConstant == ALPHA) {
                        alphaHandled = mView.setAlphaNoInvalidation(value);
                    } else {
                        setValue(values.mNameConstant, value);
                    }
                }
            }
            ...
            ...
        }

else里面调用了setValue方法,我们再继续跟下去 (哈哈,感觉好像捉贼一样):

private void setValue(int propertyConstant, float value) {
        final View.TransformationInfo info = mView.mTransformationInfo;
        final RenderNode renderNode = mView.mRenderNode;
        switch (propertyConstant) {
            case TRANSLATION_X:
                renderNode.setTranslationX(value);
                break;
            case TRANSLATION_Y:
                renderNode.setTranslationY(value);
                break;
            case TRANSLATION_Z:
                renderNode.setTranslationZ(value);
                break;
            case ROTATION:
                renderNode.setRotation(value);
                break;
            case ROTATION_X:
                renderNode.setRotationX(value);
                break;
            case ROTATION_Y:
                renderNode.setRotationY(value);
                break;
            case SCALE_X:
                renderNode.setScaleX(value);
                break;
            case SCALE_Y:
                renderNode.setScaleY(value);
                break;
            case X:
                renderNode.setTranslationX(value - mView.mLeft);
                break;
            case Y:
                renderNode.setTranslationY(value - mView.mTop);
                break;
            case Z:
                renderNode.setTranslationZ(value - renderNode.getElevation());
                break;
            case ALPHA:
                info.mAlpha = value;
                renderNode.setAlpha(value);
                break;
        }
    }

我们可以看到,它就调用了View的mRenderNode里面的setXXX方法,最关键就是这些方法啦,其实这几个setXXX方法在View里面也有公开的,我们也是可以直接调用的,所以我们在处理ACTION_MOVE的时候,就直接调用它而不用播放动画啦。
我们现在验证一下这个方案可不可行:
先试试setTranslationY:
这里写图片描述
将setTranslationY方法换成setRotation看看:
这里写图片描述
好了,经过我们实践验证了这个方案是可行的,在旋转之后,监听点击事件的位置也更新了,这正好是我们需要的效果。

计算旋转角度

现在旋转这个是搞定了,那么我们怎么计算出来手指滑动的角度呢?

想一下,它旋转的时候,肯定是有一个开始角度和结束角度的,我们把圆心坐标,起始坐标,结束坐标用线连起来,不就是三角形了?我们先来看看下面的图:
这里写图片描述这里写图片描述
哈哈,看到了吧,黄色两个圆点就是我们手指的开始和结束坐标,所以我们现在只要计算出红色两条线的夹角就行了。
先找下我们能直接拿到的东西:
- 圆心坐标;
- 起始点坐标;
- 结束点坐标;
我们知道,三角形中,只要拿到三条边的长度,就能求出它的三个角,那么能不能计算出三边的长度呢?答案是肯定的,我们可以这样做:
这里写图片描述
哈哈,想必大家都已经想到了吧,三角形的三条边都有属于自己的矩形,我们现在只要计算出三个矩形的对角线长度,进而求出夹角的大小。
蓝色矩形上的黄点为起始点,那么 (mPivotX和mPivotY是圆心的坐标,mStartX和mStartY是手指按下的坐标,mEndX和mEndY就是手指松开的所在坐标):

矩形宽(小三角形的直角边1) = Math.abs(mStartX - mPivotX);
矩形高(直角边2) = Math.abs(mStartY - mPivotY);
               ______
直角三角形求斜边公式:bc = √ ab² + ac²
那么 第一条边 = (float) Math.sqrt(Math.pow(矩形宽, 2) + Math.pow(矩形高, 2));

我们按照这个公式依次计算出剩余两条边之后,再根据公式:cosC = (a² + b² - c²) / 2ab 即:
float angle = (float) Math.toDegrees(Math.acos((Math.pow(lineA, 2) + Math.pow(lineB, 2) - Math.pow(hypotenuse, 2)) / (2 * lineA * lineB)));
记得一定要转为角度!
好了,我们来看看效果如何:

现在角度是计算出来了,但是,有没有发现,我们的角度都是正数,这在顺时针旋转时没问题,但是逆时针旋转的话,角度就应该为负数了,所以我们要加一个判断它是顺时针还是逆时针旋转的方法:

要判断手指的旋转方向,我们要先知道手指是水平滑动还是垂直滑动 (mPivotX和mPivotY是圆心的坐标,mStartX和mStartY是手指按下的坐标,mEndX和mEndY就是手指松开的所在坐标):

boolean isVerticalScroll = Math.abs(mEndY - mStartY) > Math.abs(mEndX - mStartX);

我们将x轴和y轴的滑动距离进行对比,判断哪个距离更长,如果x轴的滑动距离长,那就是水平滑动了,反之,如果y轴滑动距离比x轴的长,就是垂直滑动。

进一步:如果他是垂直滑动的话:如果它是在圆心的左边,即mEndX < mPivotX:这时候,如果是向上滑动(mEndY < mStartY,则认为是顺时针,如果是向下滑动呢,就是逆时针了。如果是在圆心右边呢,刚好相反:即向上滑动是逆时针,向下是顺时针。

水平滑动的话:如果它是在圆心上面(mEndY < mPivotY):这时候,如果是向左滑动就是逆时针,向右就是顺时针。如果在圆心下面则相反。

看代码:

    private boolean isClockwise() {
        boolean isClockwise;
        //垂直滑动  上下滑动的幅度 > 左右滑动的幅度,则认为是垂直滑动,反之
        boolean isVerticalScroll = Math.abs(mEndY - mStartY) > Math.abs(mEndX - mStartX);
        //手势向下
        boolean isGestureDownward = mEndY > mStartY;
        //手势向右
        boolean isGestureRightward = mEndX > mStartX;

        if (isVerticalScroll) {
            //如果手指滑动的地方是在圆心左边的话:向下滑动就是逆时针,向上滑动则顺时针。反之,如果在圆心右边,向下滑动是顺时针,向上则逆时针。
            isClockwise = mEndX < mPivotX != isGestureDownward;
        } else {
            //逻辑同上:手指滑动在圆心的上方:向右滑动就是顺时针,向左就是逆时针。反之,如果在圆心的下方,向左滑动是顺时针,向右是逆时针。
            isClockwise = mEndY < mPivotY == isGestureRightward;
        }
        return isClockwise;
    }

好了,现在我们来看下效果:
这里写图片描述
哈哈,现在可以正确的判断出是顺时针滑动还是逆时针了,逆时针旋转后,我们得到的角度是负数,这是我们想要的结果。

实现惯性滚动 (Scroller的妙用)

说到Scroller,相信大家第一时间想到要配合View中的computeScroll方法来使用对吧,但是呢,我们这篇文章的主题是辅助类,并不打算继承View,而且不直接持有Context引用,这个时候,可能有同学就会有以下疑问了:

  1. 这种情况下,Scroller还能正常工作吗?
  2. 调用它的startScroll或fling方法后,不是还要调用View中的invalidate方法来触发的吗?
  3. 不继承View,哪来的 invalidate方法?
  4. 不继承View,怎么重写computeScroll方法?在哪里处理惯性滚动?

哈哈,其实Scroller是完全可以脱离View来使用的,既然说是妙用,妙在哪里呢?在开始之前,我们先来了解一下Scroller:
1.它看上去更像是一个ValueAnimator,但它跟ValueAnimator有个明显的区别就是:它不会主动更新动画的值。我们在获取最新值之前,总是要先调用computeScrollOffset方法来刷新内部的mCurrX、mCurrY的值,如果是惯性滚动模式(调用fling方法),还会刷新mCurrVelocity的值。

2.在这里先分享大家一个理解源码调用顺序的方法:
比如我们想知道是哪个方法调用了computeScroll:

    @Override
    public void computeScroll() {
        StackTraceElement[] elements = Thread.currentThread().getStackTrace();
        for (StackTraceElement element : elements) {
            Log.i("computeScroll", String.format(Locale.getDefault(), "%s----->%s\tline: %d",
                    element.getClassName(), element.getMethodName(), element.getLineNumber()));
        }
    }

日志输出:

     com.wuyr.testview.MyView----->computeScroll    line: 141
     android.view.View----->updateDisplayListIfDirty    line: 15361
     android.view.View----->draw    line: 16182
     android.view.ViewGroup----->drawChild  line: 3777
     android.view.ViewGroup----->dispatchDraw   line: 3567
     android.view.View----->updateDisplayListIfDirty    line: 15373
     android.view.View----->draw    line: 16182
     android.view.ViewGroup----->drawChild  line: 3777
     android.view.ViewGroup----->dispatchDraw   line: 3567
     android.view.View----->updateDisplayListIfDirty    line: 15373
     android.view.View----->draw    line: 16182

这样我们就能够很清晰的看到它的调用链了。

回到正题,所谓的调用invalidate方法来触发,是这样的:我们都知道,调用了这个方法之后,onDraw方法就会回调,而调用onDraw的那个方法,是draw(Canvas canvas),再上一级,是draw(Canvas canvas, ViewGroup parent, long drawingTime),重点来了:
computeScroll也是在这个方法中回调的,现在可以得出一个结论:
我们在View中调用invalidate方法,也就是间接地调用computeScroll,而computeScroll中,是我们处理滚动的方法,在使用Scroller时,我们都会重写这个方法,并在里面调用Scroller的computeScrollOffset方法,然后调用getCurrX或getCurrY来获取到最新的值。(好像我前面说的都是多余的) 但是!有没有发现,这个过程,我们完全可以不依赖View来做到的?

3.现在思路就很清晰了,invalidate方法?对于Scroller来说,只是间接回调computeScroll从而更新x和y的值而已。

4.所以完全可以自己写两个方法来实现Scroller在View中的效果,我们这次打算利用Hanlder来帮我们处理异步的问题,这样的话,我们就不用自己新开线程去不断的调用方法啦。

好了,现在我们所遇到的问题,都已经有解决方案了,开始动手咯!

动手写代码

睡觉了!明晚继续

猜你喜欢

转载自blog.csdn.net/u011387817/article/details/80313184