Cursor blinking

我用 Bézier 曲线创造了一个机器人

Android 基础|Android动画|字数 1,515|阅读时长≈ 4 分钟

效果预览

效果预览.gif
效果预览.gif

可以先思考一下,如何使用代码实现上面的效果呢?

实现思路

开始之前,还是要从一个故事讲起,在一个遥远的未来世界,有一个独特的机器人,它的名字叫做小白,小白不像传统的机器人那样呆板,而是由流畅的曲线和精巧的结构构成。它的头部,特别引人注目。开始的时候,它的头部只是一个简单的贝塞尔曲线构成的轮廓,仿佛是一幅未完成的艺术品。

然而,随着时间的推移,小白的头部逐渐丰富起来。它学会了表情和情感,曲线逐渐变得更加细腻,仿佛一个真正的人类头部。小白的眼睛是最吸引人的地方,它们不仅能够传达情感,还能够扫描和分析周围的环境…

哈哈,言归正传,要实现上面的效果,其实是以下的技术实现思路:

  1. 四段三次贝塞尔曲线

使用四段三次 Bézier 曲线拟合圆 这篇博客里面,介绍了通过四段三次贝塞尔曲线去绘制一个圆,不止是圆,定义四段三次贝塞尔曲线能形成很多的形状。

  1. 形状组合和表情设计

定义一个表示形状的数据结构,其中包含四段贝塞尔曲线的描述,将多个形状组合成一个表情。通过组合不同的形状来设计表情,每个形状代表表情的一部分,例如眼睛、嘴巴、脸部等,然后在画布上绘制这些部分。

  1. 动作设计

定义一个机器人的数据结构,头部加入表情,机器人的其它部分和表情共同组成一个动作。

  1. 动作串联

将多个动作串联实现动画效果。可以通过在时间上连续绘制不同的动作,设计一个动画系统,根据时间轴逐步绘制不同的动作,从而实现机器人的动画效果。

在 Android 开发中,可以利用 Canvas 绘图功能、动画框架等来实现这种复杂的图形效果。从一段贝塞尔曲线开始,到最终的动画效果,这将是一个奇妙的旅程。

四段三次贝塞尔曲线组成基础形状

首先,通过四段三次贝塞尔曲线组成一系列形状做为基础,如下图所示:

截屏2024-03-20 23.44.34.png
截屏2024-03-20 23.44.34.png
形状.gif
形状.gif

因为需要形状的大小可以自由变化,定义了以下函数来获取一段三次贝塞尔曲线

Code
/**     * 获取一段三次贝塞尔曲线,centerX/centerY 确定中心点的位置,四个控制点都基于 radius 计算的出,这样改变 radius,即可改变形状的大小     */    fun getCurve(        centerX: Float, centerY: Float, radius: Float,        anchorRatioTR1: Float, anchorAngle1: Float,        anchorRatioTR2: Float, anchorAngle2: Float,        contRatioTR1: Float, contAngle1: Float,        contRatioTR2: Float, contAngle2: Float    ): Curve {        val curve = Curve()        curve.anchorPoint1.x = centerX + Math.cos(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1        curve.anchorPoint1.y = centerY + Math.sin(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1        curve.contPoint1.x = curve.anchorPoint1.x + Math.cos(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1        curve.contPoint1.y = curve.anchorPoint1.y + Math.sin(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1        curve.anchorPoint2.x = centerX + Math.cos(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2        curve.anchorPoint2.y = centerY + Math.sin(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2        curve.contPoint2.x = curve.anchorPoint2.x + Math.cos(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2        curve.contPoint2.y = curve.anchorPoint2.y + Math.sin(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2        return curve    }

定义一个数据结构描述四段贝塞尔曲线

Code
/** * 四段贝塞尔曲线组成一个形状 */open class CurveShape {     // 四段贝塞尔曲线之间的链接状态 (现在只考虑首尾相连的情况,不考虑交叉想连)    var isLink = true    var centerX = 0f    var centerY = 0f    //半径 做为一个基础值,所有的计算都基于这个值    var radius = 0f    //存储四段贝塞尔曲线    val curveList = ArrayList<Curve>()}

定义一个 CurveShapeView 来负责绘制形状

Code
class CurveShapeView @kotlin.jvm.JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {     // 默认颜色 透明    private var DEFAULT_COLOR = Color.TRANSPARENT     // View 宽高    private var viewWidth = 0f    private var viewHeight = 0f    private var centerX = 0f     // 画笔    private val paint: Paint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }        //画笔     // 用于颜色渐变处理    private val argbEvaluator = ArgbEvaluator()//渐变色计算类    private var startColor: Int = DEFAULT_COLOR    private var endColor: Int = DEFAULT_COLOR    private var startStrokeWidth = 0f    private var endStrokeWidth = 0f    private var paintCap = Paint.Cap.ROUND     private var curProgress: Float = 0f    private lateinit var startVisualInfo: VisualInfo    private lateinit var endVisualInfo: VisualInfo     // 起始时的 curveShape    private var startCurveShape: CurveShape? = null    // 缓存绘制过程中生成的 curveShape,以便发送切换时作为起始    private var cacheCurCurveShape: CurveShape? = null     init {        debug("init()")        initPaint()        initStartAndEndVisualInfo()    }     private fun initPaint() {        paint.isAntiAlias = true        paint.style = Paint.Style.FILL_AND_STROKE        paint.strokeCap = paintCap        paint.color = DEFAULT_COLOR    }     private fun initStartAndEndVisualInfo() {        startVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo())        endVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo())    }     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {        super.onSizeChanged(w, h, oldw, oldh)        debug("onSizeChanged()")        viewWidth = w.toFloat()        viewHeight = h.toFloat()        centerX = viewWidth / 2        startStrokeWidth = centerX * 0f        endStrokeWidth = centerX * 0f        paint.strokeWidth = startStrokeWidth    }     override fun onDraw(canvas: Canvas) {        super.onDraw(canvas)        drawCurveShape(canvas)    }     private fun drawCurveShape(canvas: Canvas) {        // 与 changeVisualInfo 呼应,这个时候 centerX >0        if (startCurveShape == null) {            this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX)            updatePaint(endVisualInfo.drawInfo)            if (curProgress > 0) {                updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)            }        }        if (startColor != endColor) {            paint.color = argbEvaluator.evaluate(curProgress, startColor, endColor) as Int        }        if (startStrokeWidth != endStrokeWidth) {            paint.strokeWidth = startStrokeWidth + (endStrokeWidth - startStrokeWidth) * curProgress        }        startCurveShape?.let {            cacheCurCurveShape = it.getCurrentCurveShape(endVisualInfo.drawInfo.getCurveShape(centerX), curProgress)            val paths = cacheCurCurveShape!!.getCurveShapePaths()            for (path in paths) {                canvas.drawPath(path, paint)            }        }    }     /**     * 切换     */    fun changeVisualInfo(visualInfo: VisualInfo, isInvalidate: Boolean = true, progress: Float = 0f) {        this.curProgress = progress        this.startVisualInfo = getCurrentVisualInfo()        this.endVisualInfo = visualInfo        debug("changeVisualInfo()")        // 如果 changeVisualInfo 时,还没有进行测量,直接返回,延迟到 onDraw 里面执行切换后的逻辑        if (centerX <= 0) {            return        }        if (cacheCurCurveShape != null) {            this.startCurveShape = cacheCurCurveShape        } else {            this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX)        }        if (curProgress > 0) {            updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)        }        updatePaint(endVisualInfo.drawInfo)        if (isInvalidate) {            invalidate()        }    }     fun setProgress(progress: Float) {        curProgress = progress        updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)        invalidate()    }     fun updateStrokeWidthRatio(endStrokeWidthRatio: Float, startStrokeWidthRatio: Float = 0f) {        this.endStrokeWidth = centerX * endStrokeWidthRatio        this.startStrokeWidth = centerX * startStrokeWidthRatio        invalidate()    }     /**     * 是否需要更新画笔     */    private fun isNadeUpdatePaint(orderInfo: DrawInfo): Boolean {        var isNadeUpdatePaint = false        startStrokeWidth = paint.getStrokeWidth()        endStrokeWidth = centerX * orderInfo.paintStrokeWidthRatio        if (startStrokeWidth != endStrokeWidth) {            isNadeUpdatePaint = true        }        startColor = paint.getColor()        endColor = orderInfo.targetColor        if (startColor != endColor) {            isNadeUpdatePaint = true        }        if (paint.style != orderInfo.paintSytle) {            paint.style = orderInfo.paintSytle            isNadeUpdatePaint = true        }        if (paint.strokeCap != orderInfo.paintCap) {            isNadeUpdatePaint = true            paint.strokeCap = orderInfo.paintCap        }        return isNadeUpdatePaint    }     /**     * 根据切换到的状态更新画笔     */    private fun updatePaint(targetDrawInfo: DrawInfo) {        startStrokeWidth = paint.getStrokeWidth()        endStrokeWidth = centerX * targetDrawInfo.paintStrokeWidthRatio        startColor = paint.getColor()        endColor = targetDrawInfo.targetColor        paint.style = targetDrawInfo.paintSytle        paint.strokeCap = targetDrawInfo.paintCap    }     /**     * 获取当前的 visualInfo     */    fun getCurrentVisualInfo(): VisualInfo {        var viewPropertyInfo = getCurrentViewPropertyInfo()        var drawInfo: DrawInfo = getCurrentDrawInfo()        val visualInfo = VisualInfo(drawInfo, viewPropertyInfo);        return visualInfo    }     private fun getCurrentViewPropertyInfo(): ViewPropertyInfo {        if (centerX <= 0f) {            return return endVisualInfo.viewPropertyInfo        }        return this.getCurrentViewPropertyInfo(this.centerX)    }     private fun getCurrentDrawInfo(): DrawInfo {        var drawInfo = DrawInfo()        drawInfo.shapeType = endVisualInfo.drawInfo.shapeType        drawInfo.isLink = endVisualInfo.drawInfo.isLink        drawInfo.radiusRatio = endVisualInfo.drawInfo.radiusRatio        drawInfo.targetColor = paint.color        drawInfo.centerXRatio = endVisualInfo.drawInfo.centerXRatio        drawInfo.centerYRatio = endVisualInfo.drawInfo.centerYRatio        drawInfo.paintStrokeWidthRatio = endVisualInfo.drawInfo.paintStrokeWidthRatio        drawInfo.paintSytle = endVisualInfo.drawInfo.paintSytle        drawInfo.paintCap = endVisualInfo.drawInfo.paintCap        return drawInfo    }     // 动画执行    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)    fun execAnim(visualInfos: ArrayList<VisualInfo>, callback: AnimExecCallback? = null) {        if (visualInfos.isNullOrEmpty()) {            return        }        changeVisualInfo(visualInfos[0])        cancelAnim()        actionValueAnimator.setDuration(visualInfos[0].duration)        val listener = object : MultiAnimatorListener {            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {                if (!visualInfos[0].isDelay) {                    setProgress(valueAnimator.animatedValue as Float)                }            }            override fun onAnimationEnd(animation: Animator) {                if (!visualInfos[0].isDelay) {                    setProgress(1f)                }                visualInfos.removeAt(0)                if (visualInfos.isEmpty()) {                    callback?.onAnimationAllFinish()                } else {                    execAnim(visualInfos, callback)                }            }        }        actionValueAnimator.addUpdateListener(listener)        actionValueAnimator.addListener(listener)        actionValueAnimator.start()    }     fun cancelAnim() {        actionValueAnimator.removeAllUpdateListeners()        actionValueAnimator.removeAllListeners()        actionValueAnimator.cancel()    }}

下图演示从一个形状切换到另一个形状,并基于进度控制切换状态。

Screen_recording_20240320_235834.gif
Screen_recording_20240320_235834.gif

因为要做状态保存,定义一个 VisualInfo 类,其中 DrawInfo 用来控制绘制(作用于 View#onDraw()),ViewPropertyInfo 用于控制 View 本身属性(供 Android 属性动画调度)

Code
/** * 协调 CurveShapeView 的位置和绘制等 */open class VisualInfo {    var duration: Long = Constants.DEFAULT_DURATION    var interpolatorType: Int = 8    var isDelay: Boolean = false    var drawInfo = DrawInfo()    var viewPropertyInfo = ViewPropertyInfo()    constructor()    constructor(drawInfo: DrawInfo, viewPropertyInfo: ViewPropertyInfo) {        this.drawInfo = drawInfo        this.viewPropertyInfo = viewPropertyInfo    }}

形状组合

有了形状,就可动手把他们组合成表情了

Screen_recording_20240321_000534.gif
Screen_recording_20240321_000534.gif

这里我定义了一个 EmoteInfo 类,其中包含面部、左右眼、嘴巴等形状

Code
/** * 表情信息 */open class EmoteInfo {    var duration: Long = Constants.DEFAULT_DURATION    var interpolatorType: Int = 8    var isDelay: Boolean = false    var faceVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_FACE, ViewPropertyInfoConst.DEFAULT_RENDERINFO_FACE)    var leftEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT)    var leftCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT)    var rightEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT)    var rightCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT)    var mouseVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_DOWN_ORBIT)}

同样定义一个 FaceView 用来绘制表情

Code
class FaceView @kotlin.jvm.JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) {     private var _binding: LayoutFaceViewBinding? = null    val binding get() = _binding!!     private var curProgress: Float = 0f     init {        _binding = LayoutFaceViewBinding.inflate(LayoutInflater.from(context), this)        initViews()    }     private fun initViews() {     }     fun changeEmote(emoteInfo: EmoteInfo, isInvalidate: Boolean = true, progress: Float = 0f) {        this.curProgress = progress        binding.cvFaceOrbit.changeVisualInfo(emoteInfo.faceVisualInfo, isInvalidate, progress)        binding.cvLeftCheekOrbit.changeVisualInfo(emoteInfo.leftCheekVisualInfo, isInvalidate, progress)        binding.cvLeftEyeOrbit.changeVisualInfo(emoteInfo.leftEyeVisualInfo, isInvalidate, progress)        binding.cvRightCheekOrbit.changeVisualInfo(emoteInfo.rightCheekVisualInfo, isInvalidate, progress)        binding.cvRightEyeOrbit.changeVisualInfo(emoteInfo.rightEyeVisualInfo, isInvalidate, progress)        binding.cvMouseOrbit.changeVisualInfo(emoteInfo.mouseVisualInfo, isInvalidate, progress)    }     fun setProgress(progress: Float) {        this.curProgress = progress        binding.cvFaceOrbit.setProgress(progress)        binding.cvLeftCheekOrbit.setProgress(progress)        binding.cvLeftEyeOrbit.setProgress(progress)        binding.cvRightCheekOrbit.setProgress(progress)        binding.cvRightEyeOrbit.setProgress(progress)        binding.cvMouseOrbit.setProgress(progress)    }     // 动画执行    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)    fun execAnim(emoteInfos: ArrayList<EmoteInfo>, callback: AnimExecCallback? = null) {        if (emoteInfos.isNullOrEmpty()) {            return        }        changeEmote(emoteInfos[0])        cancelAnim()        actionValueAnimator.setDuration(emoteInfos[0].duration)        val listener = object : MultiAnimatorListener {            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {                if (!emoteInfos[0].isDelay) {                    setProgress(valueAnimator.animatedValue as Float)                }            }             override fun onAnimationEnd(animation: Animator) {                if (!emoteInfos[0].isDelay) {                    setProgress(1f)                }                emoteInfos.removeAt(0)                if (emoteInfos.isEmpty()) {                    callback?.onAnimationAllFinish()                } else {                    execAnim(emoteInfos, callback)                }            }        }        actionValueAnimator.addUpdateListener(listener)        actionValueAnimator.addListener(listener)        actionValueAnimator.start()    }     fun cancelAnim() {        actionValueAnimator.removeAllUpdateListeners()        actionValueAnimator.removeAllListeners()        actionValueAnimator.cancel()    }     override fun onDetachedFromWindow() {        super.onDetachedFromWindow()        cancelAnim()        _binding = null    }} 

下图演示从一个表情切换到另一个表情,并基于进度控制切换表情,由于基于属性,可以在任何时间切换时获取当前属性,从而是切换时画面自然过渡。

Screen_recording_20240321_005101.gif
Screen_recording_20240321_005101.gif

其实到这一步,就可以通过变换表情来执行动画了,但总感觉少了点什么,所以下面底部加了个小影子

动作设计

定义一个 PoseInfo 类,代表一个动作,主要包含表情、底部的影子、头部的属性控制等

Code
/** * pose (摆pose,或者理解为动画的关键帧) * TODO: 这里实现形式有问题,影子应该是现实意义上的“影子”,影子跟随头部移动(头部移动,影子基于头部属性跟随移动)这样就不用定义影子相关的动画执行了,设计动作时只考虑表情就好了,可以极大简化设计动作时的复制度。 * 并且跟随动画的形式,也避免了多重动画(上下摆动和 PoseInfo 里面定义的动画)冲突的问题。过了一天才想到这个点,以后再尝试吧。 */open class PoseInfo {    var duration: Long = Constants.DEFAULT_DURATION    var interpolatorType: Int = 8    var isDelay: Boolean = false    var emoteInfo: EmoteInfo = EmoteInfo()    var trayVisualInfo: VisualInfo = VisualInfo() // 跟随动画的实现方式,这块只保留大小和颜色就好了    var robotViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()    var headViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()     var trayContainerViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()// 跟随动画的实现方式,这块就不需要了}

同样定义一个 RobotView 负责展示和执行动作

Code
class RobotView @kotlin.jvm.JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) {     private var _binding: LayoutRobotViewBinding? = null    private val binding get() = _binding!!     private var centerX = 0f     private var curProgress: Float = 0f    private lateinit var startPoseInfo: PoseInfo    private lateinit var endPoseInfo: PoseInfo     private val selfViewAnimDelegate: ViewAnimDelegate by lazy {        ViewAnimDelegate(this, centerX)    }     private val headViewAnimDelegate: ViewAnimDelegate by lazy {        ViewAnimDelegate(binding.flHeadContainer, centerX)    }     private val trayViewAnimDelegate: ViewAnimDelegate by lazy {        ViewAnimDelegate(binding.flTrayContainer, centerX)    }      init {        _binding = LayoutRobotViewBinding.inflate(LayoutInflater.from(context), this)        initViews()    }     private fun initViews() {        startPoseInfo = PoseInfo(EmoteInfo(), VisualInfo())        endPoseInfo = PoseInfo(EmoteInfo(), VisualInfo())    }     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {        super.onSizeChanged(w, h, oldw, oldh)        centerX = w.toFloat() / 2    }     fun changePose(poseInfo: PoseInfo, isInvalidate: Boolean = true, progress: Float = 0f) {        this.curProgress = progress        binding.fvFace.changeEmote(poseInfo.emoteInfo, isInvalidate, progress)        binding.cvTrayShape.changeVisualInfo(poseInfo.trayVisualInfo, isInvalidate, progress)        endPoseInfo = poseInfo        if (centerX == 0f) {            return        }        startPoseInfo.robotViewPropertyInfo = this.getCurrentViewPropertyInfo(centerX)        startPoseInfo.headViewPropertyInfo = binding.flHeadContainer.getCurrentViewPropertyInfo(centerX)        startPoseInfo.trayContainerViewPropertyInfo = binding.flTrayContainer.getCurrentViewPropertyInfo(centerX)        this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, poseInfo.robotViewPropertyInfo, progress)        binding.flHeadContainer.updateViewPropertyByProgress(            centerX,            startPoseInfo.headViewPropertyInfo,            poseInfo.headViewPropertyInfo,            progress        )        binding.flTrayContainer.updateViewPropertyByProgress(            centerX,            startPoseInfo.trayContainerViewPropertyInfo,            poseInfo.trayContainerViewPropertyInfo,            progress        )    }     fun setProgress(progress: Float) {        this.curProgress = progress        binding.fvFace.setProgress(progress)        binding.cvTrayShape.setProgress(progress)        this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, endPoseInfo.robotViewPropertyInfo, progress)        binding.flHeadContainer.updateViewPropertyByProgress(            centerX,            startPoseInfo.headViewPropertyInfo,            endPoseInfo.headViewPropertyInfo,            progress        )        binding.flTrayContainer.updateViewPropertyByProgress(            centerX,            startPoseInfo.trayContainerViewPropertyInfo,            endPoseInfo.trayContainerViewPropertyInfo,            progress        )    }    override fun onDetachedFromWindow() {        super.onDetachedFromWindow()        cancelActionAnim()        _binding = null    }}

动作串联

在 RobotView 定义了动画执行控制器,其实就是一个属性动画

Code
 /**	    * 整体一起执行动画,依次执行设计好的动作	    */    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)    fun execAction(posts: ArrayList<PoseInfo>, callback: AnimExecCallback? = null) {        if (posts.isNullOrEmpty()) {            return        }        changePose(posts[0])        cancelActionAnim()        actionValueAnimator.setDuration(posts[0].duration)        val listener = object : MultiAnimatorListener {            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {                if (!posts[0].isDelay) {                    setProgress(valueAnimator.animatedValue as Float)                }            }             override fun onAnimationEnd(animation: Animator) {                if (!posts[0].isDelay) {                    setProgress(1f)                }                posts.removeAt(0)                if (posts.isEmpty()) {                    callback?.onAnimationAllFinish()                } else {                    execAction(posts, callback)                }            }        }        actionValueAnimator.addUpdateListener(listener)        actionValueAnimator.addListener(listener)        actionValueAnimator.start()    }     private fun cancelActionAnim() {        actionValueAnimator.removeAllUpdateListeners()        actionValueAnimator.removeAllListeners()        actionValueAnimator.cancel()    }     fun execEmotesAnim(        emotes: ArrayList<EmoteInfo>,        emotesCallback: AnimExecCallback? = null    ) {        binding.fvFace.execAnim(emotes, emotesCallback)    }

考虑到机器人外部执行动画的同时,做表情变化(比如演示中上下摆动的同时做表情变换),在 FaceView 里面同样定义了动画执行控制器。

至此,按照开始时的思路已一一实现。

做这个小机器人有什么用呢

除了拿来写博客,它还有别的用处吗?

  1. 可以设计一系列表情,做矢量图形使用。
  1. 人脸验证的时候,提示张嘴、闭眼,可以用表情动画做对应的提示。
  1. 多设计几组动作,结合 ChatGpt Api 做一个聊天机器人应该也挺有意思的吧。

相关资料

!icon 后记:本来以为做这个东西只需要一两天,谁知道最终实现花费了一个完整周日和周一到周三的下班时间,看起来简单,真正做的过程中还是充满了各种挑战,坚持到写完博客真的是心力憔悴。

这份代码虽然还有很多不足的地方(对象频繁创建,整个代码结构可以使用设计模式做规整,捏和图形像是徒手磨螺丝,机器人整体动画实现方式不合理),再休息一段时间之后再来优化吧。或许做减法,只到表情那块刚刚好。 另外,通过这个作品感受到了 kotlin 的简洁性,如果用 java,今天应该还完不成。