DEV Community

HarmonyOS
HarmonyOS

Posted on

Pulse Ring with ArkUI Animator

Read the original article:Pulse Ring with ArkUI Animator

Requirement Description

Implement a minimal Pulse Ring animation for HarmonyOS wearable:

  • One-tap play/pause.
  • Smooth ring animation (grow/shrink).
  • Quick theme & ring color selection.

Background Knowledge

  • Prefer this.getUIContext().createAnimator() (UI context-bound).
  • onFrame provides interpolated value; write it to @State and use in UI.
  • @State must have default values in ArkTS.

Implementation Steps

  1. Define themes and a small palette for ring color.
  2. Create a component with @State fields: running, progress, themeIdx, ringColor.
  3. Build an Animator with direction: 'alternate', iterations: -1.
  4. In onFrame, update progress (0..1) and compute ring size.
  5. UI: a centered Stack() as the ring; a Row() of swatches; a small “Theme” button.
  6. Tap the ring to toggle play/pause.

Code Snippet / Configuration

import { AnimatorResult, AnimatorOptions } from '@kit.ArkUI'

const MIN = 84
const MAX = 140

interface ThemeConf { bg: Color; fg: Color; ring: Color; border: number }
const THEMES: ThemeConf[] = [
  { bg: Color.Black, fg: Color.White, ring: Color.White, border: 3 },
  { bg: Color.White, fg: Color.Black, ring: Color.Black, border: 3 },
  { bg: Color.Gray,  fg: Color.Black, ring: Color.White, border: 4 },
]
const RING_PALETTE: Color[] = [ Color.White, Color.Black, Color.Red, Color.Green, Color.Blue, Color.Yellow ]

@Entry
@Component
struct Index {
  @State running: boolean = false
  @State progress: number = 0
  @State themeIdx: number = 0
  @State ringColor: Color | null = null
  private anim?: AnimatorResult

  aboutToDisappear() {
    if (this.anim) { try { this.anim.cancel() } catch (_) {} this.anim = undefined }
  }

  private ensureAnimator() {
    if (this.anim) return
    const opts: AnimatorOptions = {
      duration: 1800, easing: 'smooth', delay: 0, fill: 'forwards',
      direction: 'alternate', iterations: -1, begin: 0, end: 1
    }
    this.anim = this.getUIContext().createAnimator(opts)
    this.anim.onFrame = (v: number) => { this.progress = v } // 0..1
    this.anim.onFinish = () => { this.running = false }
    this.anim.onCancel = () => { this.running = false }
  }

  private playPause() {
    this.ensureAnimator()
    if (this.running) { this.anim!.pause(); this.running = false }
    else { this.anim!.play(); this.running = true }
  }

  private ringSize(): number { return MIN + (MAX - MIN) * this.progress }
  private theme(): ThemeConf { return THEMES[this.themeIdx % THEMES.length] }
  private currentRingColor(): Color { return this.ringColor ?? this.theme().ring }
  private nextTheme() { this.themeIdx = (this.themeIdx + 1) % THEMES.length }

  build() {
    Column() {
      Text(this.running ? 'Running' : 'Paused')
        .fontSize(12).fontColor(this.theme().fg).margin({ bottom: 6 })

      Stack() {
        if (!this.running) {
          Text('Tap').fontSize(14).fontColor(this.theme().fg)
        }
      }
        .width(this.ringSize()).height(this.ringSize())
        .border({ width: this.theme().border, color: this.currentRingColor(), radius: this.ringSize() / 2 })
        .backgroundColor(this.theme().bg)
        .alignSelf(ItemAlign.Center)
        .gesture(TapGesture().onAction(() => this.playPause()))
        .margin({ bottom: 10 })

      Row() {
        ForEach(
          RING_PALETTE,
          (c: Color, idx: number) => {
            Stack()
              .width(18).height(18)
              .border({ width: 1, color: this.theme().fg, radius: 9 })
              .backgroundColor(c)
              .margin({ left: 4, right: 4 })
              .gesture(TapGesture().onAction(() => { this.ringColor = c }))
          },
          (c: Color, idx: number) => idx.toString() // trackBy must be string
        )
      }
      .justifyContent(FlexAlign.Center)
      .margin({ bottom: 8 })

      Button('Theme')
        .height(28).width(70)
        .onClick(() => this.nextTheme())
        .backgroundColor(this.currentRingColor())
        .alignSelf(ItemAlign.Center)
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
    .padding(8).backgroundColor(this.theme().bg)
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

  • Verified on wearable emulator (square & round) and one device.
  • Start/stop latency < 50 ms perceived; animation smooth at default settings

Limitations or Consideration

  • Don’t name methods like ArkUI attributes (size, width, border, …).
  • @State must have initial values (e.g., ringColor: Color | null = null).
  • Inside Column/Row/Stack DSL blocks, only UI syntax (If, ForEach, components) — no const/let.
  • reverse() does not affect animations using interpolating-spring curve (per docs).
  • Keep logic in onFrame lightweight to save battery.

Related Documents or Links

Written by Omer Basri Okcu

Top comments (0)