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:
- Suppose our volume bar currently has a decibel value of 40, with two cells green and the rest gray;
- Now, our volume bar gets a new decibel value of 70, which requires eight cells to turn green;
- 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:
- Add another type of cell: the transition cell
- Transition cells are drawn with a higher opacity color
- Next, we introduce a new variable to our component: lastDb, which represents the decibel value of the previous moment
- 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
- 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
- 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
- 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
- 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
- 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
- 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