用Android復刻Apple產品UI(1)—絲滑的噪聲監測音量條

語言: CN / TW / HK

theme: devui-blue highlight: androidstudio


前言

一直想花時間復刻一下Apple的原生UI和動畫,超級絲滑。

今天,目標是AppleWatch的噪音檢測音量條。

1. 頁面內容分析

在上手開始前,我們不妨先仔細觀察一下這個頁面所涵蓋的資訊,再將其轉換為我們的業務需求,提前整理好思路再開始上手寫。

1.1 靜態佈局

_cgi-bin_mmwebwx-bin_webwxgetmsgimg__&MsgID=5163679426253949749&skey=@crypt_3884a4c6_840c4f9dcccf1f08c8710facc1900eb7&mmweb_appid=wx_webfilehelper.jfif

我們首先來看看上方圖片裡都涵蓋了什麼細節: + 噪聲動畫條由18個圓角矩形(記為unitRect)組合拼接完成 + 整個動畫條覆蓋30dB~120dB + 每個圓角矩形的單位dB為5 + dB為80的圓角矩形的height值更大一些 + 噪聲動畫條左側為綠色/黃色,代表分貝的值,其餘為預設色

有了以上細節,我們來初步設想一下該怎麼實現: + 靜態動畫條:這個好說,用canvas.drawRoundRect()來畫出帶有corner圓角的矩形就好啦;畫18個,其中第10個我們調高他的height,就可以實現初步的效果了。 + 動畫條顏色:我們可以將當前的分貝設定為這個元件的輸入值(記為currentDb),通過currentDb來計算,有多少個單元格(unitRect)需要被標記為有色,其他的被設定為預設的無色;

1.2 動態效果

applewatch.gif

同樣,我們再來看一下上方GIF裡的動畫效果 + 顏色變化:分貝低於80,為綠色;分貝高於80,轉為黃色 + 動畫條變化:分貝值改變後,動畫條需要呈現出柔滑的過渡效果

首先,兩個動態變化的效果可以被轉化為以下兩個需求: + 顏色變化:對我們的元件的輸入值currentDb進行條件判斷,如果>80,我們就將有色單元格(unitRect)的顏色設定為黃色,否則為綠色。 + 動畫條變化:為了能夠實現柔滑的動畫條變化,我們不難想到,在建立自定義View後,用objectAnimator,針對currentDb來執行一個動畫,讓舊的分貝值逼近到新的分貝值,從而實現一個過渡效果。 音量條動態過渡效果舉例: 1. 假設我們的音量條當前的分貝值是40,有2個單元格為綠色,其餘是灰色; 2. 現在,我們音量條獲得了一個新的分貝值:70,這需要有8個單元格變成綠色; 3. 我們假設這個動畫需要在120ms內以線性的過渡效果完成完成:新增的6個綠色單元格就會在這120ms內被逐步填充,也就是20ms一個單元格,以此實現了動畫的過渡。

但,如果我們希望能夠實現更順滑的效果
1. 那就再新增一個型別的單元格:過渡單元格 2. 過渡單元格用透明度高一些的顏色來進行繪製 3. 隨後,給我們的元件引入一個新的變數:lastDb,用於表示上一刻的分貝值 4. currentDb與lastDb的差值的絕對值,用於表示當前正在變化過程中的數值,用於計算過渡單元格的數量 5. 最後,我們再使用objectAnimator來讓lastDb逐步逼近currentDb以實現更絲滑的過渡效果✌

2. 自定義View登場

強大的自定義view來了,這個音量條只需要一些最基礎的功能即可完成繪製,下面就只放最核心的程式碼。

2.1 繪製

  1. 我們需要以下5種顏色:
  2. 預設色:灰色
  3. 低分貝的綠色以及過渡用的透明綠色
  4. 高分貝的黃色以及過渡用的透明黃色 kotlin var colorGreen = Color.parseColor("#FF0FDD72") var colorParentGreen = Color.parseColor("#660FDD72") var colorYellow = Color.parseColor("#FFFFE620") var colorParentYellow = Color.parseColor("#66FFE620") var colorDefault = Color.parseColor("#FF4C4C4C")

  5. 單元格的尺寸以及總尺寸 這裡的比例是我把AppleWatch的截圖放進figma測量了一下,也可以自己定奪。 ```kotlin //Width of total View totalWidth = (width - paddingLeft - paddingRight).toFloat() //Height of unitRect secondHeight = totalWidth / 1050 * 96 //Height of total view totalHeight = totalWidth / 1050 * 129 //Height of highUnitRect highUnitHeight = totalWidth / 1050 * 120 //Width of unitRect unitWidth = totalWidth / 1050 * 50 //space between unitRects space = totalWidth / 1050 * 8 //corner of the rectangle corner = 4F

val leftBound = center.first - totalWidth / 2

val unitUpperBound = center.second - secondHeight / 2 val unitLowerBound = center.second + secondHeight / 2

val highUnitUpperBound = center.second - highUnitHeight / 2 val highUnitLowerBound = center.second + highUnitHeight / 2 ```

  1. 不同的分貝對應不同的顏色組合 80分貝以上,我們把Paint轉換為黃色,這裡我使用一個Pair打包起來: kotlin colorPair = if (currentDb < 80) { Pair(colorGreen, colorParentGreen) } else { Pair(colorYellow, colorParentYellow) }

  2. 計算不同型別單元格的數量: 我們的三種類型單元格:

  3. 有色單元格:表示上一刻的分貝
  4. 透明色單元格:表示過渡模組
  5. 灰色單元格:預設色 kotlin //有色單元格 val numOfColor:Int = ((min(lastDb, currentDb) - 30) / 5) //過渡單元格 val numOfChangingColor:Int = abs(currentDb - lastDb) / 5

  6. 迴圈開畫,畫18個:

  7. 首先,判斷下當前的index,如果是處於有色塊區間,paint則調整為color Pair的第一個值,如果是過渡塊區間,就設定成透明的顏色;剩下的都是預設色。
  8. 其次,如果我們到了第10個,也就是表示80分貝的 highUnitRect,就讓這個rect擁有更高的height。 ```kotlin for (index in 0..17) { //change paint color according to current index if (index < numOfColor) { soundPaint.color = colorPair.first } else if (index < numOfColor + numOfChangingColor) { soundPaint.color = colorPair.second } else { soundPaint.color = colorDefault }

    //if index ==10, draw highUnit var thisLeftBound = leftBound + space * (index + 1) + index * unitWidth if (index == 10) { canvas?.drawRoundRect( thisLeftBound, highUnitUpperBound, thisLeftBound + unitWidth, highUnitLowerBound, corner, corner, soundPaint ) } else { canvas?.drawRoundRect( thisLeftBound, unitUpperBound, thisLeftBound + unitWidth, unitLowerBound, corner, corner, soundPaint ) } } ``` 到這裡,我們的onDraw方法就完成了,我們擁有了如下的預設效果。
    分貝>=80: image.png 分貝<80:

image.png

2.2 柔滑的動畫過渡效果

激動人心的時刻來了,讓它動起來:

  • 首先,別忘了新增style xml檔案讓它可以獲取外部引數:這裡我們暫時只設置lastDb和currentDb,其他的顏色可以自己定義。
    在構造器裡初始化一下我們的兩個音量引數 ```kotlin constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { val styleArray = context.obtainStyledAttributes(attrs, R.styleable.NoiseSplineView)

    currentDb = styleArray.getInt(R.styleable.NoiseSplineView_currentDb, 60) lastDb = styleArray.getInt(R.styleable.NoiseSplineView_lastDb, 40)

    styleArray.recycle() ```

  • 接著,我們在layout的xml檔案中建立這個自定義的元件,並給它新增ObjectAnimator動畫:像之前所說的,我們通過讓lastDb來不斷逼近以實現柔滑的過渡效果!

```kotlin

//全域性變數儲存上一刻與當前時刻的dB lateinit var currentDb:Int lateinit var lastDb:Int

//這個函式接受一個引數,設定音量條的currentDb與lastDb,並執行動畫。 fun setSoundDataAndAnimate(noiseDb: Int) { //接收音量引數,更新我們的currentDb currentDb = noiseDb

noiseSpline?.currentDb = currentDb
noiseSpline?.lastDb = lastDb

//建立ofInt動畫,讓lastDb不斷逼近currentDb
var noiseAnimator = ObjectAnimator.ofInt(noiseSpline, "lastDb", lastDb, currentDb)
noiseAnimator?.interpolator = AccelerateDecelerateInterpolator()
//用先加速後減速的插值器,當然也可以替換成別的!
noiseAnimator?.start()

//儲存當前時刻的音量,儲存進lastDb
lastDb = currentDb

} ```

3. 效果預覽

最後,我們終於獲得了這個音量條元件,現在我們建立一個子執行緒來以700ms一次的頻率來看一下動畫效果。

androidResult.gif

最後最後,再來看一下慢放下的動畫效果,lastDb是否按我們的預期不斷逼近currentDb了? androidSlow.gif
嗯,和預想的一樣,收工。

4. 附-程式碼

File 1: NoiseSplineView.kt

```kotlin import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.util.AttributeSet import android.view.View import isense.com.R import kotlin.math.abs import kotlin.math.min import kotlin.properties.Delegates

class NoiseSplineView : View { var totalWidth = 1050F var secondHeight = 96F var totalHeight = 129F var highUnitHeight = 120F var unitWidth = 50F var space = 8F var corner = 12F var currentDb = 30 set(value) { invalidate() field = value } var lastDb = 40 set(value) { invalidate() field = value }

var backGroundPaint = Paint()
var soundPaint = Paint()

val totalAmount = 18
var center by Delegates.notNull<Float>()


lateinit var colorPair: Pair<Int, Int>

var colorGreen = Color.parseColor("#FF0FDD72")
var colorParentGreen = Color.parseColor("#660FDD72")
var colorYellow = Color.parseColor("#FFFFE620")
var colorParentYellow = Color.parseColor("#66FFE620")
var colorDefault = Color.parseColor("#FF4C4C4C")

constructor(context: Context) : super(context, null, 0) {
}

constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
    val styleArray = context.obtainStyledAttributes(attrs, R.styleable.NoiseSplineView)

    currentDb = styleArray.getInt(R.styleable.NoiseSplineView_currentDb, 60)
    lastDb = styleArray.getInt(R.styleable.NoiseSplineView_lastDb, 40)

    styleArray.recycle()
    initNoiseSpline()
}


constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
    initNoiseSpline()
}

constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(
    context,
    attrs,
    defStyleAttr,
    defStyleRes
) {
    initNoiseSpline()
}

@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    //Width of total View
    totalWidth = (width - paddingLeft - paddingRight).toFloat()
    //Height of unitRect
    secondHeight = totalWidth / 1050 * 96
    //Height of total view
    totalHeight = totalWidth / 1050 * 129
    //Height of highUnitRect
    highUnitHeight = totalWidth / 1050 * 120
    //Width of unitRect
    unitWidth = totalWidth / 1050 * 50
    //space between unitRects
    space = totalWidth / 1050 * 8
    //corner of the rectangle
    corner = 4F
    var center = Pair(width / 2, height / 2)


    colorPair = if (currentDb < 80) {
        Pair(colorGreen, colorParentGreen)
    } else {
        Pair(colorYellow, colorParentYellow)
    }

    val leftBound = center.first - totalWidth / 2

    val unitUpperBound = center.second - secondHeight / 2
    val unitLowerBound = center.second + secondHeight / 2

    val highUnitUpperBound = center.second - highUnitHeight / 2
    val highUnitLowerBound = center.second + highUnitHeight / 2


    var numOfColor = ((min(lastDb, currentDb) - 30) / 5)
    var numOfChangingColor = abs(currentDb - lastDb) / 5

    for (index in 0..17) {
        //change paint color
        if (index < numOfColor) {
            soundPaint.color = colorPair.first
        } else if (index < numOfColor + numOfChangingColor) {
            soundPaint.color = colorPair.second
        } else {
            soundPaint.color = colorDefault
        }

        //if index ==10, draw highUnit
        var thisLeftBound = leftBound + space * (index + 1) + index * unitWidth
        if (index == 10) {
            canvas?.drawRoundRect(
                thisLeftBound,
                highUnitUpperBound,
                thisLeftBound + unitWidth,
                highUnitLowerBound,
                corner, corner, soundPaint
            )
        } else {
            canvas?.drawRoundRect(
                thisLeftBound,
                unitUpperBound,
                thisLeftBound + unitWidth,
                unitLowerBound,
                corner, corner, soundPaint
            )
        }
    }
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    var width = measureDimension(totalWidth.toInt(), widthMeasureSpec)
    var height = measureDimension(totalHeight.toInt(), heightMeasureSpec)
    setMeasuredDimension(width, height)

}

fun measureDimension(defaultSize: Int, measureSpec: Int): Int {
    var result = defaultSize
    var specMode = MeasureSpec.getMode(measureSpec)
    var specSize = MeasureSpec.getSize(measureSpec)

    if (specMode == MeasureSpec.EXACTLY) {
        result = specSize
    } else {
        result = defaultSize
        if (specMode == MeasureSpec.AT_MOST) {
            result = min(result, specSize)
        }
    }
    return result
}


private fun initNoiseSpline() {
    soundPaint.strokeWidth = 0F
    soundPaint.style = Paint.Style.FILL_AND_STROKE
    soundPaint.apply {
        isAntiAlias = true
        isDither = true
        isFilterBitmap = true
    }

}

} ```

File 2: noiseSplineAttr.xml

```

<declare-styleable name="NoiseSplineView">
    <attr name="currentDb" format="integer"/>
    <attr name="lastDb" format="integer"/>
</declare-styleable>

```