s or state drift.
Core Solution
Building a declarative dial indicator requires three architectural decisions: layering strategy, state synchronization, and animation lifecycle control. The implementation below demonstrates a production-ready pattern using ArkTS.
Architecture Decisions
- Stack-Based Layering: Nested columns create implicit z-index conflicts and complicate responsive sizing.
Stack ensures the progress ring and rotating hand share the same coordinate space without layout recalculations.
- State-Driven Rotation: Binding the hand's angle to a
@State variable decouples animation logic from UI rendering. This enables external progress updates without restarting the animation scheduler.
- Explicit Lifecycle Cleanup: Infinite animations must be cancelled when the component unmounts. Failing to do so causes memory leaks and background CPU consumption.
Implementation
import { animation } from '@kit.ArkUI'
@Component
export struct DialProgressIndicator {
@Prop initialValue: number = 0
@Prop maxValue: number = 100
@State private currentProgress: number = 0
@State private handRotation: number = 0
private animationController: animation.AnimationController | null = null
aboutToAppear(): void {
this.currentProgress = this.initialValue
this.syncRotation()
this.startContinuousSweep()
}
aboutToDisappear(): void {
this.stopAnimation()
}
@Watch('currentProgress')
onProgressChange(): void {
this.syncRotation()
}
private syncRotation(): void {
const ratio = this.currentProgress / this.maxValue
this.handRotation = ratio * 360
}
private startContinuousSweep(): void {
this.animationController = animation.animateTo({
duration: 4000,
curve: animation.Curve.Linear,
iterations: -1,
playMode: animation.PlayMode.Normal
}, () => {
this.handRotation = 360
})
}
private stopAnimation(): void {
if (this.animationController) {
this.animationController.stop()
this.animationController = null
}
}
build() {
Stack({ alignContent: Alignment.Center }) {
Progress({
value: this.currentProgress,
total: this.maxValue,
type: ProgressType.ScaleRing
})
.width('100%')
.height('100%')
.backgroundColor('#1A1A1A')
.style({
scaleCount: 24,
scaleWidth: 4,
color: '#007AFF'
})
Divider()
.width(0)
.height('45%')
.borderWidth(2)
.borderColor('#FFFFFF')
.borderRadius(2)
.rotate({
centerX: '50%',
centerY: '100%',
angle: this.handRotation
})
.hitTestBehavior(HitTestMode.None)
}
.width(120)
.height(120)
}
}
Technical Rationale
Stack Alignment: Alignment.Center guarantees both children occupy identical bounds. This eliminates manual margin calculations and ensures the rotation pivot remains mathematically stable across device densities.
Divider as Hand: Using a zero-width divider with a border renders a vector-native line. This avoids asset loading, scales cleanly across DPI buckets, and consumes negligible memory compared to Image or Text overlays.
- Pivot Configuration:
centerX: '50%' and centerY: '100%' anchors the rotation to the bottom-center of the divider. This matches the physical behavior of a clock hand mounted at the center of the dial.
- Animation Controller Reference: Storing the
animateTo return value enables explicit cancellation. ArkUI's scheduler does not automatically garbage-collect infinite loops when components unmount.
@Watch Synchronization: External progress updates trigger syncRotation() immediately, preventing visual lag between the ring fill and hand position.
Pitfall Guide
1. Misaligned Rotation Pivot
Explanation: Using centerX: '0%' or centerY: '0%' rotates the hand around its top-left corner, causing a visible wobble and breaking the clock illusion.
Fix: Always anchor to centerX: '50%' and centerY: '100%' for bottom-center mounting. Verify pivot placement using DevEco Studio's layout inspector.
2. Animation Memory Leaks
Explanation: animateTo with iterations: -1 runs indefinitely. If the component unmounts without cancellation, the scheduler continues consuming CPU cycles in the background.
Fix: Store the animation controller reference and call .stop() in aboutToDisappear(). Never rely on implicit garbage collection for infinite loops.
3. Progress-to-Angle Ratio Drift
Explanation: Hardcoding this.handRotation = 360 without calculating the ratio causes the hand to overshoot or undershoot when maxValue changes dynamically.
Fix: Compute ratio = current / total and multiply by 360. Apply this calculation in both initial sync and animation callbacks.
4. Main Thread Blocking During State Updates
Explanation: Performing heavy computations inside the animateTo callback or @Watch handler stalls the UI thread, causing frame drops and janky rotation.
Fix: Keep animation callbacks minimal. Pre-calculate ratios, use primitive types, and avoid synchronous I/O or complex object instantiation during render cycles.
5. Touch Event Interception
Explanation: The Divider overlay captures tap and swipe events by default, blocking interaction with underlying controls or progress ring gestures.
Fix: Apply .hitTestBehavior(HitTestMode.None) to the hand component. This ensures touch events pass through to the stack's background layer.
6. API Version Incompatibility
Explanation: ProgressType.ScaleRing and animateTo require API 19+. Deploying to older HarmonyOS versions causes silent rendering failures or runtime exceptions.
Fix: Implement version guards using @ohos.app.ability.UIAbilityContext or provide a fallback linear progress indicator for pre-API 19 environments.
7. Ignoring Safe Area and Notch Cuts
Explanation: Fixed dimensions (width(120)) may clip on devices with aggressive screen cutouts or dynamic island layouts.
Fix: Use responsive sizing (width('80%')) or wrap the component in a SafeArea container. Test across foldable and tablet form factors before production release.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple timer/loader UI | Composite Progress + Divider | Zero external dependencies, native scheduler optimization | Low (native API only) |
| Complex data visualization with multiple hands | Canvas/SVG Custom Drawing | Requires independent transform matrices per element | Medium (custom render logic) |
| Rapid prototyping with design system compliance | Third-Party UI Library | Pre-built accessibility, theming, and responsive variants | Medium-High (bundle size + licensing) |
| Legacy HarmonyOS < API 19 | Fallback Linear Progress | ScaleRing and animateTo unavailable | Low (degraded UX) |
Configuration Template
// DialProgressIndicator.ets
import { animation } from '@kit.ArkUI'
@Component
export struct DialProgressIndicator {
@Prop progress: number = 0
@Prop total: number = 100
@Prop ringColor: string = '#007AFF'
@Prop handColor: string = '#FFFFFF'
@Prop size: number = 120
@State private rotationAngle: number = 0
private sweepController: animation.AnimationController | null = null
aboutToAppear(): void {
this.updateAngle()
this.beginSweep()
}
aboutToDisappear(): void {
this.terminateSweep()
}
@Watch('progress')
onProgressUpdate(): void {
this.updateAngle()
}
private updateAngle(): void {
const normalized = Math.min(Math.max(this.progress, 0), this.total)
this.rotationAngle = (normalized / this.total) * 360
}
private beginSweep(): void {
this.sweepController = animation.animateTo({
duration: 3500,
curve: animation.Curve.Linear,
iterations: -1
}, () => {
this.rotationAngle = 360
})
}
private terminateSweep(): void {
this.sweepController?.stop()
this.sweepController = null
}
build() {
Stack({ alignContent: Alignment.Center }) {
Progress({
value: this.progress,
total: this.total,
type: ProgressType.ScaleRing
})
.width(this.size)
.height(this.size)
.backgroundColor('#111111')
.style({
scaleCount: 20,
scaleWidth: 3,
color: this.ringColor
})
Divider()
.width(0)
.height(`${this.size * 0.45}px`)
.borderWidth(2)
.borderColor(this.handColor)
.borderRadius(2)
.rotate({
centerX: '50%',
centerY: '100%',
angle: this.rotationAngle
})
.hitTestBehavior(HitTestMode.None)
}
.width(this.size)
.height(this.size)
}
}
Quick Start Guide
- Create the Component: Copy the configuration template into
DialProgressIndicator.ets within your entry/src/main/ets/components directory.
- Import and Instantiate: Add
import { DialProgressIndicator } from '../components/DialProgressIndicator' to your target page. Render with <DialProgressIndicator progress={45} total={100} size={140} />.
- Bind Dynamic Progress: Connect the
progress prop to your application state using @State or @Link. The component automatically recalculates rotation and ring fill.
- Verify Animation Lifecycle: Run the app on a physical device or emulator. Open DevEco Studio's Profiler tab and confirm CPU usage remains below 2% during idle animation. Validate that rotation stops cleanly when navigating away from the page.
- Deploy: Ensure your
module.json5 specifies apiVersion: 19 or higher. Build and distribute. No external dependencies or native modules required.