Read the original article:Sound Wave Animations with ArkTS
Introduction
Ready to create some mesmerizing sound wave animations with ArkTS and canvas? Don't wait anymore.
Linear Animation
The logic:
We will start with the view model. Create the WaveVM.ets under the viewmodel directory. This will be an ‘@Observed’ class to store the state.
@Observed
export default class WaveVM {
// ...
}
First, define parameters and constants.
// canvas context
context: CanvasRenderingContext2D = new CanvasRenderingContext2D()
// animation mode
mode: 'live' | 'random' = 'live'
// wave data
private waveData: number[] = []
// constants
private canvasWidth = 0
private canvasHeight = 0
private maxNumRect = 0 // number of rectangles that can fit in the screen
// You can modify these values for your case
private minValue = 1 // min rect height, used if the waveData is 0
private maxValue = 100 // used to normalize waveData
private rectWidth = 5
private space = 1 // space between rectangles
We will initialize some parameters after the canvas is ready. We will come to this later; let's define the function.
start() {
// set color
this.context.fillStyle = '#7F00FF'
// set constants
this.canvasWidth = this.context.width
this.canvasHeight = this.context.height
this.maxNumRect = Math.floor((this.canvasWidth + this.space) / (this.rectWidth + this.space))
}
Canvas needs exact coordinates to draw. Thus, we will normalize the wave data to fit in the canvas.
normalize(val: number): number {
if (val === 0) {
return this.minValue
}
if (val >= this.maxValue) {
return this.canvasHeight
}
return (this.canvasHeight - this.minValue) / this.maxValue * val
}
Of course, we need data to draw waves.
addData(num: number) {
if (this.waveData.length === this.maxNumRect) {
this.waveData.shift() // delete the first element
}
this.waveData.push(num)
this.draw() // we will implement this in the next step
}
All set, now we can draw.
draw() {
// first clear
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
const centerY = this.canvasHeight / 2
// animation will start from the right side
const startX = this.canvasWidth - (this.waveData.length * this.rectWidth + (this.waveData.length - 1) * this.space)
for (let i = 0; i < this.waveData.length; i++) {
const rectHeight = this.normalize(this.waveData[i])
const x = startX + i * (this.rectWidth + this.space);
const y = centerY - rectHeight / 2;
this.context.fillRect(x, y, this.rectWidth, rectHeight);
}
}
Final touch, random wave.
random() {
this.waveData = new Array(this.maxNumRect).fill(0).map(() => Math.random() * this.maxValue)
this.draw()
}
We will use the clear function to clear the canvas when we change the animation mode to start the linear animation from the right side.
clear() {
this.waveData = []
}
Let's combine them all.
@Observed
export default class WaveVM {
context: CanvasRenderingContext2D = new CanvasRenderingContext2D()
mode: 'live' | 'random' = 'live'
private waveData: number[] = []
// constants
private canvasWidth = 0
private canvasHeight = 0
private maxNumRect = 0
// You can modify these values for your case
private minValue = 1 // used if the waveData is 0
private maxValue = 100 // used to normalize waveData
private rectWidth = 5
private space = 1
start() {
// set color
this.context.fillStyle = '#7F00FF'
// set constants
this.canvasWidth = this.context.width
this.canvasHeight = this.context.height
this.maxNumRect = Math.floor((this.canvasWidth + this.space) / (this.rectWidth + this.space))
}
clear() {
this.waveData = []
}
addData(num: number) {
if (this.waveData.length === this.maxNumRect) {
this.waveData.shift()
}
this.waveData.push(num)
this.draw()
}
random() {
this.waveData = new Array(this.maxNumRect).fill(0).map(() => Math.random() * this.maxValue)
this.draw()
}
draw() {
// first clear
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
const centerY = this.canvasHeight / 2
const startX = this.canvasWidth - (this.waveData.length * this.rectWidth + (this.waveData.length - 1) * this.space)
for (let i = 0; i < this.waveData.length; i++) {
const rectHeight = this.normalize(this.waveData[i])
const x = startX + i * (this.rectWidth + this.space);
const y = centerY - rectHeight / 2;
this.context.fillRect(x, y, this.rectWidth, rectHeight);
}
}
normalize(val: number): number {
if (val === 0) {
return this.minValue
}
if (val >= this.maxValue) {
return this.canvasHeight
}
return (this.canvasHeight - this.minValue) / this.maxValue * val
}
}
The User Interface:
Time to see things in action. Create LinearWaveAnimation.ets under the components directory. We will use the WaveVM in here.
Let's start with the canvas:
import WaveVM from "../viewmodel/WaveVM";
@Component
export struct LinearWaveAnimation {
@State waveVm: WaveVM = new WaveVM() // initialize the view model
build() {
Stack() {
Canvas(this.waveVm.context) // do not forget to use the context
.width('100%')
.height('100%')
.onReady(() => {
this.waveVm.start() // start the animation
})
}
.size({ width: '100%', height: '100%' })
}
}
The canvas is ready, but there is no data to show. We will use the setInterval function to create some sample data. Modify the .onReady() attribute.
private intervalId: number | null = null; // just to store the interval id
.onReady(() => {
this.waveVm.start()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
})
Final touch, mode change. We will use a Text to show the current mode and an ArcButton to change the mode. Since we are using a Stack, we can add these over the canvas.
// Add to Stack
Column() {
// show current mode
Text(this.waveVm.mode === 'live' ? 'Live' : 'Random').fontSize(18).padding({ top: 4 })
Blank().layoutWeight(1)
ArcButton({
options: new ArcButtonOptions({
position: ArcButtonPosition.BOTTOM_EDGE,
label: 'Change Mode',
onClick: () => {
clearInterval(this.intervalId) // delete current interval
if (this.waveVm.mode === 'live') {
this.waveVm.mode = 'random'
this.intervalId = setInterval(() => {
this.waveVm.random()
}, 100)
} else {
this.waveVm.mode = 'live'
this.waveVm.clear()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
}
}
})
})
}
Let's combine all.
import { ArcButton, ArcButtonOptions, ArcButtonPosition } from "@ohos.arkui.advanced.ArcButton";
import WaveVM from "../viewmodel/WaveVM";
@Component
export struct LinearWaveAnimation {
@State waveVm: WaveVM = new WaveVM()
private intervalId: number | null = null;
build() {
Stack() {
Canvas(this.waveVm.context)
.width('100%')
.height('100%')
.onReady(() => {
this.waveVm.start()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
})
Column() {
Text(this.waveVm.mode === 'live' ? 'Live' : 'Random').fontSize(18).padding({ top: 4 })
Blank().layoutWeight(1)
ArcButton({
options: new ArcButtonOptions({
position: ArcButtonPosition.BOTTOM_EDGE,
label: 'Change Mode',
onClick: () => {
clearInterval(this.intervalId)
if (this.waveVm.mode === 'live') {
this.waveVm.mode = 'random'
this.intervalId = setInterval(() => {
this.waveVm.random()
}, 100)
} else {
this.waveVm.mode = 'live'
this.waveVm.clear()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
}
}
})
})
}
}
.size({ width: '100%', height: '100%' })
}
}
Let's modify the Index.ets and see the wave.
import { LinearWaveAnimation } from '../components/LinearWaveAnimation'
import { ArcSwiper } from '@kit.ArkUI'
@Entry
@Component
struct Index {
build() {
ArcSwiper() {
LinearWaveAnimation()
}
}
}
The result should look something like this:
Circular Animation
We will use the same logic with minor mathematical changes.
The Logic:
Create CircularWaveVM.ets. Since the logic is the same as the linear wave, we will not dive into the details.
@Observed
export default class CircularWaveVM {
context: CanvasRenderingContext2D = new CanvasRenderingContext2D();
mode: 'live' | 'random' = 'live'
private waveData: number[] = [];
// constants
private canvasWidth = 0;
private canvasHeight = 0;
private maxNumPoints = 0;
// You can modify these values for your case
private minValue = 1; // used if the waveData is 0
private maxValue = 100; // used to normalize waveData
private radius = 0; // radius of the circle
private numPoints = 60; // number of points on the circular wave
start() {
// set color
this.context.strokeStyle = '#FFFF33';
// set constants
this.canvasWidth = this.context.width;
this.canvasHeight = this.context.height;
this.maxNumPoints = this.numPoints;
this.radius = this.canvasWidth / 4
}
clear() {
this.waveData = [];
}
addData(num: number) {
if (this.waveData.length === this.maxNumPoints) {
this.waveData.shift();
}
this.waveData.push(num);
this.draw();
}
random() {
this.waveData = new Array(this.maxNumPoints).fill(0).map(() => Math.random() * this.maxValue);
this.draw();
}
draw() {
// first clear
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
const centerX = this.canvasWidth / 2;
const centerY = this.canvasHeight / 2;
for (let i = 0; i < this.waveData.length; i++) {
const angle = (i / this.maxNumPoints) * (2 * Math.PI);
const radiusVariation = this.normalize(this.waveData[i]);
const x = centerX + Math.cos(angle) * (this.radius + radiusVariation);
const y = centerY + Math.sin(angle) * (this.radius + radiusVariation);
// Draw wave bar
this.context.beginPath();
this.context.moveTo(centerX + Math.cos(angle) * this.radius, centerY + Math.sin(angle) * this.radius);
this.context.lineTo(x, y);
this.context.lineWidth = 2; // Width of the wave bars
this.context.stroke();
}
}
normalize(val: number): number {
if (val === 0) {
return this.minValue;
}
if (val >= this.maxValue) {
return this.radius; // maximum distance from center
}
return (this.radius - this.minValue) / this.maxValue * val;
}
}
The User Interface:
The user interface is the same as the linear wave page, but uses the CircularWaveVM instead of the WaveVM.
import { ArcButton, ArcButtonOptions, ArcButtonPosition } from "@ohos.arkui.advanced.ArcButton";
import CircularWaveVM from "../viewmodel/CircularWaveVM";
@Component
export struct CircularWaveAnimation {
@State waveVm: CircularWaveVM = new CircularWaveVM()
private intervalId: number | null = null;
build() {
Stack() {
Canvas(this.waveVm.context)
.width('100%')
.height('100%')
.onReady(() => {
this.waveVm.start()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
})
Column() {
Text(this.waveVm.mode === 'live' ? 'Live' : 'Random').fontSize(18).padding({ top: 4 })
Blank().layoutWeight(1)
ArcButton({
options: new ArcButtonOptions({
position: ArcButtonPosition.BOTTOM_EDGE,
label: 'Change Mode',
onClick: () => {
clearInterval(this.intervalId)
if (this.waveVm.mode === 'live') {
this.waveVm.mode = 'random'
this.intervalId = setInterval(() => {
this.waveVm.random()
}, 100)
} else {
this.waveVm.mode = 'live'
this.waveVm.clear()
this.intervalId = setInterval(() => {
this.waveVm.addData(Math.random() * 100)
}, 100)
}
}
})
})
}
}.size({ width: '100%', height: '100%' })
}
}
Let's modify the Index.ets to see the result.
import { CircularWaveAnimation } from '../components/CircularWaveAnimation'
import { LinearWaveAnimation } from '../components/LinearWaveAnimation'
import { ArcSwiper } from '@kit.ArkUI'
@Entry
@Component
struct Index {
build() {
ArcSwiper() {
LinearWaveAnimation()
CircularWaveAnimation()
}
}
}
The final result should look like this.
Conclusion
Congratulations for not giving up! See you all in new adventures. :)
~ Fortuna Favet Fortibus
Top comments (0)