preface

Always wanted to take the time to recreate Apple’s native UI and animation, super silky.

Today, the target is the AppleWatch’s noise-detecting volume bar.

1. Page content analysis

Before getting started, take a closer look at the information on this page, translate it into business needs, and organize your thoughts before you start writing.

1.1 Static Layout

Let’s start by looking at the details covered in the image above:

  • The noise animation bar is composed of 18 rounded rectangles (denoted as unitRect)
  • The entire animation bar covers 30dB~120dB
  • Each rounded rectangle has the unit dB of 5
  • Rounded rectangles with dB of 80 have a higher height
  • The left side of the noise animation bar is green/yellow, which represents the decibel value, and the rest is the default color

With the above details, let’s take a look at how this might be done:

  • Static animation bar: This is easy, canvas. DrawRoundRect () will draw the rectangle with the corner rounded corner; Draw 18 of them, and on the 10th one we’ll raise the height, and we’ll get the initial effect.
  • Animation bar color: We can set the decibel value to the input value of this component (currentDb) to calculate how many cells (unitRect) should be colored and the others should be colorless by default.

1.2 Dynamic Effect

Again, take a look at the animation in the GIF above

  • Color change: below 80 decibels, green; Decibels above 80 turn yellow
  • Animation bar changes: After the decibel value changes, the animation bar needs to present a smooth transition effect

First, two dynamic effects can be translated into the following two requirements:

  • Color change: We condition our component’s input currentDb to yellow if >80, and green otherwise.
  • Animation bar changes: In order to achieve smooth animation bar changes, it is not difficult to imagine that after creating a custom View, objectAnimator will execute an animation against currentDb, approximating the old db value to the new db value to achieve a transition effect.

Example of volume bar dynamic transition effect:

  1. Suppose our volume bar currently has a decibel value of 40, with two cells green and the rest gray;
  2. Now, our volume bar gets a new decibel value of 70, which requires eight cells to turn green;
  3. Let’s assume that the animation needs to be completed with a linear transition effect in 120ms: the six new green cells will be progressively filled in within 120ms, which is 20ms per cell, thus achieving the animation transition.

However, if we want to achieve a smoother effect:

  1. Add another type of cell: the transition cell
  2. Transition cells are drawn with a higher opacity color
  3. Next, we introduce a new variable to our component: lastDb, which represents the decibel value of the previous moment
  4. The absolute value of the difference between currentDb and lastDb, used to represent the current value in the process of changing, used to calculate the number of transition cells
  5. Finally, we’ll also use objectAnimator to let lastDb progressively approach currentDb for a more silky transition ✌

2. Enter custom View

Powerful custom view, the volume bar only needs some of the most basic functions to complete the drawing, the following only put the most core code.

2.1 draw

  1. We need the following 5 colors:
  • Default color: gray
  • Low decibel green and transparent green for transition
  • High decibel yellow and clear yellow for transition
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")
Copy the code
  1. The size of the cell and the total size

I measured the ratio here by putting the screenshot of AppleWatch into figma, and I can also decide by myself.

//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
Copy the code
  1. Different decibels correspond to different color combinations

Above 80 decibels, we convert Paint to yellow, and here I use a Pair to wrap it:

colorPair = if (currentDb < 80) {
    Pair(colorGreen, colorParentGreen)
} else {
    Pair(colorYellow, colorParentYellow)
}
Copy the code
  1. Count the number of different types of cells:

Our three types of cells:

  • Colored cells: decibels of the last moment
  • Transparent cells: transition modules
  • Gray cell: default color
// Colored cells
val numOfColor:Int = ((min(lastDb, currentDb) - 30) / 5)
// Transition cells
val numOfChangingColor:Int = abs(currentDb - lastDb) / 5
Copy the code
  1. Cycle open drawing, draw 18:
  • First, determine the current index value. If it is in a color block, set paint to the first value of the color Pair. If it is in a transition block, set it to a transparent color. The rest are default colors.
  • Second, if we get to number 10, which is 80 db highUnitRect, let the rect have a higher 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 ) } }Copy the code

At this point, our onDraw method is complete and we have the following default effect.

Db > = 80:Db < 80:

2.2 Smooth animation transition effect

Here’s the exciting moment, let’s get it moving:

  • First, don’t forget to add the Style XML file so that it can get external parameters: we’ll just set lastDb and currentDb for now, and the rest of the colors can be customized.

Initialize our two volume parameters in the constructor

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()
Copy the code
  • Next, we create the custom component in the Layout XML file and animate it with ObjectAnimator: as mentioned earlier, we keep approaching with lastDb to achieve smooth transitions!

// The global variable stores the dB of the previous and current moment
lateinit var currentDb:Int
lateinit var lastDb:Int

// This function takes an argument, sets currentDb and lastDb of the volume bar, and executes an animation.
fun setSoundDataAndAnimate(noiseDb: Int) {
    // Receive the volume parameter to update our currentDbcurrentDb = noiseDb noiseSpline? .currentDb = currentDb noiseSpline? .lastDb = lastDb// Create ofInt animation with lastDb approximating currentDb
    var noiseAnimator = ObjectAnimator.ofInt(noiseSpline, "lastDb", lastDb, currentDb) noiseAnimator? .interpolator = AccelerateDecelerateInterpolator()// Use the interpolator to speed up before decelerating, of course, can also be replaced with other!noiseAnimator? .start()// Save the current volume to lastDb
    lastDb = currentDb
}
Copy the code

3. Effect preview

Finally, we have the volume bar component, and now we create a child thread to look at the animation 700ms at a time.

And finally, if we look at the slow down animation, is lastDb closing in on currentDb as we would expect?

Well, that’s what I thought. Call it a day.

4. Attach – Code

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(a) {
        soundPaint.strokeWidth = 0F
        soundPaint.style = Paint.Style.FILL_AND_STROKE
        soundPaint.apply {
            isAntiAlias = true
            isDither = true
            isFilterBitmap = true}}}Copy the code

File 2: noiseSplineAttr.xml

<? The 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>Copy the code