这交互炸了系列 第十四式 之 百步穿扬

来自:陈小缘

1
前言


标题大概就能猜到,这次我们要做的是一个射箭的效果。

在这篇文章中,同学们可以学到:


  • 在自定义Drawable里流畅地draw各种动画;

  • 画一条粗细不一的线段;

  • 一个炫酷的射箭效果;


来看两张效果图:





嗯,就是这样了,可以看到,我们等下要做的这个效果,还能用来当下拉刷新的动画,很炫酷。


这里有同学可能会说:“这些动画,叫UI用AE画一个,然后用Lottie加载不就行了?”


可以是可以,但是, 让UI画出来,那知识是人家UI的,到头来自己还是不会。 那下次遇到类似效果的时候,还是要去麻烦UI。


还有就是,用Lottie动画只能控制播放进度,而不能动态更改里面的单个独立属性,比如我要把弓箭的颜色变成黑色,箭头放大2倍,这样的话,对于Lottie来说,就有点无能为力了。


当然了,Lottie还是有好多其他方面优势的,但本篇文章主题不是Lottie,所以就不多提了。


好,下面开始分析怎么用自定义Drawable来实现这个效果。


2
初步分析


先来看看茄子同学画的这张图:



像这种类型的,我们可以把它各个组成部分都拆出来:


  • 首先是弓,那弓要怎么画出来呢?其实就是一段二阶的贝塞尔曲线;

  • 接着看弓的中间部分,有一小段明显比较粗的线,像个握柄,那这个握柄可以截取弓线条的一部分,然后把画笔宽度加大,再draw出来;

  • 第三部分,弦,这个很简单,确定坐标后画线就行了;

  • 最后一个,箭,这个依然可以用Path来画;


那根据上面所拆分的部分,就有了以下几个属性:


  1. 弓的Path;

  2. 握柄的Path;

  3. 弦的起始点(Point)、中点(Point)、结束点(Point);

  4. 箭的Path;


为什么弦要有三个点呢?不是只要一个起始点和一个结束点就行了?


因为要照顾后面的拉弓效果,那时候的弦会被箭羽分成两条的。


好,静止状态下画法是有了,接下来想想动态的要怎么做。


从上面的效果图可以看出,当拉弓的时候:


  • ,弯曲角度会渐渐增大;

  • 握柄,随着弓的变化而变化;

  • ,中点始终在箭羽的底部;

  • ,垂直降落;


那要怎么实现弓逐渐弯曲的效果呢?


上面说到,这个弓可以用二阶贝塞尔来画(Path的quadTo方法),这个方法接收4个参数,也就是控制点和结束点各自的坐标(x,y)了,起始点就是Path上一次的落点(可以调用moveTo来调整)。


那么我们就可以通过改变这个起始点和结束点的位置,来实现弓的弯曲效果。


怎么个改变法?


要是直接把坐标点垂直往下移动,那效果就不好了,因为弓会被越拉越长。


就像这样:



这样看上去就很不自然。


正确的方法应该是:


让这个坐标点,绕弓的中点旋转,半径就是弓长的一半,然后计算旋转后的值。


看图:



emmmm,其实也就是根据旋转角度求点在圆上的坐标了,其公式是:


x = 半径 * cos(弧度)
y = 半径 * sin(弧度)


当然了,最后还要加上圆心的坐标值的。


计算出坐标后,重新用Path的quadTo方法把它们连起来就行了。


那弓的Path更新了之后,握柄自然也就好了(截取中间的一小段)。


弦的话,在箭下降的时候只需要改变中点的y轴坐标值。


箭一样很简单,甚至不用重新初始化箭的Path,只需要调用Path的offset方法来垂直偏移就行了。


那最后的发射动画,应该怎么做呢?


仔细观察刚开始的效果图,当拉满弓,把箭发射出去的时候会看到:


  • 弓先向下移动,直至超出可见范围,并且在移动过程中弯曲角度会慢慢变小;

  • 箭离弦之后,开始缩短(改变箭长后重画),并且箭的小尾巴慢慢出现;

  • 箭缩短到一定程度之后,开始上下移动;

  • 箭上下移动时,会出现一条条的竖线快速地往下掉;


不要被这么多步骤吓到了,其实每个步骤都很简单的。


比如弓向下移动的,我们只需要记录三样东西:


  1. 开始时间;

  2. 动画的时长;

  3. 要移动的总距离;


当每次更新帧(draw)的时候,先计算出已播放时长(当前时间-开始时间),然后用这个已播放时长/动画总时长得出动画当前播放进度,最后用动画当前播放进度*总距离得出偏移量(当前要偏移的距离)。


那接下来,就可以把这个偏移量,应用到弓的Path上了(调用offset方法)。


其他的也是一样原理,只是操作的变量不同,比如箭的缩短动画:用播放进度*要缩短的总长度得出当前要缩短的长度,然后用箭初始长度-当前要缩短的长度得出新的长度,再基于这个新的长度重新画箭就行了。


好啦,分析的差不多了,准备写代码咯。


3
自定义线条


细心的同学会发现,效果图的中弓,它的两端是比较细的,越接近中间就越粗,但这种效果在SDK中并没有提供直接的API。


那应该怎么做呢?


我们知道,在屏幕上看到的图像,是由一粒粒像素点组成的,那么,可不可以把一条线(Line)分解成一粒粒点(Point),然后改变每一个点的Width,再draw出来?


答案是肯定的。


分解线条,要怎么分解?


当然是借助强大的PathMeasure来分解了:


根据Path创建PathMeasure实例后,可以调用其getPosTan方法获得每一个点的坐标值,这些坐标值正是我们想要的东西。


看代码怎么写:


没错了就是这样,当方法结束后会返回Path上的全部坐标点。


那怎么计算出来每个点的缩放比例?


至于计算平滑缩放比例,我们可以按照飞龙在天的思路:

https://blog.csdn.net/u011387817/article/details/81875021



来封装一个ScaleHelper类。

首先是构造方法:



有了缩放比例之后,接下来看看怎么计算任意位置上的缩放比例:


    /**
     * 获取指定位置的缩放比例
     * @param fraction 当前位置(0~1)
     */

    float getScale(float fraction) {
        float minScale = 1;
        float maxScale = 1;
        float scalePosition;
        float minFraction = 0, maxFraction = 1;
        //顺序遍历,找到小于fraction的,最贴近的scale
        for (int i = 1; i < mScales.length; i += 2) {
            scalePosition = mScales[i];
            if (scalePosition <= fraction) {
                minScale = mScales[i - 1];
                minFraction = mScales[i];
            } else {
                break;
            }
        }
        //倒序遍历,找到大于fraction的,最贴近的scale
        for (int i = mScales.length - 1; i >= 1; i -= 2) {
            scalePosition = mScales[i];
            if (scalePosition >= fraction) {
                maxScale = mScales[i - 1];
                maxFraction = mScales[i];
            } else {
                break;
            }
        }
        //计算当前点fraction,在起始点minFraction与结束点maxFraction中的百分比
        fraction = solveTwoPointForm(minFraction, maxFraction, fraction);
        //最大缩放 - 最小缩放 = 要缩放的范围 
        float distance = maxScale - minScale;
        //缩放范围 * 当前位置 = 当前缩放比例
        float scale = distance * fraction;
        //加上基本的缩放比例
        float result = minScale + scale;
        //如果得出的数值不合法,则直接返回基本缩放比例
        return isFinite(result) ? result : minScale;
    }

    /**
     * 将基于总长度的百分比转换成基于某个片段的百分比 (解两点式直线方程)
     *
     * @param startX   片段起始百分比
     * @param endX     片段结束百分比
     * @param currentX 总长度百分比
     * @return 该片段的百分比
     */

    private float solveTwoPointForm(float startX, float endX, float currentX) {
        return (currentX - startX) / (endX - startX);
    }

    /**
     * 判断数值是否合法
     *
     * @param value 要判断的数值
     * @return 合法为true,反之
     */

    private boolean isFinite(float value) {
        return !Float.isNaN(value) && !Float.isInfinite(value);
    }



其实很简单:先找出输入位置到底在哪两个给定的位置之间,然后套个公式就行了。


好,现在来写代码测试下:



我们在线条的起点处(0%),缩放了50%,在30%处缩放到了10%,而在80%处则放大到原始的120%,最后在终点处恢复正常大小.


看看效果:



emmmm,效果还不错。


好了,现在基本的东西都已准备好,可以正式开始啦


4
创建Drawable


在日常开发中,自定义Drawable虽然没有自定义View和ViewGroup出现的频率高,但是它有View和ViewGroup都没有的优点,比如:


  • 它比View更轻量,可以嵌入到任何一个View上面,甚至还可以在SurfaceView里面直接draw;

  • 更专注于draw,因为它没有像View或ViewGroup那样需要measure和layout;


当然了,既然变得更轻量了,也代表着某些能力没有了,比如说处理触摸事件——在Drawable中可是不能像View那样可以直接接收到MotionEvent的。


如果同学们要做的效果不需要依赖触摸事件,只需要draw的话,可以优先考虑自定义Drawable,而不是View。


比如我们这次要做的效果,就选择了Drawable,名字呢,就叫做ArrowDrawable吧。


来看看初始的代码:


为了让更多还没开始学习Kotlin的同学能感受到Kotlin的魅力,所以这次的Demo代码也是使用Kotlin来写 (java版本的在文章最后会给出地址)



可以看到,现在只重写了几个基本的方法:


  • getIntrinsicWidth,getIntrinsicHeight这两个方法用来告诉外面,它内容的宽和高;

  • getOpacity、setAlpha、setColorFilter,这三个是Drawable的抽象方法,大多数情况下像上面那样做就行了;

  • draw,最重要就是这个了,我们等下都在draw方法里画东西;


5
画弓

好,现在先来画弓。


上面说到:弓的结束点可以根据弯曲的角度,计算出绕中点旋转后的坐标,旋转半径就是弓长的一半。


那这个弓长,可以让外部来提供,这样更灵活。


来看看计算坐标的代码:


    private val mTempPoint = PointF()

    /**
     * 根据弓当前弯曲的角度计算新的端点坐标
     *
     * @param angle 弓当前弯曲的角度
     * @return 新的端点坐标
     */

    private fun getPointByAngle(angle: Float): PointF {
        //先把角度转成弧度
        val radian = angle * Math.PI / 180
        //半径 取 弓长的一半
        val radius = mBowLength / 2
        //x轴坐标值
        val x = (mCenterX + radius * cos(radian)).toFloat()
        //y轴坐标值
        val y = (radius * sin(radian)).toFloat()
        mTempPoint.set(x, y)
        return mTempPoint
    }


mCenterX就是宽度的一半,也就是弓的水平位置了。


细心的同学会发现,x坐标有加上mCenterX, 而y坐标却没有加上mCenterY,这是为什么呢?


因为考虑到等下的弓要从上往下移动的,所以如果一开始就加上了mCenterY的话,那弓就会直接出现在中心的位置上了。


好,有了结束点坐标值之后呢,就可以确定起点的坐标了,那弓的Path也能成形了,我们来定义一个updateBowPath方法,用来更新弓所对应的Path:


    /**
     * 初始化弓
     * @param currentAngle 弓弯曲的角度
     */

    private fun updateBowPath(currentAngle: Float) {
        val stringPoint = getPointByAngle(currentAngle)
        //起始点的x坐标,直接镜像 结束点的x轴坐标
        val startX = mCenterX * 2 - stringPoint.x
        //起始点的y坐标,也就是结束点的y坐标了
        val startY = stringPoint.y
        //控制点x坐标,直接取宽度的一半,也就是中点了
        val controlX = mCenterX
        //控制点的y坐标,刚好跟两端的y坐标相反,这样的话,线条的中点位置就能保持不变
        val controlY = -stringPoint.y
        //结束点坐标,直接赋值,因为getPointByAngle计算的就是结束点坐标
        val endX = stringPoint.x
        val endY = stringPoint.y

        mBowPath.reset()
        //根据三点坐标画一条二阶贝塞尔曲线
        mBowPath.moveTo(startX, startY)
        mBowPath.quadTo(controlX, controlY, endX, endY)
    }


mBowPath就是弓所对应的Path对象了。


当updateBowPath调用了之后,就可以把它画出来啦,我们在draw方法中加上画弓的代码,看看效果:


    override fun draw(canvas: Canvas) {
        updateBowPath(30F)
        //因为画的是线条,所以要用STROKE
        mPaint.style = Paint.Style.STROKE
        canvas.drawPath(mBowPath, mPaint)
    }


弯曲的角度我们传的是30度。


看看效果怎么样:



对哦,中间大,两端小的效果还没加上去呢,马上来封装一个drawBowPath方法:

我们一开始封装的那个ScaleHelper要派上用场了,先初始化:

 

    mScaleHelper = ScaleHelper(
        .2F, 0F,//起点处缩至20%
        1F, .05F,//5%处恢复正常
        2F, .5F,//50%处放大到200%
        1F, .95F,//95%处又恢复正常
        .2F, 1F//最后缩放到20%
    )


接着到drawBowPath方法:


    /**
     * 画弓
     */

    private fun drawBowPath(canvas: Canvas) {
        mBowPathMeasure = PathMeasure(mBowPath, false)
        //分解弓Path
        mBowPathPoints = decomposePath(mBowPathMeasure)

        val length = mBowPathPoints.size
        var fraction: Float
        var radius: Float
        var i = 0
        //把每一个坐标点都画出来
        while (i < length) {
            fraction = i.toFloat() / length
            radius = mBowWidth * mScaleHelper.getScale(fraction) / 2
            canvas.drawCircle(mBowPathPoints[i], mBowPathPoints[i + 1], radius, mPaint)
            i += 2
        }
    }


mBowPathPoints用来装分解之后的点坐标,decomposePath方法就是刚刚封装的分解Path的方法,mBowWidth是弓的宽度。


这次看看效果怎么样:



emmmm,还差点什么?


没错,就是握柄了,上面分析过,握柄可以直接截取弓中间的一段然后加粗线条就行了,来看看代码怎么写:


    /**
     * 初始化握柄
     */

    private fun updateHandlePath() {
        val bowPathLength = mBowPathMeasure.length
        //握柄长度取弓长度的1/5
        val handlePathLength = bowPathLength / 5
        //弓的中点
        val center = bowPathLength / 2
        //中点减去握柄长度的一半,得出起点位置
        val start = center - handlePathLength / 2
        mHandlePath.reset()
        //从弓的中间截取弓长的1/5作为握柄的Path
        mBowPathMeasure.getSegment(start, start + handlePathLength, mHandlePath, true)
    }


mBowPathMeasure就是刚刚初始化弓的时候创建的PathMeasure对象,mHandlePath就是握柄所对应的Path了。


Path初始化完成之后,接着到draw:


    /**
     * 画手柄
     */

    private fun drawHandlePath(canvas: Canvas) {
        canvas.drawPath(mHandlePath, mPaint)
    }


好,draw方法里也加上drawHandlePath,看看现在的draw方法(为了更方便理解,一些线宽,线长都是先写死):


    override fun draw(canvas: Canvas) {
        //线宽为10
        mPaint.strokeWidth = 10F
        //黄色
        mPaint.color = Color.YELLOW

        //因为画的是实心圆,所以要用FILL
        mPaint.style = Paint.Style.FILL
        //初始化弓
        updateBowPath(30F)
        //画弓
        drawBowPath(canvas)

        //因为画的是线条,所以要用STROKE
        mPaint.style = Paint.Style.STROKE
        //线宽增大到原来的3倍,因为ScaleHelper最大是2倍
        mPaint.strokeWidth = mPaint.strokeWidth * 3F
        //初始化握柄
        updateHandlePath()
        //画握柄
        drawHandlePath(canvas)
    }


来看看效果:




可以啦。


6
画弦


相信同学们都注意到了,弦的两端点,它的位置都不是在弓的端点上,只是接近弓的端点。

要拿到那两个点的位置很简单,因为我们刚刚在画弓的Path时就已经留了一手:我们把弓分解之后的坐标点都保留着,所以等下可以直接通过索引来取了。


比如现在要拿弓Path上5%和95%位置上的坐标:


    private fun updateStringPoints() {
        val length = mBowPathPoints.size
        //起始点索引
        var stringStartIndex = (length * .05F).toInt()
        //必须是偶数,如果不是,强行调整
        if (stringStartIndex % 2 != 0) {
            stringStartIndex--
        }
        //结束点索引
        var stringEndIndex = (length * .95F).toInt()
        //必须是偶数,如果不是,强行调整
        if (stringEndIndex % 2 != 0) {
            stringEndIndex--
        }
        //起始点坐标
        mStringStartPoint.x = mBowPathPoints[stringStartIndex]
        mStringStartPoint.y = mBowPathPoints[stringStartIndex + 1]
        //结束点坐标
        mStringEndPoint.x = mBowPathPoints[stringEndIndex]
        mStringEndPoint.y = mBowPathPoints[stringEndIndex + 1]
        //中间点坐标
        //x轴固定在中间
        mStringMiddlePoint.x = mCenterX
        //y轴呢,先跟起始点的y轴一样
        mStringMiddlePoint.y = mStringStartPoint.y
    }


mBowPathPoints,这个装点坐标的数组,里面都是[x,y]成对地存放的,所以拿x坐标的时候必须是偶数,y坐标则必须是奇数索引,不然的话就乱套了。


mStringStartPoint呢是弦的起始点坐标,它是PointF的实例,当然了,还有剩下的两个:mStringEndPoint弦的另一个端点(结束点)、mStringMiddlePoint(弦的中间点)。


接着到画弦了,我们在上面讲到过,要分成两条线来画:起点到中点,中点和结束点:


    /**
     * 画弦
     */

    private fun drawString(canvas: Canvas) {
        //起点到中间点的线
        canvas.drawLine(
            mStringStartPoint.x, mStringStartPoint.y,
            mStringMiddlePoint.x, mStringMiddlePoint.y, mPaint)
        //中间点到结束点的线
        canvas.drawLine(
            mStringEndPoint.x, mStringEndPoint.y,
            mStringMiddlePoint.x, mStringMiddlePoint.y, mPaint)
    }


好,来看看效果:




太棒啦~


7
画箭


上面说过可以用Path来画,但是在画之前,必须要先确定好每一段线条的尺寸,比如箭羽高度啊,箭杆长度这些。


来看下茄子同学的这张图:



看那一段段绿色的线,可以看出,一共要定义7个尺寸,从上到下分别是:


  1. 箭嘴高度;

  2. 箭嘴宽度;

  3. 箭杆长度;

  4. 箭羽倾斜高度;

  5. 箭羽高度;

  6. 箭羽宽度;

  7. 箭杆宽度;


那么问题来了:


如果我要把弓长增加1倍,其他的尺寸肯定也要跟着加吧,那就是要设置7次尺寸咯?


这样的体验肯定是很差的,所以我们要把其他的尺寸,都依赖于弓长,那么,当弓长改动了之后,其他尺寸也跟着变了:


    //箭杆长度 取 弓长的一半
    mArrowBodyLength = mBowLength / 2
    //箭杆宽度 取 箭杆长度的 1/70
    mArrowBodyWidth = mArrowBodyLength / 70
    //箭羽高度 取 箭杆长度的 1/6
    mFinHeight = mArrowBodyLength / 6
    //箭羽宽度 取 箭羽高度 1/3
    mFinWidth = mFinHeight / 3
    //箭羽倾斜高度 = 箭羽宽度
    mFinSlopeHeight = mFinWidth
    //箭嘴宽度 = 箭羽宽度
    mArrowWidth = mFinWidth
    //箭嘴高度 取 箭杆长度的 1/8
    mArrowHeight = mArrowBodyLength / 8


有了尺寸之后,只需要把他们连起来就行了:


    /**
     * 初始化箭
     * @param arrowBodyLength 箭杆长度
     */

    private fun initArrowPath(arrowBodyLength: Float) {
        mArrowPath.reset()
        //一开始定位到箭杆的底部偏向右边的位置
        mArrowPath.moveTo(mCenterX + mArrowBodyWidth, -mFinSlopeHeight)
        //向右下 画箭羽底部的斜线
        mArrowPath.rLineTo(mFinWidth, mFinSlopeHeight)
        //向上 画箭羽的竖线
        mArrowPath.rLineTo(0F, -mFinHeight)
        //向左上 画箭羽的顶部斜线
        mArrowPath.rLineTo(-mFinWidth, -mFinSlopeHeight)
        //向上 画箭杆
        mArrowPath.rLineTo(0F, -arrowBodyLength)
        //向右 画箭嘴 右边底部 的横线
        mArrowPath.rLineTo(mArrowWidth, 0F)
        //向左上 画箭嘴 右边 的斜线
        mArrowPath.rLineTo(-mArrowWidth - mArrowBodyWidth, -mArrowHeight)
        //向左下 画箭嘴 左边 的斜线
        mArrowPath.rLineTo(-mArrowWidth - mArrowBodyWidth, mArrowHeight)
        //向右 画箭嘴 左边底部 的横线
        mArrowPath.rLineTo(mArrowWidth, 0F)
        //向下 画箭杆
        mArrowPath.rLineTo(0F, arrowBodyLength)
        //向左下 画箭羽的顶部斜线
        mArrowPath.rLineTo(-mFinWidth, mFinSlopeHeight)
        //向下 画箭羽的竖线
        mArrowPath.rLineTo(0F, mFinHeight)
        //向右上 画箭羽底部的斜线
        mArrowPath.rLineTo(mFinWidth, -mFinSlopeHeight)
        //结束
        mArrowPath.close()
    }


mArrowPath就是箭所对应的Path了,之所以把箭杆长度放到参数里,是为了等下可以灵活地改变箭的长度。


有同学会说:这样一看,好抽象的样子,只看文字注释根本就想象不出来是怎么画的嘛。


没关系,动图早就准备好了,看几次图片的绘制顺序,再结合上面的代码和注释,就非常容易理解了:



细心的同学又发现问题了:为什么是从底部开始向上画,而不是从顶部开始向下画呢?


因为要照顾后面的动态效果咯,那时候箭是从Drawable的顶部慢慢向下移动的,所以就干脆把它画在Drawable可见范围的外面。


好啦,看看现在的样子(现在在布局中设置了clipChildren为false,所以能看到可见范围外的东西):


    override fun draw(canvas: Canvas) {

        ......
        ......

        //箭是实心的
        mPaint.style = Paint.Style.FILL
        drawArrow(canvas)
    }

    /**
     * 画箭
     */

    private fun drawArrow(canvas: Canvas) {
        canvas.drawPath(mArrowPath, mPaint)
    }


箭的初始化方法,可以在箭的各个尺寸都确定好了之后调用,因为它不用每次都重新画,是可以重用的。


看看:



不错不错,就是这样了。不过现在的弓一开始还是在边界范围内,这是不对的,等下还要把弓给弄到上面去。


8
拉弓


静态的处理完之后,轮到动态的了。


想一想,在拉弓的时候,肯定不能无限往后拉的,弓有个最大的弯曲角度,而且还要记录一个progress,表示拉弓的进度,最小是0,最大是1。


那当progress变动的时候要怎么做呢?


我们的Drawable一开始是空白的,progress逐渐增大时,首先是弓从顶部慢慢向下移动,到了指定的最大距离之后停止,接着到箭向下移动,当箭羽的y坐标比弦的y坐标还要大时,证明已经开始拉弓了,那弦中点的y坐标就要跟着箭羽的一起增大了,还有,这时弓的弯曲角度也要跟着增大,这样的拉弓效果,就出来了。


怎么把弓弄到顶部上面去呢?


可以调用弓所对应的Path的offset方法来进行偏移,偏移量就是负的弓端点的y坐标值,偏移之后,弓的两端点y坐标就刚好等于0。


如果弓和箭一开始都是不可见,那怎么分配滑动进度?


我们打算用0%~25% 来偏移弓,25%~50% 用来偏移箭,50%~100% 用来拉弓,也就是箭和弦一起向下继续偏移。


好,来看看代码要怎么写:


首先是setProgress方法:


    fun setProgress(progress: Float) {
        mProgress = when {
            progress > 1 -> 1//最大是1
            progress < 0 -> 0//最小是0
            else -> progress
        }
        //请求容器重绘
        invalidateSelf()
    }


可以看到在progress变更时还请求重绘了,那就代表着每一次进度的更新,draw方法都会被回调。


接着看看弓要怎么偏移(因为弓的Path是在updateBowPath方法里面初始化的,所以现在可以直接在这个方法里面加上偏移的代码了):


    private fun updateBowPath(currentAngle: Float) {

        ......
        ......

        //初始偏移量
        var offsetY = -mBaseBowOffset
        //根据滑动进度偏移
        //如果当前进度>25%,表示已经到了终点,所以总是返回1
        //如果<=25%,因为总距离也是只有25%,所以要用4倍速度赶上
        offsetY += mMaxBowOffset * if (mProgress <= .25F) mProgress * 4else 1F
        //偏移弓
        mBowPath.offset(0F, offsetY)
    }


mBaseBowOffset,就是刚刚说的,弓一开始的偏移量(弓端点的y坐标值),它是这样得来的:


    //后面的 +mBowWidth,就是画笔(画弓)的宽度,这样才不会画出格
    mBaseBowOffset = getPointByAngle(mBaseAngle).y + mBowWidth


getPointByAngle就是上面初始化弓Path时用来计算弓端点坐标的方法。

mMaxBowOffset是弓的最大偏移量(最终停留在垂直的中线上):


    //弓高度
    val bowHeight = mBaseBowOffset
    //最大偏移量 = 弓高 + Drawable总高度-箭杆长度的一半
    mMaxBowOffset = bowHeight + (mHeight - mArrowBodyLength) / 2


emmmm,还记不记得,这个updateBowPath方法,当时是直接传的30度?


但是现在不能写死了,要根据progress来动态计算这个角度:


    /**
     * 根据当前拖动的进度计算出弓的弯曲角度
     */

    private fun getAngleByProgress() =
        //当前角度 = 基本角度 + (可用角度 * 滑动进度)
        mBaseAngle + if (mProgress <= .5F) 0
        else mUsableAngle * (mProgress - .5F/*对齐(从0%开始)*/) * 2F/*两倍速度追赶*/


mBaseAngle也就是一开始弓的那个弯曲角度,我们暂定为25度。


mUsableAngle就是可以弯曲的角度,暂定为20,那这个弓能弯曲的最大角度就是45度了。

在刚刚分配的滑动进度中,因为前50% 是用来偏移弓和箭的,所以在50%之前,弓的弯曲角度是不变的,也就是可以直接取mBaseAngle的值了。


过了50%之后,角度才开始变化,但这时候,进度已经被消费了一半,如果按照原速度来弯曲,肯定是来不及了,所以要用2倍速度弯曲。


弓弯曲了之后,握柄自然也就跟着弯曲了(因为是截取弓的中间一部分)。


那接下来到弦了,弦的话,其实只是偏移中间的点,两边的端点不用变。


那具体怎么做呢? 很简单,只需要在updateStringPoints方法中加几句代码就行:


    mStringOffset = mStringStartPoint.y + if (mProgress <= .5F) 0F
    else (mProgress - .5F) * mMaxStringOffset * 2F
    //改变弦的中点y坐标
    mStringMiddlePoint.y = mStringOffset


mStringOffset就是我们记录的弦的偏移量,它的计算方法是这样的:


当拖动的进度mProgress还没超过一半的时候,就不用偏移,即偏移量=0,如果超过了一半呢,就要2倍速度偏移了(因为已经消耗了一半)。


mMaxStringOffset就是弦的最大偏移量了,它的值是:


    //弓高度
    val bowHeight = mBaseBowOffset
    //弦最大偏移量 = 箭杆长度 - 弓的高度
    mMaxStringOffset = mArrowBodyLength - bowHeight


其实也就是预留了箭嘴的高度,那么在拉满弓的时候,就可以保证箭嘴在弓的上面。


好了,最后到箭的偏移啦。


因为我们刚刚并没有定义更新箭偏移量的方法,所以现在要新写一个了:


    /**
     * 更新箭偏移量
     */

    private fun updateArrowOffset() {
        var newOffset = 0F

        //如果进度超过一半,证明已经开始拉弓了
        if (mProgress > .5F) {
            //这时候可以直接使用弦的偏移量。
             newOffset = mStringOffset
        } else if (mProgress >= .25F) {
            //如果进度大于1/4,证明弓已经到达目的地,要开始箭的偏移了
            //这时候要用4倍速度去偏移,因为箭偏移的动作只分配了25%。
            newOffset = (mProgress - .25F/*对齐(从0开始)*/) * mStringOffset * 4F
        }
        //先重置偏移量为0(抵消)
        mArrowPath.offset(0F, -mArrowOffset)
        //应用新的偏移量
        mArrowPath.offset(0F, newOffset)
        //更新本次偏移量
        mArrowOffset = newOffset
    }


可以看到,每次更新箭偏移量的时候都要调用两次offset方法,为什么呢?

因为现在的箭我们是重用的,也就是只初始化了一次,如果不重置offset的话,那么这个偏移量每次都会重复叠加,这样肯定是不对的。


好了,现在到draw方法里,在调用drawArrow方法前,先调用updateArrowOffset更新一下箭的偏移量,看看效果:




哇!终于动起来了,哈哈哈哈哈哈,是不是很开心?


9
发射


在做发射动画之前,我们还要先定义几个状态,用来区分当前是要拉弓还是发射还是做其他:


    companion object{
        const val  STATE_NORMAL = 0 //静止状态

        const val  STATE_DRAGGING = 1 //正在拉弓

        const val  STATE_FIRING = 2 //发射动画播放中
    }


那么draw方法就可以改成这样:


    override fun draw(canvas: Canvas) {
        when (mState) {
            STATE_FIRING -> {
                //处理发射动画
            }
            else -> {
                ......
                //原来画弓箭弦的代码
                ......
            }
        }
    }


发射的动画,就按一开始说的那样,给每个要移动的元素定义三样东西:开始时间、动画时长、要移动的距离。


在这里重新捋一下动画的流程和细节:


  1. 弓先向下偏移,直至超出可见范围。偏移过程中弓会慢慢张开,张开的动作占用总进度的30%(即弯曲角度在弓偏移到总距离的30%处会恢复到初始的角度);

  2. 箭杆在离弦(弦的中点y值>箭的偏移量)之后,开始缩短(改变箭杆长度后重画),并且箭的小尾巴(一个外发光的矩形)慢慢出现;

  3. 在箭杆缩短了30%之后,箭开始上下反复移动(移动的幅度为一个箭羽的高度);

  4. 箭上下移动时,会出现一条条的竖线快速地往下掉;



好,那现在先来看看弓的行为代码:


     /**
     * 处理发射中的状态
     */

    private fun handleFiringState(canvas: Canvas) {
        //弓坠落动画已播放的时长
        val totalFallTime = (SystemClock.uptimeMillis() - mFireTime).toFloat()
        //检查弓坠落动画是否播放完毕
        if (totalFallTime <= mFiringBowFallDuration) {
            //得出动画已播放的百分比
            var percent = totalFallTime / mFiringBowFallDuration
            //处理溢出
            if (percent > 1) {
                percent = 1F
            }
            //当前要弯曲的角度
            //在弓向下移动了总距离的30%时完全展开(弯曲角度恢复到未拉弓前的角度)
            var angle = getAngleByProgress() - percent * 3F * mUsableAngle
            //弯曲角度不能小于未拉弓前的角度
            if (angle < mBaseAngle) {
                angle = mBaseAngle
            }
            //根据新的角度更新弓的Path
            updateBowPath(angle)
            //偏移弓,偏移量就是当前进度 * 要偏移的总距离
            mBowPath.offset(0F, percent * mFiringBowOffsetDistance)

            //画弓
            drawBowPath(canvas)
            //更新握柄Path
            updateHandlePath()
            //画手柄
            drawHandlePath(canvas)

            //更新弦坐标点
            updateStringPoints(false)
            if (mStringMiddlePoint.y < mStringStartPoint.y) {
                //弦中点y值小于两边端点y值的时候,证明箭已经离弦了
                //弦绷紧(即三个点的y值都一样)
                mStringMiddlePoint.y = mStringStartPoint.y
                //箭杆的缩放动画是时候播放了,记录开始时间
                if (mFiredArrowShrinkStartTime == 0L) {
                    mFiredArrowShrinkStartTime = SystemClock.uptimeMillis()
                }
            }
            //画弦
            drawString(canvas)
            //画箭(这时候箭不用更新偏移量)
            drawArrow(canvas)
        }
        //不断请求重绘
        invalidateSelf()
    }


mFireTime、mFiringBowFallDuration、mFiringBowOffsetDistance分别是刚刚说的:开始时间、动画时长、要偏移的总距离。


mFiredArrowShrinkStartTime就是等下箭的缩短动画的开始时间。


还有一个更新弦坐标点的方法updateStringPoints,可以看到这次传了个false进去,这个boolean是用来判断弦的中点y坐标是否跟随当前拉弓的进度作偏移。


因为现在只是弓向下移动,箭的位置是不变的,所以弦的中点坐标也不用变。当箭离弦后,中点的y值跟两端点的y值一样(变成一条直线)。


这样说好像有点抽象,先来看个图吧:



就是这样了。


现在来看看修改后的updateStringPoints方法:


    private fun updateStringPoints() {
        updateStringPoints(true)
    }

    private fun updateStringPoints(updateMiddlePointY: Boolean) {

        ......
        //上面的代码不变
        ......

        if (updateMiddlePointY) {
            //y轴呢,先跟起始点的y轴一样
            mStringMiddlePoint.y = mStringStartPoint.y
            mStringOffset = mStringStartPoint.y + if (mProgress <= .5F) 0F
            else (mProgress - .5F) * mMaxStringOffset * 2F
            //改变弦的中点y坐标
            mStringMiddlePoint.y = mStringOffset
        }
    }


其实只是在更新mStringMiddlePoint.y(弦的中点y坐标)值之前加了条件判断,如果参数为false就不更新。可以看到这个方法还被分成了两个,没参数的那个默认为true,也就是修改之前的效果了。


好,那接下来到箭杆的缩短和发光的箭尾了:


箭杆可以用上面偏移弓那种做法,还记不记得当时初始化箭Path的方法,需要传一个箭杆长度进去?


那么等下我们就可以先计算出当前箭的长度,再调用那个方法来重新初始化箭,以达到缩短的效果。


至于发光的箭尾,它其实就是一个加了MaskFilter的矩形,但是要注意的是:


MaskFilter不支持硬件加速,所以等下还要先把硬件加速给关掉。


来看看它初始化的代码:


    //箭尾
    private val mArrowTail = RectF()

    /**
     * 初始化箭尾
     */

    private fun initArrowTail() {
        //箭尾尺寸暂定为箭羽宽高的两倍
        val tailHeight = mFinHeight * 2
        //位置在Drawable的水平中点上
        mArrowTail.set(mCenterX - mFinWidth, 0F, mCenterX + mFinWidth, tailHeight)
        //发光效果,模式为内外发光,半径为箭羽的宽度
        mTailMaskFilter = BlurMaskFilter(mFinWidth, BlurMaskFilter.Blur.NORMAL)
    } 


因为这些都是可以重用的,所以应该像初始化箭Path那样,在箭尺寸确定后调用这个方法就行了。


看看绘制的方法:


    /**
     * 画箭尾
     */

    private fun drawArrowTail(canvas: Canvas) {
        //实心的
        mPaint.style = Paint.Style.FILL
        //加上发光效果
        mPaint.maskFilter = mTailMaskFilter
        //画箭尾
        canvas.drawRect(mArrowTail, mPaint)
        //移除发光效果(因为等下还可能要画其他东西)
        mPaint.maskFilter = null
    }   


好,接下来是处理动画的方法:


    /**
     * 画正在缩短的箭
     */

    private fun drawShrinkingArrow(canvas: Canvas) {
        //先算出已播放的时长
        val runTime = (SystemClock.uptimeMillis() - mFiredArrowShrinkStartTime).toFloat()
        //得出当前进度
        var percent = runTime / mFiredArrowShrinkDuration
        if (percent > 1) {
            percent = 1F
        }
        //当前进度 * 要缩短的总长度 = 当前要缩短的长度
        val needSubtractLength = percent * mFiredArrowShrinkDistance
        //新的箭杆长度(原始长度 - 要缩短的长度)
        val arrowLength = mArrowBodyLength - needSubtractLength
        //根据新的箭杆长度重新初始化箭的Path
        initArrowPath(arrowLength)

        //因为现在的箭是新画的,还没有偏移量,所以还要偏移一下
        //箭新的偏移量(缩短了多少就向下偏移多少,以保持箭头位置不变)
        val newArrowOffset = mArrowOffset - needSubtractLength
        //应用偏移到箭
        mArrowPath.offset(0F, newArrowOffset)
        //更新箭尾的位置:x坐标不变(在Drawable的中间),y坐标,在箭的底部往上偏移一半的箭羽高度
        mArrowTail.offsetTo(mArrowTail.left, newArrowOffset - mFinHeight / 2)

        mPaint.color = Color.YELLOW
        //在缩短过程中,慢慢出现(透明度渐变)
        mPaint.alpha = (255 * percent).toInt()
        //画箭尾
        drawArrowTail(canvas)
        //重置透明度
        mPaint.alpha = 255
        drawArrow(canvas)

        if (percent == 1F) {
            //缩短动画播放完毕,开始上下移动的动画
            mFiredArrowShrinkStartTime = 0
            mFiredArrowMoveStartTime = SystemClock.uptimeMillis()
        }
    }


逻辑呢,跟上面偏移弓的是一样的,也是先计算出百分比,再根据百分比计算出当前的距离(要缩短的长度)。


可以看到还调用了mArrowTail的offsetTo方法,这个方法是用绝对坐标来定位的,我们传进去的那两个参数分别对应left和top。


在动画结束时,还记录了下一个环节(上下移动)的开始时间。


好,来看看现在的效果是怎么样的:



哈哈哈,箭最后消失了的原因是动画已经播放完毕,不符合draw的条件。


那现在来把剩下的动画完善一下:


先是箭上下移动的方法:


    /**
     * 画正在上下移动的箭
     */

    private fun drawDancingArrow(canvas: Canvas) {
        val runTime = (SystemClock.uptimeMillis() - mFiredArrowMoveStartTime).toFloat()
        var percent = runTime / mFiredArrowMoveDuration
        if (percent > 1) {
            percent = 1F
        }
        //基于当前进度计算得出绝对偏移亮
        val distance = percent * mFiredArrowMoveDistance
        //减去上一次记录的 已偏移距离,得出相对偏移量
        val offset = distance - mFiredArrowLastMoveDistance
        //应用相对偏移量到箭
        mArrowPath.offset(0F, offset)
        //应用相对偏移量到箭尾
        mArrowTail.offset(0F, offset)
        //记录上一次的绝对偏移量
        mFiredArrowLastMoveDistance = distance
        //画箭
        drawArrow(canvas)
        //画尾巴
        drawArrowTail(canvas)
        //检查本次动画是否播放完毕
        if (percent == 1F) {
            //刷新开始时间
            mFiredArrowMoveStartTime = SystemClock.uptimeMillis()
            //切换方向
            mFiredArrowMoveDistance = -mFiredArrowMoveDistance
            //重置上一次的偏移距离
            mFiredArrowLastMoveDistance = 0F
        }
    }


可以看到,在动画播放完成之后,并没有将动画的开始时间置0,而是刷新这个时间,让它一直重复上下移动。


好,现在来想想,不断从顶部掉下来的线条,要怎么画呢?


其实一样可以用偏移动画的方法来做,不过呢,这些线条除了开始时间、总时长、总距离这三样,还有两个端点的坐标值要记录,如果不把这些东西装起来的话,那么等下写起代码来就会很痛苦,所以我们应该用一个内部类来把它们封装起来:



接着用List把它装起来:



还没开始学习Kotlin的同学看这句代码可能有点费解,其实就是在创建了MutableList实例后,再创建6个Line实例并把它放到mLines里面去。


接下来到画的,很简单,把参数填上去就行了:



那么,在画完之后,肯定还要有一个更新线条坐标的方法,不然的话这些线条就不会动了:



可以看到,当这些线条播放完之后呢,会被重用(调用initLines方法重新初始化),来看看它是怎么初始化的:



好,各个方法都定义好了之后,现在来把它们拼装起来,我们修改一下刚刚的handleFiringState方法:



emmmm,最后还需要一个fire方法来触发射箭的动画:



好了,来看看现在的效果:



哇!太棒了!


文章实在是太长了,超过了微信的限制,通过截图各种方式缩短字数都不太好处理,故省略了最后一节,整体不影响阅读和学习。


哈哈哈,可以了,发张表情包鼓励下自己:


好了,本篇文章到此结束,有错误的地方请指出,谢谢大家!


Github地址,迎Star

https://github.com/wuyr/ArrowDrawable

推荐↓↓↓
安卓开发
上一篇:Android 多 Fragment 切换优化 下一篇:QQ空间说说都有弹幕咯