链接:https://juejin.im/post/5cb53e93e51d456e55623b07
周末在家刷抖音的时候看到了这款网红时钟,都是 Android 平台的,想来何不自己实现一把。看抖音里大家发的视频,这款时钟基本分两类,一类是展示在「壁纸」上,一类是展示在「锁屏」上。
然而实现两者的基础便是拿起
Canvas
Paint
等把它绘制出来,所以「上篇」我先用自定义 View 的方式把时钟画出来,在 Activity 中展示效果。「下篇」的时候再把该 View 结合
LiveWallPaper
设置到壁纸。抖音爆红文字时钟项目源码(
https://github.com/drawf/SourceSet/blob/master/app/src/main/java/me/erwa/sourceset/view/TextClockView.kt
)
这是我当时截图下来的参考,先分析下涉及到的元素及样式表现:
「圆中信息」圆中心的数字时间 + 数字日期 + 文字星期几,始终为白色
「时圈」一圈文字小时,一点、二点.. 十二点,
当前点数为白色,其它为白色 + 透明度
,如图中十点就是白色。
「分圈」一圈文字分钟,一分、二分.. 五十九分,
六十分显示为空
,同理,当前分钟为白色,其它白色 + 透明度。
「秒圈」一圈文字秒,一秒、二秒.. 五十九秒,
六十秒显示为空
,也是同理。
然后分析下动画效果:
每秒钟「秒圈」走一下,这一下的旋转角度为
360°/60=6°
,并且走这一下的时候有个
线性旋转
过去的动画效果。
每分钟「分圈」走一下,旋转角度和动画效果跟「秒圈」相同。
每小时「时圈」走一下,旋转角度为
360°/12=30°
,动画效果同上。
1、画布准备
基本是将画布背景填充黑色,然后将画布的原点移动到 View 大小的中心,这样方便思维理解与绘制。
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() mHourR = mWidth * 0.143f mMinuteR = mWidth * 0.35f mSecondR = mWidth * 0.35f } 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) 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.4 f mPaint.alpha = 255 mPaint.textAlign = Paint.Align.CENTER canvas.drawText("$hour:$minute" , 0 f, 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() mPaint.textSize = mHourR * 0.16 f mPaint.alpha = 255 mPaint.textAlign = Paint.Align.CENTER canvas.drawText("$month.$day 星期$dayOfWeek" , 0 f, mPaint.getTopedY(), mPaint) } }private fun Paint.getCenteredY(): Float { return this .fontSpacing / 2 - this .fontMetrics.bottom }private fun Paint.getBottomedY(): Float { return -this .fontMetrics.bottom }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() val iDeg = 360 / 12f * i canvas.rotate(iDeg) 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