DEV Community

HarmonyOS
HarmonyOS

Posted on

Sound Wave Animations with ArkTS

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 {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Final touch, random wave.

random() {
  this.waveData = new Array(this.maxNumRect).fill(0).map(() => Math.random() * this.maxValue)
  this.draw()
}
Enter fullscreen mode Exit fullscreen mode

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 = []
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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%' })
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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)
        }
      }
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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%' })
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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%' })
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The final result should look like this.

Conclusion

Congratulations for not giving up! See you all in new adventures. :)

~ Fortuna Favet Fortibus

Written by Mehmet Karaaslan

Top comments (0)