前言
在上一篇的自定义Drawable中,我们学习了如何在Canvas上draw
一个射箭的动画,不过那个动画是以线条为主的,多看几眼可能就会觉得没味道了,那么在本篇文章,将和同学们一起做一个看起来更耐看,更丝滑的动画。
先看看效果图:
哈哈,小手是不是很可爱? O不OK?。
这个动画看上去挺难,但实际上还没有上一篇的射箭动画复杂。我们等下还会用上一些技巧,来简化画各个元素的步骤。
初步分析
先看看茄子同学画的这张图:
和上一篇的方式一样:先把各个组成部分拆开。
那这个杯子就可以拆分成:杯身、手柄、杯底、手柄底、还有咖啡。
- 咖啡的话,我们能很直观的看出来,就是一个咖啡色的实心圆形,再加上边缘的【透明~白色】放射渐变;
- 杯身其实也是一个圆形,只是它的直径比咖啡要大一点;
- 手柄看上去是一个旋转了45°的圆角矩形;
- 杯底和手柄底,其实也就是偏移一下位置,改一下颜色,重新画杯身和手柄罢了;
不过我们在画的时候,顺序刚好和上面的顺序相反,因为咖啡的圆形是在最上面,而杯底和手柄底则在最底层。
现在来看看手要怎么画:
看上去好像挺难,先不管,来拆分一下吧:
- 两只竖起来像K型的手指,看着是两个圆角矩形;
- 拇指和食指组成的O型手势,可以用一个圆弧来做;
- 保持垂直的手臂,其实就是一个矩形;
画手指技巧
如果手指和刚刚的手柄用圆角矩形
来画的话,就会很麻烦,因为除了要计算[l, t, r, b]
之外,还要计算和处理旋转角度。
那应该用哪种方式呢?
熟悉Paint的同学会知道一个叫Cap的东西,它可以改变线条端点的样式,一共有三种,分别是:BUTT、ROUND、SQUARE。默认情况下是第一个,但因为现在我们要把线条的端点变成圆,也就是要用第二个了。
来测试一下:
//设置端点样式为圆形
mPaint.strokeCap = Paint.Cap.ROUND
//线条
mPaint.style = Paint.Style.STROKE
//白色
mPaint.color = Color.WHITE
//加大线宽
mPaint.strokeWidth = 100F
//画线
canvas.drawLine(100F, 100F, 800F, 800F, mPaint)
看看效果:
emmm,没错了,等下画手柄和手指,都可以用这个方法来做,这样就方便了很多。
创建Drawable
像上次那样,先创建一个类继承自Drawable,然后把最基本的几个方法重写(因为我们这次要做的是搅拌咖啡的效果,名字就叫CoffeeDrawable了):
class CoffeeDrawable(private var width: Int, private var height: Int) : Drawable() {
private var paint = Paint()
init {
initPaint()
updateSize(width, height)
}
private fun initPaint() = paint.run {
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
fun updateSize(width: Int, height: Int) {
this.width = width
this.height = height
}
override fun draw(canvas: Canvas) {
}
override fun getIntrinsicWidth() = width
override fun getIntrinsicHeight() = height
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
}
画杯
好,先来画静态的杯子。刚刚分析过,杯子大致就是圆形 + 粗线条(手柄) 的组合,那么在画的时候就需要先定义以下变量:
- 中心点坐标:
centerX
,centerY
(因为杯子是在Drawable的中心处); - 杯子半径
cupRadius
、咖啡半径coffeeRadius
、手柄宽度cupHandleWidth
; - 最后一个,杯底的偏移量
cupBottomOffset
;
为了能适应各种尺寸的Drawable容器,这些变量应该基于Drawable的width
或height
来动态计算,而不是随便指定某个值。
这样的话,当Drawable尺寸变大时,我们的杯子也能跟着变大,缩小时,也能跟着缩小:
fun updateSize(width: Int, height: Int) {
//水平中心点
centerX = width / 2F
//垂直中心点
centerY = height / 2F
//杯子半径
cupRadius = width / 12F
//咖啡半径
coffeeRadius = cupRadius * .95F
//杯子手柄宽度
cupHandleWidth = cupRadius / 3F
//杯底偏移量
cupBottomOffset = cupHandleWidth / 2
}
可以看到,杯子的半径指定为Drawable宽度的1/12
,咖啡的半径则取杯子半径的95%
,手柄宽度是杯半径的1/3
,而杯底的偏移量则是手柄宽度的一半。
看看怎么画:
private fun drawCup(canvas: Canvas) {
/
// 先画底部,所以是先偏移
canvas.translate(0F, cupBottomOffset)
//杯底颜色
paint.color = -0xFFA8B5
//要画实心的圆
paint.style = Paint.Style.FILL
//画杯底
canvas.drawCircle(centerX, centerY, cupRadius, paint)
//手柄是线条
paint.style = Paint.Style.STROKE
//宽度
paint.strokeWidth = cupHandleWidth
//画手柄底部
canvas.drawLine(centerX, centerY, centerX + cupRadius, centerY + cupRadius, paint)
/
// 画完之后,偏移回来,继续画上面一层
canvas.translate(0F, -cupBottomOffset)
//杯身颜色
paint.color = Color.WHITE
paint.style = Paint.Style.FILL
//画杯身
canvas.drawCircle(centerX, centerY, cupRadius, paint)
//画手柄
paint.style = Paint.Style.STROKE
canvas.drawLine(centerX, centerY, centerX + cupRadius, centerY + cupRadius, paint)
//咖啡颜色
paint.color = -0x81A4C2
paint.style = Paint.Style.FILL
//画咖啡
canvas.drawCircle(centerX, centerY, coffeeRadius, paint)
}
我们先将画布向下偏移指定距离,画完底部两个元素(杯底,手柄底)之后,重新把画布偏移回原来位置,然后开始画杯身、手柄还有咖啡。
里面的颜色,在这里为了方便理解就直接写死了,正常情况应该用变量保存起来,方便动态修改。
还可以看到,画手柄时,直接从中心处延伸了一条线出来,那么这条线的长度就是一个腰长为cupRadius
的直角等腰三角形的底边长度。
好,来看看效果:
emmm,还差个边缘渐变的效果。
想一下,这个渐变的结束颜色的RGB,一定要跟杯身的一样,才不会有违和感。而且还要半透明,因为如果色值完全一样的话,就会和杯壁混在一起,显得很笨重。
所以我们要先把杯身颜色
变成半透明,然后再生成一个RadialGradient对象:
private fun initCoffeeShader() {
if (coffeeRadius > 0) {
//半透明
val a = 128
//把rgb先取出来
val r = Color.red(cupBodyColor)
val g = Color.green(cupBodyColor)
val b = Color.blue(cupBodyColor)
//获得一个半透明的颜色
val endColor = Color.argb(a, r, g, b)
//渐变色,从全透明到半透明
val colors = intArrayOf(Color.TRANSPARENT, endColor)
//全透明的范围从中心出发,到距离边缘的30%处结束,然后慢慢过渡到半透明
val stops = floatArrayOf(.7F, 1F)
coffeeShader = RadialGradient(centerX, centerY, coffeeRadius, colors, stops, Shader.TileMode.CLAMP)
}
}
加入到上面的updateSize
方法中:
fun updateSize(width: Int, height: Int) {
......
......
initCoffeeShader()
invalidateSelf()
}
在drawCup
方法中draw出来:
private fun drawCup(canvas: Canvas) {
......
......
paint.shader = coffeeShader
canvas.drawCircle(centerX, centerY, coffeeRadius, paint)
paint.shader = null
}
好,来看看现在的效果:
OK啦。
画手
跟着前面的思路:O型手指是圆弧、K型手指是线条、手臂是矩形。
那应该怎么定位这些元素呢?根据什么来定位?
我们知道,画圆弧需要提供一个矩形[l, t, r, b]
,那K型手指(线条)的一个端点,它的x
坐标就可以对齐这个矩形的右边,y
轴可以取矩形的top
+ height / 2
,也就是垂直居中。
手臂的话,可以先决定好宽度
,然后它的right
像K手指一样,与O手指矩形的右边相对齐,y
轴相对于O手指矩形垂直居中就行了。
那么整只手的架构,就像这样:
emmm,等下draw的时候,除手臂矩形是实心之外其他地方只需要加大线条宽度就行了(红色框不用,现在画出来只是为了方便理解)。
来看看代码怎么写:
首先是尺寸的定义,等下要用到:手指宽度
、K手指长度
x2(因为K手势的两只手指长度是不同的),O手势的半径
,手臂宽度
。
//手指宽度
fingerWidth = cupHandleWidth
//第二根手指长度
finger2Length = cupRadius * 1.2F
//第一根手指长度
finger1Length = finger2Length * .8F
//手指O形状半径
fingerORadius = cupRadius / 2F
//手臂宽度
armWidth = cupRadius
跟前面一样,都是计算的相对尺寸:
- 手指的宽度,我们指定它跟咖啡杯手柄的宽度一样;
- 第二根手指长度,是咖啡杯半径的1.2倍;
- 第一根手指长度比第二根短了20%;
- O型手指的O半径,取咖啡杯半径的一半;
- 手臂的宽度,直接跟咖啡杯半径一样大;
接着按刚刚的思路画,首先初始化那个矩形:
private fun updateOFingerRect() {
//o手指的中心点坐标
val oCenterX = width / 2F
val oCenterY = height / 2F
//根据o手指的半径来计算出矩形的边界
val left = oCenterX - fingerORadius
val top = oCenterY - fingerORadius
val right = left + fingerORadius * 2
val bottom = top + fingerORadius * 2
//更新矩形尺寸
oFingerRect.set(left, top, right, bottom)
}
有了矩形之后,开始根据这个矩形来画圆弧:
private fun updateOFingersPath() {
//预留开口角度为30度
val reservedAngle = 30F
//起始角度
val startAngle = 180 + reservedAngle
//扫过的角度
val sweepAngle = 360 - reservedAngle * 2
oFingersPath.reset()
oFingersPath.addArc(oFingerRect, startAngle, sweepAngle)
}
预留的开口角度现在写死为30度,等下我们会根据搅拌棒的宽度来动态计算这个值。
接下来到K手势了:
private fun updateKFingersPath() {
//o手指的中心点坐标
val oCenterY = height / 2F
kFingersPath.reset()
//第一根手指
kFingersPath.moveTo(oFingerRect.right, oCenterY)
kFingersPath.rLineTo(-fingerWidth, -finger1Length)
//第二根手指
kFingersPath.moveTo(oFingerRect.right, oCenterY)
kFingersPath.rLineTo(0F, -finger2Length)
}
两只手指的起始点,都像刚刚说的那样,在O手势矩形的右边,并且垂直居中。
定位了起点之后,会向上拉(-fingerLength
)。
两条线除了上拉的高度不同之外,其中一条线的结束点还向左边偏移了一个手指宽度的距离,避免重叠。
最后是手臂的Path:
private fun updateArmPath() {
val oCenterY = height / 2F
val halfFingerWidth = fingerWidth / 2
val left = oFingerRect.right - armWidth + halfFingerWidth
val top = oCenterY
val right = oFingerRect.right + halfFingerWidth
//底部直接对齐Drawable的底部,看上去就像是从底部伸出来的样子
val bottom = height.toFloat()
armPath.reset()
armPath.addRect(left, top, right, bottom, Path.Direction.CW)
}
可以看到手臂的矩形向右偏移了半个手指宽度,这是为了能对齐手指线条的右边。
因为线条在增加宽度时,是向两侧扩展的,我们把矩形向右偏移宽度的1/2,就刚好能对齐了。
好,现在把手指和手臂都draw出来:
override fun draw(canvas: Canvas) {
drawHand(canvas)
}
private fun drawHand(canvas: Canvas) {
//初始化各个元素
updateOFingerRect()
updateOFingersPath()
updateKFingersPath()
updateArmPath()
//画手臂
drawArm(canvas)
//画手指
drawOKFingers(canvas)
}
private fun drawArm(canvas: Canvas) {
paint.style = Paint.Style.FILL
paint.color = -0x16386c
canvas.drawPath(armPath, paint)
}
private fun drawOKFingers(canvas: Canvas) {
paint.style = Paint.Style.STROKE
paint.strokeWidth = fingerWidth
canvas.drawPath(oFingersPath, paint)
canvas.drawPath(kFingersPath, paint)
}
看看效果:
emmm,现在手臂的矩形,凸出了一部分,我们要把它给剪掉(差集运算,手臂矩形Path - O型手指Path)。
有同学可能会想:op运算不是只能计算封闭的Path的吗?你一条弧线怎么减?
虽然现在看上去只是一条弧线,但当你用作op运算的时候,它的形状是闭合的,就像是偷偷调用了close
方法一样。
来修改下updateArmPath
方法:
private fun updateArmPath() {
......
......
//剪掉与O形状手指所重叠的地方
armPath.op(oFingersPath, Path.Op.DIFFERENCE)
}
很简单,就在方法的最后加上这句就行了。
看看现在的效果:
棒~
画搅拌棒
现在手和杯都已经画出来了,接下来我们要借助一样东西把它们连接在一起,这个东西就是搅拌棒。
来看看茄子同学画的这张图:
跟前面的思路一样,搅拌棒同样也可以用一条线来实现。
先把搅拌棒和手连在一起:
可以看到,这条线右边的端点,是在O形状手指(圆弧)的左侧,并和它垂直居中。
来看看代码怎么写,先是更新搅拌棒坐标的方法:
private fun updateStickLocation() {
stickStartPoint.set(centerX, centerY)
//结束点先和起始点一样
stickEndPoint.set(stickStartPoint)
//结束点再向右偏移一个杯半径的距离
stickEndPoint.offset(cupRadius, 0F)
}
stickStartPoint
和stickEndPoint
分别是搅拌棒起始点和结束点的PointF对象实例。
我们暂时把搅拌棒的起始点放到Drawable的中心位置上,长度暂定为一个杯半径的距离。
搅拌棒定位好了之后,接着还要把手安上去,这一步很简单,只需要更新一下oCenterX
和oCenterY
(O形状手指的中心点坐标)就行了,因为刚刚在画手的时候,O形状手指、K形状手指、手臂都是基于这两个局部变量来定位的:
修改以下三个方法:
private fun updateOFingerRect() {
val oCenterX = stickEndPoint.x + fingerORadius
val oCenterY = stickEndPoint.y
......
......
//向左偏移半个手指宽度的距离
val halfFingerWidth = fingerWidth / 2
left -= halfFingerWidth
right -= halfFingerWidth
oFingerRect.set(left, top, right, bottom)
}
private fun updateKFingersPath() {
val oCenterY = stickEndPoint.y
......
......
}
private fun updateArmPath() {
val oCenterY = stickEndPoint.y
......
......
}
我们分别把手的各个元素(O手指、K手指、手臂)的基准点都进行了重新定位:由原来的Drawable中心点([width / 2F, height / 2F]
)改成了搅拌棒的结束点[stickEndPoint.x, stickEndPoint.y]
。
在updateOFingerRect
方法的最后,还将矩形向左偏移了半个手指宽度的距离,好让搅拌棒的结束点在两手指的中间处。
好,现在把搅拌棒画上:
override fun draw(canvas: Canvas) {
//更新搅拌棒坐标点
updateStickLocation()
//画搅拌棒
drawStick(canvas)
drawHand(canvas)
}
private fun drawStick(canvas: Canvas) {
paint.color = Color.WHITE
paint.style = Paint.Style.STROKE
paint.strokeWidth = coffeeStickWidth
canvas.drawLine(stickStartPoint.x, stickStartPoint.y, stickEndPoint.x, stickEndPoint.y, paint)
}
看看效果:
emmm,现在看上去两只手指都没有碰到搅拌棒,是因为在画O形状手指时,那个预留的开口角度写死为30度了,这是不对的,正确的做法应该是要根据搅拌棒宽度来动态计算。
那应该怎么计算呢?
来看看这张图:
这就很容易看出,这个开口角度可以借助反三角函数来得到。
现在已知的条件,是对边和斜边,所以要用asin来计算:
修改一下updateOFingersPath
方法:
private fun updateOFingersPath() {
//对边
val opposite = coffeeStickWidth / 2 + fingerWidth / 2
//斜边
val hypotenuse = fingerORadius.toDouble()
//预留开口角度 = asin(对边 / 斜边)
val reservedAngle = Math.toDegrees(asin(opposite / hypotenuse)).toFloat()
......
......
}
这样就行了,现在的预留开口角度(reservedAngle
)会根据搅拌棒的宽度来动态计算,当它变大时,这个角度也会跟着变大。
好,现在来把咖啡杯和搅拌棒连接起来,看看要怎么连:
可以看到,搅拌棒的端点现在是根据那两个黄色的圆来定位的,所以在确定好两个圆的圆心坐标
和半径
之后,就能借助cos
和sin
来根据旋转角度动态计算出端点的坐标值了。
还可以看出,左边大圆的圆心
和咖啡杯的圆心
位置是一样的,也就是Drawable的中心点了。右边的小圆,它的圆心坐标就是大圆圆心
向右偏移一个咖啡杯半径
的距离。
大圆的半径其实就是咖啡杯半径
的1/2,小圆是1/3。
好,有了这些数据之后,我们再来修改一下updateStickLocation
方法:
private fun updateStickLocation() {
//大圆半径
val startRadius = cupRadius / 2
//小圆半径
val endRadius = cupRadius / 3
//根据半径和旋转角度得到起始点的原始坐标值
stickStartPoint.set(getPointByAngle(startRadius, stickAngle))
//偏移到大圆的圆心坐标上
stickStartPoint.offset(centerX, centerY)
//根据半径和旋转角度得到结束点的原始坐标值
stickEndPoint.set(getPointByAngle(endRadius, stickAngle))
//偏移到小圆的圆心坐标上
stickEndPoint.offset(centerX + cupRadius, centerY)
}
就按刚刚说的那样做,先是根据半径(startRadius
, endRadius
)和旋转角度stickAngle
(现在是0)得到坐标值,然后偏移到目标圆的圆心坐标上。
可以看到里面是通过一个getPointByAngle
方法来计算坐标的,在上一篇的射箭动画中也用到了这个方法。
来看看它是怎样的:
private val tempPoint = PointF()
private fun getPointByAngle(radius: Float, angle: Float): PointF {
//先把角度转成弧度
val radian = angle * Math.PI / 180
//x轴坐标值
val x = (radius * cos(radian)).toFloat()
//y轴坐标值
val y = (radius * sin(radian)).toFloat()
tempPoint.set(x, y)
return tempPoint
}
好,看看现在的效果:
OKOK。
因为刚刚我们已经把手的各个元素改成以搅拌棒的结束点(stickEndPoint
)为基准了,所以现在更新搅拌棒的坐标之后,手的坐标也会跟着变。
搅拌咖啡
现在想要让它动起来太简单了,只需要不断更新搅拌棒坐标所依赖的stickAngle
就行:
//旋转一圈的时长
private var stirringDuration = 1000L
//开始时间
private var stirringStartTime = 0F
private fun updateStickAngle() {
if (stirringStartTime > 0) {
val playTime = SystemClock.uptimeMillis() - stirringStartTime
//得到当前进度
var percent = playTime / stirringDuration
if (percent >= 1F) {
percent = 1F
//转完一圈,重新开始
stirringStartTime = SystemClock.uptimeMillis().toFloat()
}
//逆时针旋转所以是负数
stickAngle = percent * -360F
}
}
还是跟上一篇一样的思路:记录起始时间
和时长
,然后计算出当前进度
,再用当前进度
* 总距离
,现在的距离就是-360,也就是每一次播放动画都逆时针旋转一圈。
可以看到,里面还判断了当前进度
是否>=1,如果是的话,证明本次动画已经播放完成,准备下一次动画的播放。
在开始动画前,我们还应该先定义两个状态,好让Drawable能根据不同的状态做出不同的行为:
private var state = 0
companion object {
//普通状态
const val STATE_NORMAL = 0
//搅拌中
const val STATE_STIRRING = 1
}
好,现在在draw
方法的最后,加上状态判断,并在里面调用刚刚的updateStickAngle
方法:
override fun draw(canvas: Canvas) {
......
......
if (state == STATE_STIRRING) {
updateStickAngle()
invalidateSelf()
}
}
就差一个start
方法来启动动画了:
fun start() {
if (state != STATE_STIRRING) {
//更新状态
state = STATE_STIRRING
//重置角度
stickAngle = 0F
//标记开始时间
stirringStartTime = SystemClock.uptimeMillis().toFloat()
//通知重绘
invalidateSelf()
}
}
看看效果:
emmm,动是动起来了,但看着好像很僵硬,因为现在手的各个元素的运动轨迹都是一样的。
我们可以在不同的元素上分别制造一些偏移,好让它们看上去更有活力一点,比如:
- O形状手指所对应的矩形,在每次水平偏移时,它的
right
可以只偏移一半,left
则正常偏移,这样的话,O形状手指就会随着矩形一起被拉伸,形成一个手指伸缩的效果; - K形手势的两只手指,在更新位置时还可以把搅拌棒起始点所对应的圆的
y
轴偏移量(正弦波)拿过来,应用到x
轴上;
好,就按着这个思路修改一下:
首先是updateOFingerRect
方法:
private fun updateOFingerRect() {
......
......
//如果是搅拌状态,则取搅拌棒x轴偏移量的一半
val rightOffset = if (state == STATE_STIRRING) {
(stickEndPoint.x - centerX - cupRadius) / 2
} else {
halfFingerWidth
}
//将原来的halfFingerWidth换成rightOffset
right -= rightOffset
......
}
接着是updateKFingersPath
方法:
private fun updateKFingersPath() {
......
val finger1Offset = stickStartPoint.y - centerY
val finger2Offset = finger1Offset / 2
kFingersPath.reset()
//第一根手指
kFingersPath.moveTo(oFingerRect.right, oCenterY)
kFingersPath.rLineTo(-finger1Offset - fingerWidth, -finger1Length)
//第二根手指
kFingersPath.moveTo(oFingerRect.right, oCenterY)
kFingersPath.rLineTo(-finger2Offset, -finger2Length)
}
新增的finger1Offset
和finger2Offset
,分别是K手势两只手指的结束点要偏移的距离,finger1Offset
取搅拌棒起始点的y
轴偏移量,而finger2Offset
则取finger1Offset
的一半,使得两根手指各有不同的摆动速度和幅度。
在lineTo
时,两根手指的x
轴都分别减去了对应的偏移量,这样就能随着搅拌棒端点的旋转而摆动起来了。
运行一下看看效果:
不错不错。
水涟漪
在文章开头的预览图中可以看到,在搅拌的时候会有一个涟漪效果,这个效果是怎么做的呢?
其实也就是一个圆弧,我们可以用Path来实现。不过这个圆弧在搅拌动画刚开始时是慢慢延长而不是突然出现的,所以要动态去更新Path。
细心的同学还会发现,这条涟漪是头大尾细的,还有不透明度也是从头到尾逐渐变小(越来越透明)。
但因为现在没有API可以直接画这样的线条,所以我们还需要先把画好圆弧的Path分解成坐标数组,来给圆弧上的每一个点设置不同的透明度,还有借助上一篇的那个缩放辅助类ScaleHelper来实现头大尾细的效果。
好,先来把Path搞定:
//涟漪是否完全展开
private var rippleFulled = false
private fun updateRipplePath() {
val halfSize = cupRadius / 2
val left = centerX - halfSize
val right = centerX + halfSize
val top = centerY - halfSize
val bottom = centerY + halfSize
var sweepAngle: Float
if (rippleFulled) {
sweepAngle = 180F
} else {
//因为现在的stickAngle为负数(逆时针),所以要取负数
//涟漪拉伸的速度是搅拌速度的一半,所以要/2
sweepAngle = -stickAngle / 2
if (sweepAngle >= 180) {
sweepAngle = 180F
//标记已满
rippleFulled = true
}
}
ripplePath.reset()
ripplePath.addArc(left, top, right, bottom, stickAngle, sweepAngle)
}
圆弧扫过的最大角度,我们指定为180度,也就是半圆了。
接着还要用搅拌棒的旋转角度stickAngle
来作为圆弧的起点,结束点取旋转角度的一半,也就是当搅拌棒刚好旋转了一圈时,这条圆弧也刚好完全伸展开,完全伸展开之后,就保持这个长度继续跟着搅拌棒转圈了。
Path准备好之后,看看要怎么把它画出来:
private val scaleHelper = ScaleHelper(1F, 0F, .2F, 1F)
private fun drawRipple(canvas: Canvas) {
paint.style = Paint.Style.FILL
paint.color = stickColor
//以最小缩放时的直径为精确度(确保在最小圆点之间也不会有空隙)
val precision = (coffeeStickWidth * .2F).toInt()
val points = decomposePath(ripplePath, precision)
//一半的透明度=128,但因为精度是coffeeStickWidth的1/5(0.2),
//也就是Path上一段长度为coffeeStickWidth的路径范围内最多会有5个点
//也就是会有5个半透明的点在叠加,为了保持这个透明度不变,还要用128 * 2 或 / 5
val baseAlpha = 128F * .2F
val length = points.size
var i = 0
while (i < length) {
//当前遍历的进度
val fraction = i.toFloat() / length
//小点的半径(因为是半径,所以要/2)
val radius = coffeeStickWidth * scaleHelper.getScale(fraction) / 2
//设置透明度
paint.alpha = (baseAlpha * (1 - fraction)).toInt()
//画点
canvas.drawCircle(points[i], points[i + 1], radius, paint)
//坐标点数组格式为【x,y,x,y,....】,所以每次+2
i += 2
}
}
可以看到在开头就创建了一个ScaleHelper对象的实例,里面传的四个参数的意思是:在线条的0%
处缩放100%
,100%
处缩放到20%
。也就是从大到小了,小到原尺寸的20%。
接着调用decomposePath
方法把Path分解成坐标点数组,然后遍历这个数组,并在里面画圆点,画圆点之前还给paint
设置了透明度,这个透明度是根据当前遍历的进度来计算的。
那个本来是半透明的baseAlpha
,为什么要 * 0.2 呢?
因为现在的圆弧是一个一个圆点堆出来的,如果有透明度的话,那么圆点和圆点之间重叠的部分,它的透明度就会累加,这样画出来的线条,就不是半透明了。
为了避免这种情况,我们事先计算出一个正常大小的圆点范围内最多能有几个圆点存在(取决于分解Path时的精度),然后把透明度调整为:即使多个圆点重叠,基准透明度也能够保持半透明(128)。
嗯,那个decomposePath
方法,也是从上一篇中拿过来的:
private fun decomposePath(path: Path, precision: Int): FloatArray {
if (path.isEmpty) {
return FloatArray(0)
}
val pathMeasure = PathMeasure(path, false)
val pathLength = pathMeasure.length
val numPoints = (pathLength / precision).toInt() + 1
val points = FloatArray(numPoints * 2)
val position = FloatArray(2)
var index = 0
var distance: Float
for (i in 0 until numPoints) {
distance = i * pathLength / (numPoints - 1)
pathMeasure.getPosTan(distance, position, null)
points[index] = position[0]
points[index + 1] = position[1]
index += 2
}
return points
}
好,现在在draw
方法中的updateStickLocation
方法调用之前,加上刚刚的updateRipplePath
和drawRipple
方法:
override fun draw(canvas: Canvas) {
......
updateRipplePath()
drawRipple(canvas)
updateStickLocation()
......
}
看看最终的效果(为了能看清涟漪效果特意加大了尺寸):
太棒了!
其实还有个边界渐变透明的动画和手的进出场动画,不过这两个动画都很简单的,就留给同学们自己去实现啦。
说一下思路:
- 渐变透明: 在画完杯之后,
setShader
之前不断更新paint
的alpha
就行了; - 进出场:利用刚刚的
decomposePath
方法把一条路径事先分解成坐标点数组,然后把这些坐标点应用到搅拌棒的两端点上就行了(手也会跟随搅拌棒的坐标变更而变更的);