“用Android复刻Apple产品UI”(1)—丝滑的噪声监测音量条

·  阅读 2863
“用Android复刻Apple产品UI”(1)—丝滑的噪声监测音量条

前言

一直想花时间复刻一下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种颜色:
  • 默认色:灰色
  • 低分贝的绿色以及过渡用的透明绿色
  • 高分贝的黄色以及过渡用的透明黄色
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")
复制代码
  1. 单元格的尺寸以及总尺寸 这里的比例是我把AppleWatch的截图放进figma测量了一下,也可以自己定夺。
//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打包起来:
colorPair = if (currentDb < 80) {
    Pair(colorGreen, colorParentGreen)
} else {
    Pair(colorYellow, colorParentYellow)
}
复制代码
  1. 计算不同类型单元格的数量: 我们的三种类型单元格:
  • 有色单元格:表示上一刻的分贝
  • 透明色单元格:表示过渡模块
  • 灰色单元格:默认色
//有色单元格
val numOfColor:Int = ((min(lastDb, currentDb) - 30) / 5)
//过渡单元格
val numOfChangingColor:Int = abs(currentDb - lastDb) / 5
复制代码
  1. 循环开画,画18个:
  • 首先,判断下当前的index,如果是处于有色块区间,paint则调整为color Pair的第一个值,如果是过渡块区间,就设置成透明的颜色;剩下的都是默认色。
  • 其次,如果我们到了第10个,也就是表示80分贝的 highUnitRect,就让这个rect拥有更高的height。
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,其他的颜色可以自己定义。
    在构造器里初始化一下我们的两个音量参数
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来不断逼近以实现柔滑的过渡效果!

//全局变量存储上一刻与当前时刻的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
嗯,和预想的一样,收工。

如果你觉得还挺有趣,可以点击下方链接浏览更多同类文章:
“用Android复刻Apple产品UI”(3)—优雅的数据统计图表 - 掘金 (juejin.cn)
“用Android复刻Apple产品UI”(2)—丝滑的AppStore卡片转场动画 - 掘金 (juejin.cn)

4. 附-代码

File 1: NoiseSplineView.kt

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

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="NoiseSplineView">
        <attr name="currentDb" format="integer"/>
        <attr name="lastDb" format="integer"/>
    </declare-styleable>
</resources>
复制代码
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改