原始碼地址
起源
週末在家刷抖音的時候看到了這款網紅時鐘,都是Android平臺的,想來何不自己實現一把。看抖音裡大家發的視訊,這款時鐘基本分兩類,一類是展示在「桌布」上,一類是展示在「鎖屏」上。
展示到「桌布」通過LiveWallPaper
相關API可以做到,這也是本專題要實現的方式。
展示到「鎖屏」目測是使用各ROM廠商的相關API,開發鎖屏主題可以做到。
然而實現兩者的基礎便是拿起Canvas
Paint
等把它繪製出來,所以「上篇」我先用自定View的方式把時鐘畫出來,在Activity中展示效果。「下篇」的時候再把該View結合LiveWallPaper
設定到桌布。
思考分析
這是我當時截圖下來的參考,先分析下涉及到的元素及樣式表現:
- 「圓中資訊」圓中心的數字時間+數字日期+文字星期幾,始終為白色
- 「時圈」一圈文字小時,一點、二點..十二點,當前點數為白色,其它為白色+透明度,如圖中十點就是白色。
- 「分圈」一圈文字分鐘,一分、二分..五十九分,六十分顯示為空,同理,當前分鐘為白色,其它白色+透明度。
- 「秒圈」一圈文字秒,一秒、二秒..五十九秒,六十秒顯示為空,也是同理。
然後分析下動畫效果:
- 每秒鐘「秒圈」走一下,這一下的旋轉角度為
360°/60=6°
,並且走這一下的時候有個線性旋轉過去的動畫效果。 - 每分鐘「分圈」走一下,旋轉角度和動畫效果跟「秒圈」相同。
- 每小時「時圈」走一下,旋轉角度為
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. 讓時鐘轉的優雅點
稍後補更吧~
文末
個人能力有限,如有不正之處歡迎大家批評指出,我會虛心接受並第一時間修改,以不誤導大家。