前言
Emmmm,看标题大概就能猜到,这次我们要做的是一个射箭的效果。
在这篇文章中,同学们可以学到:
- 在自定义Drawable里流畅地draw各种动画;
- 画一条粗细不一的线段;
- 一个炫酷的射箭效果;
来看两张效果图:
嗯,就是这样了,可以看到,我们等下要做的这个效果,还能用来当下拉刷新的动画,很炫酷。
这里有同学可能会说:“这些动画,叫UI用AE画一个,然后用Lottie
加载不就行了?”
可以是可以,但是, 让UI画出来,那知识是人家UI的,到头来自己还是不会。 那下次遇到类似效果的时候,还是要去麻烦UI。
还有就是,用Lottie
动画只能控制播放进度,而不能动态更改里面的属性,比如我要把弓箭的颜色变成黑色,箭头放大2倍,这样的话,对于Lottie来说,就有点无能为力了。
当然了,Lottie还是有好多其他方面优势的,但本篇文章主题不是Lottie,所以就不多提了。
好,下面开始分析怎么用自定义Drawable来实现这个效果。
初步分析
先来看看茄子同学画的这张图:
像这种类型的,我们可以把它各个组成部分都拆出来:
- 首先是弓,那弓要怎么画出来呢?其实就是一段二阶的贝塞尔曲线;
- 接着看弓的中间部分,有一小段明显比较粗的线,像个握柄,那这个握柄可以截取弓线条的一部分,然后把画笔宽度加大,再draw出来;
- 第三部分,弦,这个很简单,确定坐标后画线就行了;
- 最后一个,箭,这个依然可以用Path来画;
那根据上面所拆分的部分,就有了以下几个属性:
- 弓的
Path
; - 握柄的
Path
; - 弦的起始点(
Point
)、中点(Point
)、结束点(Point
); - 箭的
Path
;
为什么弦要有三个点呢?不是只要一个起始点和一个结束点就行了?
因为要照顾后面的拉弓效果,那时候的弦会被箭羽分成两条的。
好,静止状态下画法是有了,接下来想想动态的要怎么做。
从上面的效果图可以看出,当拉弓的时候:
- 弓,弯曲角度会渐渐增大;
- 握柄,随着弓的变化而变化;
- 弦,中点始终在箭羽的底部;
- 箭,垂直降落;
那要怎么实现弓逐渐弯曲的效果呢?
上面说到,这个弓可以用二阶贝塞尔来画(Path的quadTo
方法),这个方法接收4个参数,也就是控制点
和结束点
各自的坐标(x,y)
了,起始点
就是Path上一次的落点(可以调用moveTo
来调整)。
那么我们就可以通过改变这个起始点
和结束点
的位置,来实现弓的弯曲效果。
怎么个改变法?
要是直接把坐标点垂直往下移动,那效果就不好了,因为弓会被越拉越长。
就像这样:
这样看上去就很不自然。
正确的方法应该是:
让这个坐标点,绕弓的中点旋转,半径就是弓长的一半,然后计算旋转后的值。
看图:
emmmm,其实也就是根据旋转角度求点在圆上的坐标了,其公式是:
x = 半径 * cos(弧度)
y = 半径 * sin(弧度)
当然了,最后还要加上圆心的坐标值的。
计算出坐标后,重新用Path的quadTo
方法把它们连起来就行了。
那弓的Path更新了之后,握柄自然也就好了(截取中间的一小段)。
弦的话,在箭下降的时候只需要改变中点的y轴坐标值。
箭一样很简单,甚至不用重新初始化箭的Path,只需要调用Path的offset
方法来垂直偏移就行了。
那最后的发射动画,应该怎么做呢?
仔细观察刚开始的效果图,当拉满弓,把箭发射出去的时候会看到:
- 弓先向下移动,直至超出可见范围,并且在移动过程中弯曲角度会慢慢变小;
- 箭离弦之后,开始缩短(改变箭长后重画),并且箭的小尾巴慢慢出现;
- 箭缩短到一定程度之后,开始上下移动;
- 箭上下移动时,会出现一条条的竖线快速地往下掉;
不要被这么多步骤吓到了,其实每个步骤都很简单的。
比如弓向下移动的,我们只需要记录三样东西:
- 开始时间;
- 动画的时长;
- 要移动的总距离;
当每次更新帧(draw)的时候,先计算出已播放时长(当前时间-开始时间)
,然后用这个已播放时长
/动画总时长
得出动画当前播放进度
,最后用动画当前播放进度
*总距离
得出偏移量(当前要偏移的距离)
。
那接下来,就可以把这个偏移量
,应用到弓的Path上了(调用offset
方法)。
其他的也是一样原理,只是操作的变量不同,比如箭的缩短动画: 用播放进度
*要缩短的总长度
得出当前要缩短的长度
,然后用箭初始长度
-当前要缩短的长度
得出新的长度
,再基于这个新的长度
重新画箭就行了。
好啦,分析的差不多了,准备写代码咯。
自定义线条
细心的同学会发现,效果图的中弓,它的两端是比较细的,越接近中间就越粗,但这种效果在SDK中并没有提供直接的API。
那应该怎么做呢?
我们知道,在屏幕上看到的图像,是由一粒粒像素点组成的,那么,可不可以把一条线(Line
)分解成一粒粒点(Point
),然后改变每一个点的Width
,再draw
出来?
答案是肯定的。
分解线条,要怎么分解?
当然是借助强大的PathMeasure
来分解了:
根据Path
创建PathMeasure
实例后,可以调用其getPosTan
方法获得每一个点的坐标值,这些坐标值正是我们想要的东西。
看代码怎么写:
/**
* 分解Path
* @return Path上的全部坐标点
*/
private float[] decomposePath(PathMeasure pathMeasure) {
if (pathMeasure.getLength() == 0) {
return new float[0];
}
final float pathLength = pathMeasure.getLength();
final int precision = 1;
int numPoints = (int) (pathLength / precision) + 1;
float[] points = new float[numPoints * 2];
final float[] position = new float[2];
int index = 0;
float distance;
for (int i = 0; i < numPoints; ++i) {
distance = (i * pathLength) / (numPoints - 1);
pathMeasure.getPosTan(distance, position, null);
points[index] = position[0];
points[index + 1] = position[1];
index += 2;
}
return points;
}
没错了就是这样,当方法结束后会返回Path上的全部坐标点。
那怎么计算出来每个点的缩放比例?
至于计算平滑缩放比例,我们可以按照飞龙在天的思路:
来封装一个ScaleHelper类。
首先是构造方法:
private float[] mScales;
ScaleHelper(float... scales) {
updateScales(scales);
}
/**
* 更新平滑缩放比例,数组长度必须是偶数
* 偶数索引表示要缩放的比例,奇数索引表示位置 (0~1)
* 奇数索引必须要递增,即越往后的数值应越大
* 例如:
* [0.8, 0.5] 表示在50%处缩放到原来的80%
* [0, 0, 1, 0.5, 0, 1]表示在起点处的比例是原来的0%,在50%处会恢复原样,到终点处会缩小到0%
*
* @param scales 每个位置上的缩放比例
*/
void updateScales(float... scales) {
//如果没有指定缩放比例,默认不缩放
if (scales.length == 0) {
scales = new float[]{1, 0, 1, 1};
}
//检查是否存在负数
for (float tmp : scales) {
if (tmp < 0) {
throw new IllegalArgumentException("Array value can not be negative!");
}
}
if (!Arrays.equals(mScales, scales)) {
//长度一定要为偶数
if (scales.length < 2 || scales.length % 2 != 0) {
throw new IllegalArgumentException("Array length no match!");
}
//最后赋值
mScales = scales;
}
}
有了缩放比例之后,接下来看看怎么计算任意位置上的缩放比例:
/**
* 获取指定位置的缩放比例
* @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);
}
其实很简单:先找出输入位置到底在哪两个给定的位置之间,然后套个公式就行了。
好,现在来写代码测试下:
//画一条斜线
Path path = new Path();
path.moveTo(0, 0);
path.lineTo(900, 1000);
//分解Path
float[] points = decomposePath(new PathMeasure(path, false));
ScaleHelper scaleHelper = new ScaleHelper(
.5F, 0F, /*在起始位置的缩放比例是50%*/
.1F, .3F, /*在30%处的缩放比例是10%*/
1.2F, .8F, /*在80%处的缩放比例是120%*/
1F, 1F /*在终点位置的缩放比例是100%*/);
final int length = points.length;
//原始线宽为50
final int baseLineWidth = 50;
float fraction;
float radius;
//遍历分解后的点,然后画圆点
for (int i = 0; i < length; i += 2) {
fraction = ((float) i) / length;
radius = baseLineWidth * scaleHelper.getScale(fraction) / 2;
canvas.drawCircle(points[i], points[i + 1], radius, mPaint);
}
我们在线条的起点处(0%),缩放了50%,在30%处缩放到了10%,而在80%处则放大到原始的120%,最后在终点处恢复正常大小
看看效果:
emmmm,效果还不错。
好了,现在基本的东西都已准备好,可以正式开始啦
创建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版本的在文章最后会给出地址)
class ArrowDrawable private constructor(
private var mWidth: Int, //Drawable的宽
private var mHeight: Int //Drawable的高
) : Drawable() {
private var mPaint = Paint()
init {
initPaint()
}
private fun initPaint() {
mPaint.isAntiAlias = true
mPaint.strokeCap = Paint.Cap.ROUND
mPaint.strokeJoin = Paint.Join.ROUND
}
override fun draw(canvas: Canvas) {
}
override fun getIntrinsicWidth() = mWidth
override fun getIntrinsicHeight() = mHeight
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {
mPaint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
mPaint.colorFilter = colorFilter
}
}
可以看到,现在只重写了几个基本的方法:
getIntrinsicWidth
,getIntrinsicHeight
这两个方法用来告诉外面,它内容的宽和高;getOpacity
、setAlpha
、setColorFilter
,这三个是Drawable的抽象方法,大多数情况下像上面那样做就行了;draw
,最重要就是这个了,我们等下都在draw
方法里画东西;
画弓
好,现在先来画弓。
上面说到:弓的结束点可以根据弯曲的角度,计算出绕中点旋转后的坐标,旋转半径就是弓长的一半。
那这个弓长,可以让外部来提供,这样更灵活。
来看看计算坐标的代码:
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)
}
来看看效果:
可以啦。
画弦
相信同学们都注意到了,弦的两端点,它的位置都不是在弓的端点上,只是接近弓的端点。
要拿到那两个点的位置很简单,因为我们刚刚在画弓的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)
}
好,来看看效果:
太棒啦~
画箭
上面说过可以用Path来画,但是在画之前,必须要先确定好每一段线条的尺寸,比如箭羽高度啊,箭杆长度这些。
来看下茄子同学的这张图:
看那一段段绿色的线,可以看出,一共要定义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)
}
箭的初始化方法,可以在箭的各个尺寸都确定好了之后调用,因为它不用每次都重新画,是可以重用的。
看看:
不错不错,就是这样了。不过现在的弓一开始还是在边界范围内,这是不对的,等下还要把弓给弄到上面去。
拉弓
静态的处理完之后,轮到动态的了。
想一想,在拉弓的时候,肯定不能无限往后拉的,弓有个最大的弯曲角度,而且还要记录一个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
更新一下箭的偏移量,看看效果:
哇!终于动起来了,哈哈哈哈哈哈,是不是很开心?
发射
在做发射动画之前,我们还要先定义几个状态,用来区分当前是要拉弓还是发射还是做其他:
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 -> {
......
//原来画弓箭弦的代码
......
}
}
}
发射的动画,就按一开始说的那样,给每个要移动的元素定义三样东西:开始时间、动画时长、要移动的距离。
在这里重新捋一下动画的流程和细节:
- 弓先向下偏移,直至超出可见范围。偏移过程中弓会慢慢张开,张开的动作占用总进度的30%(即弯曲角度在弓偏移到总距离的30%处会恢复到初始的角度);
- 箭杆在离弦(弦的中点y值>箭的偏移量)之后,开始缩短(改变箭杆长度后重画),并且箭的小尾巴(一个外发光的矩形)慢慢出现;
- 在箭杆缩短了30%之后,箭开始上下反复移动(移动的幅度为一个箭羽的高度);
- 箭上下移动时,会出现一条条的竖线快速地往下掉;
好,那现在先来看看弓的行为代码:
/**
* 处理发射中的状态
*/
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,而是刷新这个时间,让它一直重复上下移动。
好,现在来想想,不断从顶部掉下来的线条,要怎么画呢?
其实一样可以用偏移动画的方法来做,不过呢,这些线条除了开始时间、总时长、总距离这三样,还有两个端点的坐标值要记录,如果不把这些东西装起来的话,那么等下写起代码来就会很痛苦,所以我们应该用一个内部类来把它们封装起来:
/**
* 坠落的线条
*/
private class Line {
var duration = 0L//坠落的时长
var startTime = 0L//开始坠落的时间
var distance = 0F//坠落的总距离
var startX = 0F//线条端点x坐标
var startY = 0F//线条端点y坐标
var height = 0F//线条高度
var endX = 0F//线条端点x坐标
}
接着用List
把它装起来:
//发射中坠落的线条
private var mLines = MutableList(6){ Line() }
还没开始学习Kotlin的同学看这句代码可能有点费解,其实就是在创建了MutableList
实例后,再创建6个Line
实例并把它放到mLines
里面去。
接下来到画的,很简单,把参数填上去就行了:
/**
* 画正在坠落的线条
*/
private fun drawLines(canvas: Canvas) {
mPaint.style = Paint.Style.STROKE
//遍历Lines来把全部的线条draw出来
mLines.forEach {
canvas.drawLine(it.startX, it.startY, it.endX, it.startY + it.height, mPaint)
}
}
那么,在画完之后,肯定还要有一个更新线条坐标的方法,不然的话这些线条就不会动了:
/**
* 更新每一条线的y坐标
*/
private fun updateLinesY() {
mLines.forEach {
//该线条已坠落的时间
val runtime = (SystemClock.uptimeMillis() - it.startTime).toFloat()
//动画播放的百分比
val percent = runtime / it.duration
//根据百分比更新线条的y坐标
it.startY = percent * it.distance - it.height
//如果该线条已超出屏幕,则重新初始化,即回到顶部重新开始坠落
if (it.startY >= mHeight) {
initLines(it)
}
}
}
可以看到,当这些线条播放完之后呢,会被重用(调用initLines
方法重新初始化),来看看它是怎么初始化的:
/**
* 初始化线条数据
*/
private fun initLines(tmp: Line) {
//记录开始时间
tmp.startTime = SystemClock.uptimeMillis()
//随机时长:最小不会小于给定时长的1/4,最大时长是给定时长的1.25倍
tmp.duration = (mBaseLinesFallDuration / 4 + mRandom.nextInt(mBaseLinesFallDuration)).toLong()
//线条起始点的y坐标值,是一个负的随机数,最大不超过Drawable的高度
tmp.startY = -mHeight + mRandom.nextFloat() * mHeight
//线条的结束点y坐标值刚好未为0
tmp.height = -tmp.startY
//在x轴上随机一个位置坠落
tmp.startX = mRandom.nextFloat() * mWidth
//两端点的x轴坐标是一样的,即垂直的线条
tmp.endX = tmp.startX
//要偏移的距离就是线条起始点离Drawable底部的距离
tmp.distance = mHeight - tmp.startY
}
好,各个方法都定义好了之后,现在来把它们拼装起来,我们修改一下刚刚的handleFiringState
方法:
private fun handleFiringState(canvas: Canvas) {
......
//原来的代码不变
......
if (mFiredArrowShrinkStartTime > 0) {
//画不断缩短的箭
drawShrinkingArrow(canvas)
} else if (mFiredArrowMoveStartTime > 0) {
//先画线条
drawLines(canvas)
//更新线条坐标
updateLinesY()
//画重复上下移动的箭
drawDancingArrow(canvas)
}
invalidateSelf()
}
emmmm,最后还需要一个fire
方法来触发射箭的动画:
fun fire() {
//标记当前状态为 正在发射
mState = STATE_FIRING
//首先初始化线条数据
mLines.forEach { initLines(it) }
//重置缩短动画的开始时间
mFiredArrowShrinkStartTime = 0
//重置上下移动动画的开始时间
mFiredArrowMoveStartTime = 0
//重置上一次的偏移距离
mFiredArrowLastMoveDistance = 0F
//记录发射开始时间
mFireTime = SystemClock.uptimeMillis()
invalidateSelf()
}
好了,来看看现在的效果:
哇!太棒了!
命中
到最后一个环节啦,命中的动画,它会先向上移动,直至箭嘴没入Drawable顶部,接着尾部开始左右摆动,摆动一定次数后停止。
向上移动这个是完全没有问题,关键是摆动要怎么个摆动法呢?
熟悉Canvas
的同学会知道,它有一个skew
方法,是用来倾斜画布的,那么我们等下也可以用倾斜画布的方法,来做这个左右摆动的效果。
好,现在先来做箭向上移动的动画吧:
在开始之前,我们还要定义一个新的状态:STATE_HITTING = 3
那么在draw
方法里就可以加上这个状态的分支了。:
override fun draw(canvas: Canvas) {
when (mState) {
STATE_HITTING ->{
handleHittingState(canvas)
}
......
......
}
}
来看看这个handleHittingState
方法:
/**
* 处理命中状态
*/
private fun handleHittingState(canvas: Canvas) {
if (mHitStartTime > 0) {
//画向上偏移的动画
drawArrowHitting(canvas)
//请求重绘
invalidateSelf()
} else {
if (mSkewStartTime > 0) {
//画左右摆动的动画
drawArrowSkewing(canvas)
//请求重绘
invalidateSelf()
} else {
//如果摆动动画播放完成,就直接画箭
//并且不再请求重绘
drawArrow(canvas)
}
}
}
/**
* 画正在射向目标的箭
*/
private fun drawArrowHitting(canvas: Canvas) {
val runTime = (SystemClock.uptimeMillis() - mHitStartTime).toFloat()
var percent = runTime / mHitDuration
if (percent > 1) {
percent = 1F
//偏移动画结束,开始左右摆动的动画
mHitStartTime = 0
mSkewStartTime = SystemClock.uptimeMillis().toFloat()
//标记当前摆动的次数是1
mCurrentSkewCount = 1
}
val distance = percent * mHitDistance
//相对偏移量
val offset = distance - mFiredArrowLastMoveDistance
mFiredArrowLastMoveDistance = distance
//偏移箭和箭尾
mArrowPath.offset(0F, offset)
mArrowTail.offset(0F, offset)
//画线条
drawLines(canvas)
//更新线条坐标
updateLinesY()
//画箭
drawArrow(canvas)
//箭尾渐渐变得透明起来,直至完全透明
mPaint.alpha = (255 * (1 - percent)).toInt()
drawArrowTail(canvas)
mPaint.alpha = 255
}
emmmm,偏移的动画逻辑都是大同小异的,我们重点来看下面的drawArrowSkewing
方法:
/**
* 画正在左右摇摆的箭
*/
private fun drawArrowSkewing(@NonNull canvas: Canvas) {
val runTime = SystemClock.uptimeMillis() - mSkewStartTime
var percent = runTime / mSkewDuration
if (percent > 1) {
percent = 1F
}
//当前要摆动的幅度
var tan = mSkewTan * percent
//如果是偶数,则向左摆动,否则向右
if (mCurrentSkewCount % 2 == 0) {
tan -= mSkewTan
}
//倾斜画布
canvas.skew(tan, 0F)
//画箭
drawArrow(canvas)
if (percent == 1F) {
//如果摆动的次数达到指定的次数则停止动画
if (mCurrentSkewCount == mMaxSkewCount) {
//完满结束,重置时间
mSkewStartTime = 0F
return
} else {
//更新动画的开始时间
mSkewStartTime = SystemClock.uptimeMillis().toFloat()
//记录当前已摆动的次数
mCurrentSkewCount++
}
//如果次数为偶数就要切换方法(一次来一次回,所以是偶数)
if (mCurrentSkewCount % 2 == 0) {
mSkewTan = -mSkewTan
}
}
}
我们的摆动策略是这样的:
- 一共要摆动的次数为8次(
mMaxSkewCount = 8
),左右各2次来回; - 每次摆动的幅度大概为2度(
mSkewTan = .035F
)(这个是正切值); - 第一次向右偏移(刚刚的
drawArrowHitting
方法标记了mCurrentSkewCount
为1
),偏移结束后,mCurrentSkewCount
会+1,+1后就是偶数了,继续往下执行,因为检测到是偶数还会把目的地取反,也就是要往相反方向走了。所以当它重新开始动画时,所偏移的方向是反的,这样一来一回,看上去就像是箭尾在摆动的样子。
好,最后还要定义一个hit
方法,用来触发命中动画:
fun hit() {
//处在上下移动状态时才可以hit
if (mState == STATE_FIRING && mFiredArrowMoveStartTime > 0) {
mState = STATE_HITTING
//标记命中动画开始时间
mHitStartTime = SystemClock.uptimeMillis()
//计算出当前箭的偏移量
var currentArrowOffset = mArrowOffset + mFiredArrowLastMoveDistance
if (mFiredArrowMoveDistance > 0) {
//如果距离是正数,证明已经向上偏移过一次了,因为第一次是负数,所以要减去这个距离
currentArrowOffset -= mFiredArrowMoveDistance
}
//除去箭嘴的箭高度(要没入一个箭嘴的高度)
val arrowBodyHeight = mFinHeight + mFinSlopeHeight + mArrowBodyLength
//因为是向上移动,所以是负数
mHitDistance = -(currentArrowOffset - arrowBodyHeight)
//重置上一次的偏移量
mFiredArrowLastMoveDistance = 0F
invalidateSelf()
}
}
来看看最终的效果:
哈哈哈,可以了,发张表情包鼓励下自己: