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

来自:陈小缘

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?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?????????????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?->?1F?//最大是1
????????????progress?0?->?0F?//最小是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?*?4F?else?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)?0F?
????????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?????????????????angle?=?mBaseAngle
????????????}
????????????//根据新的角度更新弓的Path
????????????updateBowPath(angle)
????????????//偏移弓,偏移量就是当前进度?*?要偏移的总距离
????????????mBowPath.offset(0F,?percent?*?mFiringBowOffsetDistance)

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

????????????//更新弦坐标点
????????????updateStringPoints(false)
????????????if?(mStringMiddlePoint.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空间说说都有弹幕咯