美文网首页具体自定义控件程序员Android开发
用Kotlin实现抖音爆红的文字时钟,征服产品小姐姐就靠它了(上

用Kotlin实现抖音爆红的文字时钟,征服产品小姐姐就靠它了(上

作者: 06fd4cf1f427 | 来源:发表于2019-07-24 20:36 被阅读41次

    源码地址

    抖音网红文字时钟-TextClockView

    起源

    周末在家刷抖音的时候看到了这款网红时钟,都是Android平台的,想来何不自己实现一把。看抖音里大家发的视频,这款时钟基本分两类,一类是展示在「壁纸」上,一类是展示在「锁屏」上。

    展示到「壁纸」通过LiveWallPaper相关API可以做到,这也是本专题要实现的方式。

    展示到「锁屏」目测是使用各ROM厂商的相关API,开发锁屏主题可以做到。

    然而实现两者的基础便是拿起Canvas Paint等把它绘制出来,所以「上篇」我先用自定View的方式把时钟画出来,在Activity中展示效果。「下篇」的时候再把该View结合LiveWallPaper设置到壁纸。

    思考分析

    这是我当时截图下来的参考,先分析下涉及到的元素及样式表现:

    1. 「圆中信息」圆中心的数字时间+数字日期+文字星期几,始终为白色
    2. 「时圈」一圈文字小时,一点、二点..十二点,当前点数为白色,其它为白色+透明度,如图中十点就是白色。
    3. 「分圈」一圈文字分钟,一分、二分..五十九分,六十分显示为空,同理,当前分钟为白色,其它白色+透明度。
    4. 「秒圈」一圈文字秒,一秒、二秒..五十九秒,六十秒显示为空,也是同理。

    然后分析下动画效果:

    1. 每秒钟「秒圈」走一下,这一下的旋转角度为360°/60=6°,并且走这一下的时候有个线性旋转过去的动画效果。
    2. 每分钟「分圈」走一下,旋转角度和动画效果跟「秒圈」相同。
    3. 每小时「时圈」走一下,旋转角度为360°/12=30°,动画效果同上。

    绘制静态图

    1. 画布准备

    基本是将画布背景填充黑色,然后将画布的原点移动到View大小的中心,这样方便思维理解与绘制。

    
    //在onLayout方法中计算View去除padding后的宽高
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        mWidth = (measuredWidth - paddingLeft - paddingRight).toFloat()
        mHeight = (measuredHeight - paddingTop - paddingBottom).toFloat()
    
        //后文会涉及到
        //统一用View宽度*系数来处理大小,这样可以联动适配样式
        mHourR = mWidth * 0.143f
        mMinuteR = mWidth * 0.35f
        mSecondR = mWidth * 0.35f
    }
    
    //在onDraw方法将画布原点平移到中心位置
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (canvas == null) return
        canvas.drawColor(Color.BLACK)//填充背景
        canvas.save()
        canvas.translate(mWidth / 2, mHeight / 2)//原点移动到中心
    
        //绘制各元件,后文会涉及到
        drawCenterInfo(canvas)
        drawHour(canvas, mHourDeg)
        drawMinute(canvas, mMinuteDeg)
        drawSecond(canvas, mSecondDeg)
    
        //从原点处向右画一条辅助线,之后要处理文字与x轴的对齐问题,稍后再说
        canvas.drawLine(0f, 0f, mWidth, 0f, mHelperPaint)
    
        canvas.restore()
    }
    

    2. 画「圆中信息」

    经过第一步,可以在AS的Xml Preview中看到一屏黑色+一条从屏幕中心到右边界的红线。(一眼望去,还是挺美的)

    /**
     * 绘制圆中信息
     */
    private fun drawCenterInfo(canvas: Canvas) {
        Calendar.getInstance().run {
            //绘制数字时间
            val hour = get(Calendar.HOUR_OF_DAY)
            val minute = get(Calendar.MINUTE)
    
            mPaint.textSize = mHourR * 0.4f//字体大小根据「时圈」半径来计算
            mPaint.alpha = 255
            mPaint.textAlign = Paint.Align.CENTER
            canvas.drawText("$hour:$minute", 0f, mPaint.getBottomedY(), mPaint)
    
            //绘制月份、星期
            val month = (this.get(Calendar.MONTH) + 1).let {
                if (it < 10) "0$it" else "$it"
            }
            val day = this.get(Calendar.DAY_OF_MONTH)
            val dayOfWeek = (get(Calendar.DAY_OF_WEEK) - 1).toText()//私有的扩展方法,将Int数字转换为 一、十一、二十等,后文绘制三个文字圈都会用该方法
    
            mPaint.textSize = mHourR * 0.16f//字体大小根据「时圈」半径来计算
            mPaint.alpha = 255
            mPaint.textAlign = Paint.Align.CENTER
            canvas.drawText("$month.$day 星期$dayOfWeek", 0f, mPaint.getTopedY(), mPaint)
        }
    }
    
    /**
     * 扩展获取绘制文字时在x轴上 垂直居中的y坐标
     */
    private fun Paint.getCenteredY(): Float {
        return this.fontSpacing / 2 - this.fontMetrics.bottom
    }
    
    /**
     * 扩展获取绘制文字时在x轴上 贴紧x轴的上边缘的y坐标
     */
    private fun Paint.getBottomedY(): Float {
        return -this.fontMetrics.bottom
    }
    
    /**
     * 扩展获取绘制文字时在x轴上 贴近x轴的下边缘的y坐标
     */
    private fun Paint.getToppedY(): Float {
        return -this.fontMetrics.ascent
    }
    

    其中要说一下mPaint.getBottomedY() mPaint.getToppedY(),这是两个扩展到Paint画笔上的两个kotlin方法。他们的作用是为了处理绘制文字时与x轴的对齐关系。canvas.drawText()方法的第三个参数是y坐标,但这个指的是文字的Baseline的y坐标,所以写了工具方法来得到矫正后的y坐标。(这里就只抛出这个点吧,具体实现原理可先查阅Paint类的相关API就会明白,文末会贴出我拜读的文章链接)

    拿绘制数字时间举例,展示下不同效果:

    mPaint.getBottomedY()替换成0f(y坐标为0,就是文字的Baseline坐标为0),文字使用15:67 abc jqk,可以看到两者区别。(红线就是前文画的那条好美的辅助线)

    canvas.drawText("15:67 测试文字 abc jqk", 0f, 0f, mPaint)
    
    canvas.drawText("15:67 测试文字 abc jqk", 0f, mPaint.getBottomedY(), mPaint)
    

    ok,「圆中信息」绘制后长这个样子:

    3. 画「时圈」「分圈」「秒圈」

    绘制思路就是for循环12次,每次将画布旋转30°乘以i,然后在指定位置绘制文字,12次后刚好一个圆圈。

    该方法接收一个degrees: Float参数,是控制「时圈」整体的旋转的,后文就是不断改变该值,而产生动画效果的。 并且因为三个圈的动画方向都是逆时针,所以这个degrees是个始终会是个负数。

    /**
     * 绘制小时
     */
    private fun drawHour(canvas: Canvas, degrees: Float) {
        mPaint.textSize = mHourR * 0.16f
    
        //处理整体旋转
        canvas.save()
        canvas.rotate(degrees)
    
        for (i in 0 until 12) {
            canvas.save()
    
            //从x轴开始旋转,每30°绘制一下「几点」,12次就画完了「时圈」
            val iDeg = 360 / 12f * i
            canvas.rotate(iDeg)
    
            //这里处理当前时间点的透明度,因为degrees控制整体逆时针旋转
            //iDeg控制绘制时顺时针,所以两者和为0时,刚好在x正半轴上,也就是起始绘制位置。
            mPaint.alpha = if (iDeg + degrees == 0f) 255 else (0.6f * 255).toInt()
            mPaint.textAlign = Paint.Align.LEFT
    
            canvas.drawText("${(i + 1).toText()}点", mHourR, mPaint.getCenteredY(), mPaint)
            canvas.restore()
        }
    
        canvas.restore()
    }
    

    同理绘制「分圈」「秒圈」

    /**
     * 绘制分钟
     */
    private fun drawMinute(canvas: Canvas, degrees: Float) {
        mPaint.textSize = mHourR * 0.16f
    
        //处理整体旋转
        canvas.save()
        canvas.rotate(degrees)
    
        for (i in 0 until 60) {
            canvas.save()
    
            val iDeg = 360 / 60f * i
            canvas.rotate(iDeg)
    
            mPaint.alpha = if (iDeg + degrees == 0f) 255 else (0.6f * 255).toInt()
            mPaint.textAlign = Paint.Align.RIGHT
    
            if (i < 59) {
                canvas.drawText("${(i + 1).toText()}分", mMinuteR, mPaint.getCenteredY(), mPaint)
            }
            canvas.restore()
        }
    
        canvas.restore()
    }
    
    /**
     * 绘制秒
     */
    private fun drawSecond(canvas: Canvas, degrees: Float) {
        mPaint.textSize = mHourR * 0.16f
    
        //处理整体旋转
        canvas.save()
        canvas.rotate(degrees)
    
        for (i in 0 until 60) {
            canvas.save()
    
            val iDeg = 360 / 60f * i
            canvas.rotate(iDeg)
    
            mPaint.alpha = if (iDeg + degrees == 0f) 255 else (0.6f * 255).toInt()
            mPaint.textAlign = Paint.Align.LEFT
    
            if (i < 59) {
                canvas.drawText("${(i + 1).toText()}秒", mSecondR, mPaint.getCenteredY(), mPaint)
            }
            canvas.restore()
        }
    
        canvas.restore()
    }
    

    DuangDuang!!效果出来啦~

    4. 让时钟转起来

    那么如何可以让时钟转起来呢?我们再看一下onDraw()中的代码,绘制三个圈的方法都会接收一个相应的degrees: Float参数,这个是控制一个圈的整体旋转的,而且要逆时针转,所以始终得是负数。

    这样一来就好说了,只要控制这三个角度变化,就能让时钟动起来。

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        ...//省略
    
        //绘制各元件,后文会涉及到
        drawCenterInfo(canvas)
        drawHour(canvas, mHourDeg)
        drawMinute(canvas, mMinuteDeg)
        drawSecond(canvas, mSecondDeg)
    
        ...//省略
    }
    

    那么首先定义三个角度的全局变量,并把他们与实际的时间关联起来,然后每隔一秒触发一次View的重绘即可。

    //定义三个角度的全局变量
    private var mHourDeg: Float by Delegates.notNull()
    private var mMinuteDeg: Float by Delegates.notNull()
    private var mSecondDeg: Float by Delegates.notNull()
    
    /**
     * 绘制方法
     */
    fun doInvalidate() {
        Calendar.getInstance().run {
            val hour = get(Calendar.HOUR)
            val minute = get(Calendar.MINUTE)
            val second = get(Calendar.SECOND)
    
            //这里将三个角度与实际时间关联起来,当前几点几分几秒,就把相应的圈逆时针旋转多少
            mHourDeg = -360 / 12f * (hour - 1)
            mMinuteDeg = -360 / 60f * (minute - 1)
            mSecondDeg = -360 / 60f * (second - 1)
    
            invalidate()
        }
    }
    

    然后只需在Activity中使用timer每秒钟刷新一次View即可。效果如下图,会发现转是转起来的,但是却每秒一跳。再看一下咱们当时的分析:

    每秒钟「秒圈」走一下,这一下的旋转角度为360°/60=6°,并且走这一下的时候有个线性旋转过去的动画效果。

    所以是还差一个线性旋转的效果。

    //Activity中的代码
    private var mTimer: Timer? = null
    private fun caseTextClock() {
        setContentView(R.layout.activity_stage_text_clock)
    
        mTimer = timer(period = 1000) {
            runOnUiThread {
                stage_textClock.doInvalidate()
            }
        }
    
    }
    
    override fun onDestroy() {
        super.onDestroy()
        mTimer?.cancel()
    }
    

    5. 让时钟转的优雅点

    基于我们已经知道了,时钟动起来的本质就是在一段时间内(比如150ms)不断的改变参数degrees: Float的值并触发重绘方法,这样就产生了人眼看到的动画效果。

    所以,我们想让「秒圈」(三个圈的代表)转的更线性更优雅一点,就可以在要开始绘制新的一秒的时候,在前150ms线性的旋转6°

    init {
        //处理动画,声明全局的处理器
        mAnimator = ValueAnimator.ofFloat(6f, 0f)//由6降到1
        mAnimator.duration = 150
        mAnimator.interpolator = LinearInterpolator()//插值器设为线性
        doInvalidate()
    }
    
    /**
     * 开始绘制
     */
    fun doInvalidate() {
        Calendar.getInstance().run {
            val hour = get(Calendar.HOUR)
            val minute = get(Calendar.MINUTE)
            val second = get(Calendar.SECOND)
    
            mHourDeg = -360 / 12f * (hour - 1)
            mMinuteDeg = -360 / 60f * (minute - 1)
            mSecondDeg = -360 / 60f * (second - 1)
    
            //记录当前角度,然后让秒圈线性的旋转6°
            val hd = mHourDeg
            val md = mMinuteDeg
            val sd = mSecondDeg
    
            //处理动画
            mAnimator.removeAllUpdateListeners()//需要移除先前的监听
            mAnimator.addUpdateListener {
                val av = (it.animatedValue as Float)
    
                if (minute == 0 && second == 0) {
                    mHourDeg = hd + av * 5//时圈旋转角度是分秒的5倍,线性的旋转30°
                }
    
                if (second == 0) {
                    mMinuteDeg = md + av//线性的旋转6°
                }
    
                mSecondDeg = sd + av//线性的旋转6°
    
                invalidate()
            }
            mAnimator.start()
        }
    }
    

    就用这美丽优雅的时钟结尾吧~

    文末

    个人能力有限,如有不正之处欢迎大家批评指出,我会虚心接受并第一时间修改,以不误导大家

    拜读的文章

    好啦,文章写到这里就结束了,如果你觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

    希望读到这的您能转发分享和关注一下我,以后还会更新技术干货,谢谢您的支持!

    转发+点赞+关注,第一时间获取最新知识点

    Android架构师之路很漫长,一起共勉吧!


    以下墙裂推荐阅读!!!

    相关文章

      网友评论

        本文标题:用Kotlin实现抖音爆红的文字时钟,征服产品小姐姐就靠它了(上

        本文链接:https://www.haomeiwen.com/subject/mrdfrctx.html