【自定義View】抖音網紅文字時鐘-上篇

源碼地址

抖音網紅文字時鐘-TextClockViewjava

起源

週末在家刷抖音的時候看到了這款網紅時鐘,都是Android平臺的,想來何不本身實現一把。看抖音裏你們發的視頻,這款時鐘基本分兩類,一類是展現在「壁紙」上,一類是展現在「鎖屏」上。git

展現到「壁紙」經過LiveWallPaper相關API能夠作到,這也是本專題要實現的方式。github

展現到「鎖屏」目測是使用各ROM廠商的相關API,開發鎖屏主題能夠作到。canvas

然而實現二者的基礎即是拿起Canvas Paint等把它繪製出來,因此「上篇」我先用自定View的方式把時鐘畫出來,在Activity中展現效果。「下篇」的時候再把該View結合LiveWallPaper設置到壁紙。app

思考分析

這是我當時截圖下來的參考,先分析下涉及到的元素及樣式表現:ide

  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中看到一屏黑色+一條從屏幕中心到右邊界的紅線。(一眼望去,仍是挺美的)post

/** * 繪製圓中信息 */
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數字轉換爲 1、11、二十等,後文繪製三個文字圈都會用該方法

        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()
    }
}
複製代碼

就用這美麗優雅的時鐘結尾吧~

文末

我的能力有限,若有不正之處歡迎你們批評指出,我會虛心接受並第一時間修改,以不誤導你們

拜讀的文章

個人其它文章

相關文章
相關標籤/搜索