Android自定义Drawable第十五式之啡常OK

前言

上一篇的自定义Drawable中,我们学习了如何在Canvasdraw一个射箭的动画,不过那个动画是以线条为主的,多看几眼可能就会觉得没味道了,那么在本篇文章,将和同学们一起做一个看起来更耐看,更丝滑的动画。
先看看效果图:
在这里插入图片描述在这里插入图片描述
哈哈,小手是不是很可爱? O不OK?。
这个动画看上去挺难,但实际上还没有上一篇的射箭动画复杂。我们等下还会用上一些技巧,来简化画各个元素的步骤。


初步分析

先看看茄子同学画的这张图:
在这里插入图片描述

和上一篇的方式一样:先把各个组成部分拆开。
那这个杯子就可以拆分成:杯身手柄杯底手柄底、还有咖啡

  1. 咖啡的话,我们能很直观的看出来,就是一个咖啡色的实心圆形,再加上边缘的【透明~白色】放射渐变;
  2. 杯身其实也是一个圆形,只是它的直径比咖啡要大一点;
  3. 手柄看上去是一个旋转了45°的圆角矩形;
  4. 杯底和手柄底,其实也就是偏移一下位置,改一下颜色,重新画杯身手柄罢了;

不过我们在画的时候,顺序刚好和上面的顺序相反,因为咖啡的圆形是在最上面,而杯底和手柄底则在最底层。


现在来看看手要怎么画:
在这里插入图片描述

看上去好像挺难,先不管,来拆分一下吧:

  • 两只竖起来像K型的手指,看着是两个圆角矩形;
  • 拇指和食指组成的O型手势,可以用一个圆弧来做;
  • 保持垂直的手臂,其实就是一个矩形;
画手指技巧

如果手指和刚刚的手柄圆角矩形来画的话,就会很麻烦,因为除了要计算[l, t, r, b]之外,还要计算和处理旋转角度。

那应该用哪种方式呢?
熟悉Paint的同学会知道一个叫Cap的东西,它可以改变线条端点的样式,一共有三种,分别是:BUTTROUNDSQUARE。默认情况下是第一个,但因为现在我们要把线条的端点变成圆,也就是要用第二个了。

来测试一下:

	//设置端点样式为圆形
	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
    }
}

画杯

好,先来画静态的杯子。刚刚分析过,杯子大致就是圆形 + 粗线条(手柄) 的组合,那么在画的时候就需要先定义以下变量:

  • 中心点坐标:centerXcenterY(因为杯子是在Drawable的中心处);
  • 杯子半径cupRadius、咖啡半径coffeeRadius、手柄宽度cupHandleWidth
  • 最后一个,杯底的偏移量cupBottomOffset

为了能适应各种尺寸的Drawable容器,这些变量应该基于Drawable的widthheight来动态计算,而不是随便指定某个值。
这样的话,当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)
     }

stickStartPointstickEndPoint分别是搅拌棒起始点结束点的PointF对象实例。
我们暂时把搅拌棒的起始点放到Drawable的中心位置上,长度暂定为一个杯半径的距离。

搅拌棒定位好了之后,接着还要把安上去,这一步很简单,只需要更新一下oCenterXoCenterY(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)会根据搅拌棒的宽度来动态计算,当它变大时,这个角度也会跟着变大。

好,现在来把咖啡杯搅拌棒连接起来,看看要怎么连:
在这里插入图片描述
可以看到,搅拌棒的端点现在是根据那两个黄色的圆来定位的,所以在确定好两个圆的圆心坐标半径之后,就能借助cossin来根据旋转角度动态计算出端点的坐标值了。
还可以看出,左边大圆圆心咖啡杯圆心位置是一样的,也就是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,动是动起来了,但看着好像很僵硬,因为现在手的各个元素的运动轨迹都是一样的。
我们可以在不同的元素上分别制造一些偏移,好让它们看上去更有活力一点,比如:

  1. O形状手指所对应的矩形,在每次水平偏移时,它的right可以只偏移一半,left则正常偏移,这样的话,O形状手指就会随着矩形一起被拉伸,形成一个手指伸缩的效果;
  2. 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)
    }

新增的finger1Offsetfinger2Offset,分别是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方法调用之前,加上刚刚的updateRipplePathdrawRipple方法:

    override fun draw(canvas: Canvas) {
        ......
        
        updateRipplePath()
        drawRipple(canvas)
        
        updateStickLocation()
        ......
    }

看看最终的效果(为了能看清涟漪效果特意加大了尺寸):
在这里插入图片描述
太棒了!


其实还有个边界渐变透明的动画和手的进出场动画,不过这两个动画都很简单的,就留给同学们自己去实现啦。
说一下思路:

  • 渐变透明: 在画完杯之后,setShader之前不断更新paintalpha就行了;
  • 进出场:利用刚刚的decomposePath方法把一条路径事先分解成坐标点数组,然后把这些坐标点应用到搅拌棒的两端点上就行了(手也会跟随搅拌棒的坐标变更而变更的);

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

Github地址:https://github.com/wuyr/CoffeeDrawable 欢迎Star

  • 16
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 23
    评论
评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值