文章目录
-
- 每日一句正能量
- 一、前言:运动健康的视觉与交互新范式
- 二、健康场景下的特性适配设计
-
- [2.1 运动状态与导航的协同](#2.1 运动状态与导航的协同)
- [2.2 生理数据-光效映射算法](#2.2 生理数据-光效映射算法)
- 三、核心组件实战
-
- [3.1 运动感知悬浮导航(MotionAwareFloatNav.ets)](#3.1 运动感知悬浮导航(MotionAwareFloatNav.ets))
- [3.2 实时数据光效可视化(DataLightVisualization.ets)](#3.2 实时数据光效可视化(DataLightVisualization.ets))
- [3.3 训练计划悬浮卡片(WorkoutFloatCard.ets)](#3.3 训练计划悬浮卡片(WorkoutFloatCard.ets))
- 四、主页面集成
-
- [4.1 运动主页面(WorkoutPage.ets)](#4.1 运动主页面(WorkoutPage.ets))
- 五、关键技术总结
-
- [5.1 运动状态-导航-光效协同矩阵](#5.1 运动状态-导航-光效协同矩阵)
- [5.2 生理数据可视化映射](#5.2 生理数据可视化映射)
- [5.3 性能与功耗优化](#5.3 性能与功耗优化)
- 六、调试与适配建议
- 七、结语

每日一句正能量
人生就像骑自行车,想要保持平衡,就必须不断前行。停下来就会倒下,犹豫就会败北。即使前路颠簸,也要握紧车把,奋力蹬踏。风吹过脸庞的感觉,只有前进的人才懂。早上好!
一、前言:运动健康的视觉与交互新范式
在智能健身领域,数据可视化与交互体验直接影响用户的运动积极性和训练效果。HarmonyOS 6(API 23)带来的**悬浮导航(Float Navigation)与沉浸光感(Immersive Light Effects)**特性,为健康类应用开发提供了革命性的设计工具。
传统健身APP的固定导航栏会遮挡运动数据图表,而复杂的菜单操作在运动中极不便利。HarmonyOS 6的悬浮导航允许在运动过程中通过手势快速切换数据视图,无需精确点击;沉浸光感则能根据实时心率、运动强度动态调整环境氛围,将生理数据转化为视觉反馈,创造"身体即界面"的沉浸式体验。
本文将构建一款名为**"光影律动"**的智能健身应用,展示如何:
- 运动感知悬浮导航:根据运动状态(静止/热身/燃脂/冲刺)自动调整导航形态
- 生理数据光效映射:实时心率、卡路里消耗转化为动态光效反馈
- 多设备数据融合:手表传感器数据驱动手机端光效变化
- 全屏沉浸训练模式 :利用
expandSafeArea实现无边界运动界面
二、健康场景下的特性适配设计
2.1 运动状态与导航的协同
| 运动阶段 | 心率区间 | 导航形态 | 光效特征 | 交互优先级 |
|---|---|---|---|---|
| 静止/准备 | <100bpm | 底部展开栏 | 平静呼吸光 | 高(可配置) |
| 热身 | 100-120bpm | 侧边迷你轨 | 温和渐变色 | 中 |
| 燃脂 | 120-150bpm | 边缘光点 | 动态脉冲光 | 低(防误触) |
| 有氧 | 150-170bpm | 全隐藏+手势 | 快速闪烁警示 | 极低 |
| 冲刺 | >170bpm | 仅紧急按钮 | 红色警报光 | 仅紧急停止 |
| 恢复 | 下降中 | 逐步浮现 | 舒缓绿光 | 中 |
2.2 生理数据-光效映射算法
基于运动生理学原理,我们建立了心率变异性(HRV)与光效参数的实时映射模型:
typescript
// 心率-光效映射配置
export const HeartRateLightMap = {
zones: [
{ min: 0, max: 100, color: '#4ECDC4', label: 'rest', pulse: 6000 }, // 静息:青绿慢呼吸
{ min: 100, max: 120, color: '#2ECC71', label: 'warmup', pulse: 4000 }, // 热身:绿色温和
{ min: 120, max: 150, color: '#F5A623', label: 'fatburn', pulse: 2500 }, // 燃脂:橙色活跃
{ min: 150, max: 170, color: '#FF6B6B', label: 'cardio', pulse: 1500 }, // 有氧:红色急促
{ min: 170, max: 220, color: '#FF0000', label: 'peak', pulse: 800 } // 峰值:深红警示
]
};
三、核心组件实战
3.1 运动感知悬浮导航(MotionAwareFloatNav.ets)
代码亮点:这是本应用的核心创新组件。它通过集成运动传感器和心率数据,实现"零点击"导航------在剧烈运动中,导航自动收缩为边缘光点防止误触;在恢复阶段,手势悬停即可展开完整菜单。导航形态与生理状态实时联动,这是传统固定导航无法实现的。
typescript
// entry/src/main/ets/components/MotionAwareFloatNav.ets
import { sensor } from '@kit.SensorServiceKit';
import { health } from '@kit.HealthKit';
// 运动状态枚举
export enum MotionState {
IDLE = 'idle', // 静止
WARMUP = 'warmup', // 热身
FAT_BURN = 'fat_burn', // 燃脂
CARDIO = 'cardio', // 有氧
PEAK = 'peak', // 峰值
RECOVERY = 'recovery' // 恢复
}
// 导航模式
export enum FitnessNavMode {
FULL = 'full', // 完整展开
MINI = 'mini', // 迷你轨道
DOT = 'dot', // 边缘光点
HIDDEN = 'hidden', // 完全隐藏
EMERGENCY = 'emergency' // 仅紧急停止
}
// 导航项
interface FitnessNavItem {
id: string;
icon: Resource;
label: string;
shortcut: string; // 手势快捷键
requiresStable: boolean; // 是否需要静止状态
}
@Component
export struct MotionAwareFloatNav {
// 回调
onItemSelect?: (item: FitnessNavItem) => void;
onEmergencyStop?: () => void;
onGesture?: (gesture: string) => void;
@State currentHeartRate: number = 75;
@State motionState: MotionState = MotionState.IDLE;
@State navMode: FitnessNavMode = FitnessNavMode.FULL;
@State activeItemId: string = 'dashboard';
@State currentLightColor: string = '#4ECDC4';
@State lightPulseSpeed: number = 6000;
@State lightIntensity: number = 0.6;
@State isExpanded: boolean = true;
@State accelerometerData: {x: number, y: number, z: number} = {x: 0, y: 0, z: 0};
@State gestureBuffer: string = ''; // 手势识别缓冲区
// 导航数据
private navItems: FitnessNavItem[] = [
{ id: 'dashboard', icon: $r('app.media.ic_dashboard'), label: '数据面板', shortcut: 'tap', requiresStable: false },
{ id: 'workout', icon: $r('app.media.ic_dumbbell'), label: '训练计划', shortcut: 'swipe_up', requiresStable: true },
{ id: 'history', icon: $r('app.media.ic_chart'), label: '历史记录', shortcut: 'swipe_left', requiresStable: true },
{ id: 'music', icon: $r('app.media.ic_music'), label: '运动音乐', shortcut: 'swipe_right', requiresStable: false },
{ id: 'settings', icon: $r('app.media.ic_settings'), label: '设置', shortcut: 'long_press', requiresStable: true }
];
// 传感器管理
private heartRateTimer: number = -1;
private accelTimer: number = -1;
private readonly EMERGENCY_THRESHOLD: number = 190; // 紧急心率阈值
aboutToAppear(): void {
this.initializeSensors();
this.startHealthMonitoring();
}
aboutToDisappear(): void {
clearInterval(this.heartRateTimer);
clearInterval(this.accelTimer);
}
private async initializeSensors(): Promise<void> {
try {
// 注册加速度计(检测运动强度)
sensor.on(sensor.SensorId.SENSOR_TYPE_ID_ACCELEROMETER, (data) => {
this.accelerometerData = {
x: data.x,
y: data.y,
z: data.z
};
this.detectMotionIntensity();
});
} catch (error) {
console.error('Sensor initialization failed:', error);
}
}
private startHealthMonitoring(): void {
// 模拟心率数据获取(实际应使用HealthKit)
this.heartRateTimer = setInterval(() => {
this.fetchHeartRate();
}, 2000);
}
private async fetchHeartRate(): Promise<void> {
try {
// 实际项目中使用HealthKit获取实时心率
// const heartRate = await health.getHeartRate();
// 模拟数据:根据运动状态生成合理心率
const simulatedHR = this.simulateHeartRate();
this.currentHeartRate = simulatedHR;
// 更新运动状态
this.updateMotionState(simulatedHR);
// 同步光效
this.syncLightEffect(simulatedHR);
// 检查紧急情况
if (simulatedHR > this.EMERGENCY_THRESHOLD) {
this.triggerEmergencyMode();
}
} catch (error) {
console.error('Heart rate fetch failed:', error);
}
}
private simulateHeartRate(): number {
// 模拟心率变化(实际项目中替换为真实数据)
const baseHR = 75;
const variation = Math.sin(Date.now() / 10000) * 30 + Math.random() * 10;
return Math.floor(Math.max(60, Math.min(200, baseHR + variation)));
}
private detectMotionIntensity(): void {
// 通过加速度计检测运动强度
const { x, y, z } = this.accelerometerData;
const magnitude = Math.sqrt(x*x + y*y + z*z);
// 运动强度辅助判断心率区间
if (magnitude > 15) {
// 高强度运动,优先使用心率判断
}
}
private updateMotionState(heartRate: number): void {
let newState: MotionState;
if (heartRate < 100) {
newState = MotionState.IDLE;
} else if (heartRate < 120) {
newState = MotionState.WARMUP;
} else if (heartRate < 150) {
newState = MotionState.FAT_BURN;
} else if (heartRate < 170) {
newState = MotionState.CARDIO;
} else {
newState = MotionState.PEAK;
}
// 检测恢复状态(心率下降)
if (this.motionState === MotionState.CARDIO && heartRate < 140) {
newState = MotionState.RECOVERY;
}
if (newState !== this.motionState) {
this.motionState = newState;
this.adaptNavToMotion(newState);
}
}
private adaptNavToMotion(state: MotionState): void {
switch (state) {
case MotionState.IDLE:
case MotionState.RECOVERY:
this.navMode = FitnessNavMode.FULL;
this.isExpanded = true;
break;
case MotionState.WARMUP:
this.navMode = FitnessNavMode.MINI;
this.isExpanded = false;
break;
case MotionState.FAT_BURN:
this.navMode = FitnessNavMode.DOT;
this.isExpanded = false;
break;
case MotionState.CARDIO:
this.navMode = FitnessNavMode.HIDDEN;
this.isExpanded = false;
break;
case MotionState.PEAK:
this.navMode = FitnessNavMode.EMERGENCY;
this.isExpanded = false;
break;
}
console.info(`Nav adapted to motion state: ${state}, mode: ${this.navMode}`);
}
private syncLightEffect(heartRate: number): void {
// 心率区间映射到光效
const zones = [
{ max: 100, color: '#4ECDC4', pulse: 6000, intensity: 0.4 },
{ max: 120, color: '#2ECC71', pulse: 4000, intensity: 0.5 },
{ max: 150, color: '#F5A623', pulse: 2500, intensity: 0.7 },
{ max: 170, color: '#FF6B6B', pulse: 1500, intensity: 0.8 },
{ max: 220, color: '#FF0000', pulse: 800, intensity: 1.0 }
];
const zone = zones.find(z => heartRate <= z.max) || zones[zones.length - 1];
this.currentLightColor = zone.color;
this.lightPulseSpeed = zone.pulse;
this.lightIntensity = zone.intensity;
// 同步到全局
AppStorage.setOrCreate('fitness_light_color', zone.color);
AppStorage.setOrCreate('fitness_light_pulse', zone.pulse);
AppStorage.setOrCreate('fitness_light_intensity', zone.intensity);
AppStorage.setOrCreate('current_heart_rate', heartRate);
}
private triggerEmergencyMode(): void {
// 触发紧急模式
this.navMode = FitnessNavMode.EMERGENCY;
AppStorage.setOrCreate('emergency_mode', true);
// 震动警示
try {
import('@kit.SensorServiceKit').then(sensor => {
sensor.vibrator.startVibration({
type: 'time',
duration: 1000
}, { id: 0 });
});
} catch (error) {
console.error('Emergency vibration failed:', error);
}
}
// 手势识别
private onPanGesture(event: GestureEvent): void {
const { offsetX, offsetY } = event;
const threshold = 50;
if (Math.abs(offsetX) > Math.abs(offsetY)) {
// 水平手势
if (offsetX > threshold) {
this.gestureBuffer = 'swipe_right';
this.handleGesture('next');
} else if (offsetX < -threshold) {
this.gestureBuffer = 'swipe_left';
this.handleGesture('prev');
}
} else {
// 垂直手势
if (offsetY < -threshold) {
this.gestureBuffer = 'swipe_up';
this.handleGesture('expand');
} else if (offsetY > threshold) {
this.gestureBuffer = 'swipe_down';
this.handleGesture('collapse');
}
}
}
private handleGesture(gesture: string): void {
this.onGesture?.(gesture);
switch (gesture) {
case 'expand':
if (this.navMode !== FitnessNavMode.EMERGENCY) {
this.navMode = FitnessNavMode.FULL;
this.isExpanded = true;
}
break;
case 'collapse':
this.adaptNavToMotion(this.motionState);
break;
case 'next':
this.cycleNavItem(1);
break;
case 'prev':
this.cycleNavItem(-1);
break;
}
}
private cycleNavItem(direction: number): void {
const currentIndex = this.navItems.findIndex(i => i.id === this.activeItemId);
const newIndex = (currentIndex + direction + this.navItems.length) % this.navItems.length;
this.activeItemId = this.navItems[newIndex].id;
this.onItemSelect?.(this.navItems[newIndex]);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 内容层(由外部传入)
this.contentBuilder()
// 运动感知导航层
if (this.navMode === FitnessNavMode.FULL) {
this.buildFullNav()
} else if (this.navMode === FitnessNavMode.MINI) {
this.buildMiniNav()
} else if (this.navMode === FitnessNavMode.DOT) {
this.buildDotNav()
} else if (this.navMode === FitnessNavMode.HIDDEN) {
this.buildHiddenNav()
} else if (this.navMode === FitnessNavMode.EMERGENCY) {
this.buildEmergencyNav()
}
// 心率光效反馈层
this.buildHeartRateGlow()
}
.width('100%')
.height('100%')
.gesture(
PanGesture()
.onActionUpdate((event: GestureEvent) => {
this.onPanGesture(event);
})
)
}
@Builder
contentBuilder(): void {
Column() {
Text('运动内容区域')
.fontSize(16)
.fontColor('rgba(255,255,255,0.3)')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
// 完整导航(静止/恢复状态)
@Builder
buildFullNav(): void {
Column() {
// 心率指示条
this.buildHeartRateIndicator()
// 导航容器
Column() {
Row({ space: 8 }) {
ForEach(this.navItems, (item: FitnessNavItem) => {
this.buildNavItem(item)
})
}
.width('100%')
.height(72)
.padding({ left: 16, right: 16 })
.justifyContent(FlexAlign.SpaceAround)
}
.width('94%')
.height(80)
.backgroundColor('rgba(30, 30, 40, 0.8)')
.backdropFilter($r('sys.blur.25'))
.borderRadius(24)
.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.3)',
offsetX: 0,
offsetY: -4
})
}
.width('100%')
.height(120)
.padding({ bottom: 12 })
.animation({
duration: 300,
curve: Curve.EaseInOut
})
}
// 迷你导航(热身状态)
@Builder
buildMiniNav(): void {
Row() {
Column() {
// 垂直进度指示
ForEach(this.navItems, (item: FitnessNavItem, index: number) => {
Column() {
Column()
.width(6)
.height(6)
.backgroundColor(item.id === this.activeItemId ?
this.currentLightColor : 'rgba(255,255,255,0.2)')
.borderRadius(3)
.shadow({
radius: item.id === this.activeItemId ? 6 : 0,
color: this.currentLightColor,
offsetX: 0,
offsetY: 0
})
}
.width(24)
.height(32)
.onClick(() => {
this.activeItemId = item.id;
this.onItemSelect?.(item);
})
})
}
.width(32)
.height(200)
.backgroundColor('rgba(30, 30, 40, 0.6)')
.backdropFilter($r('sys.blur.15'))
.borderRadius({ topRight: 16, bottomRight: 16 })
.position({ x: 0, y: '50%' })
.anchor('50%')
}
.width(32)
.height('100%')
}
// 边缘光点(燃脂状态)
@Builder
buildDotNav(): void {
Column() {
// 呼吸光点
Column() {
Column()
.width(12)
.height(12)
.backgroundColor(this.currentLightColor)
.borderRadius(6)
.opacity(0.6 + Math.sin(Date.now() / this.lightPulseSpeed * Math.PI * 2) * 0.4)
.shadow({
radius: 8,
color: this.currentLightColor,
offsetX: 0,
offsetY: 0
})
}
.width(32)
.height(32)
.backgroundColor('rgba(30, 30, 40, 0.4)')
.backdropFilter($r('sys.blur.10'))
.borderRadius(16)
.onClick(() => {
// 点击展开迷你菜单
this.navMode = FitnessNavMode.MINI;
})
}
.width(32)
.height(32)
.position({ x: '100%', y: '50%' })
.anchor('100%')
.margin({ right: 8 })
}
// 隐藏导航(有氧状态)- 仅边缘热区
@Builder
buildHiddenNav(): void {
Column()
.width(20)
.height('100%')
.position({ x: '100%' })
.anchor('100%')
.onClick(() => {
this.navMode = FitnessNavMode.DOT;
})
}
// 紧急导航(峰值状态)
@Builder
buildEmergencyNav(): void {
Column() {
// 紧急停止按钮(大且明显)
Button() {
Column({ space: 4 }) {
Image($r('app.media.ic_stop'))
.width(32)
.height(32)
.fillColor('#FFFFFF')
Text('紧急停止')
.fontSize(14)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
}
.type(ButtonType.Circle)
.width(80)
.height(80)
.backgroundColor('#FF0000')
.shadow({
radius: 20,
color: '#FF0000',
offsetX: 0,
offsetY: 0
})
.onClick(() => {
this.onEmergencyStop?.();
})
.animation({
duration: 500,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
})
.scale({ x: 1.05, y: 1.05 })
// 心率警告
Text(`${this.currentHeartRate} BPM`)
.fontSize(18)
.fontColor('#FF0000')
.fontWeight(FontWeight.Bold)
.margin({ top: 12 })
.shadow({
radius: 10,
color: '#FF0000',
offsetX: 0,
offsetY: 0
})
}
.width('100%')
.height(200)
.position({ x: 0, y: '100%' })
.anchor('100%')
.margin({ bottom: 40 })
}
// 心率指示条
@Builder
buildHeartRateIndicator(): void {
Row() {
// 心率图标
Image($r('app.media.ic_heart'))
.width(16)
.height(16)
.fillColor(this.currentLightColor)
.margin({ right: 8 })
// 心率值
Text(`${this.currentHeartRate}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(this.currentLightColor)
.margin({ right: 4 })
Text('BPM')
.fontSize(10)
.fontColor('rgba(255,255,255,0.5)')
// 心率区间条
Stack() {
Column()
.width('100%')
.height(4)
.backgroundColor('rgba(255,255,255,0.1)')
.borderRadius(2)
Column()
.width(`${Math.min(100, (this.currentHeartRate / 200) * 100)}%`)
.height(4)
.backgroundColor(this.currentLightColor)
.borderRadius(2)
.animation({ duration: 500 })
}
.width(80)
.height(4)
.margin({ left: 12 })
}
.width('100%')
.height(32)
.padding({ left: 20, right: 20 })
.justifyContent(FlexAlign.Center)
}
// 导航项构建
@Builder
buildNavItem(item: FitnessNavItem): void {
Column() {
Stack() {
// 选中背景
if (this.activeItemId === item.id) {
Column()
.width(48)
.height(48)
.backgroundColor(this.currentLightColor)
.opacity(0.2)
.blur(10)
.borderRadius(24)
}
Image(this.activeItemId === item.id ? item.activeIcon : item.icon)
.width(24)
.height(24)
.fillColor(this.activeItemId === item.id ? this.currentLightColor : '#B0B0B0')
}
.width(48)
.height(48)
Text(item.label)
.fontSize(10)
.fontColor(this.activeItemId === item.id ? '#FFFFFF' : 'rgba(255,255,255,0.6)')
.margin({ top: 2 })
}
.opacity(item.requiresStable && this.motionState !== MotionState.IDLE ? 0.4 : 1.0)
.onClick(() => {
if (!item.requiresStable || this.motionState === MotionState.IDLE) {
this.activeItemId = item.id;
this.onItemSelect?.(item);
}
})
}
// 心率光效反馈层
@Builder
buildHeartRateGlow(): void {
Column() {
// 底部心率光晕
Column()
.width('80%')
.height(100)
.backgroundColor(this.currentLightColor)
.blur(80)
.opacity(this.lightIntensity * 0.2)
.position({ x: '50%', y: '100%' })
.anchor('50%')
.animation({
duration: this.lightPulseSpeed,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
})
.scale({ x: 1.1, y: 1.1 })
// 侧边心率脉冲(高强度时)
if (this.motionState === MotionState.CARDIO || this.motionState === MotionState.PEAK) {
Column()
.width(40)
.height('60%')
.backgroundColor(this.currentLightColor)
.blur(60)
.opacity(this.lightIntensity * 0.15)
.position({ x: 0, y: '20%' })
.animation({
duration: this.lightPulseSpeed,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
})
}
}
.width('100%')
.height('100%')
.pointerEvents(PointerEvents.None)
}
}
3.2 实时数据光效可视化(DataLightVisualization.ets)
代码亮点:将抽象的运动数据(心率、配速、卡路里)转化为直观的动态光效。例如,心率变异性(HRV)通过光效脉冲的不规则性可视化,卡路里消耗累积通过光效亮度渐变呈现。这是HarmonyOS 6沉浸光感在健康数据领域的创新应用。
typescript
// entry/src/main/ets/components/DataLightVisualization.ets
import { health } from '@kit.HealthKit';
// 运动数据接口
interface FitnessData {
heartRate: number; // 心率
heartRateVariability: number; // 心率变异性
calories: number; // 卡路里
distance: number; // 距离
pace: number; // 配速
steps: number; // 步数
duration: number; // 运动时长
}
// 光效粒子
interface LightParticle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
color: string;
size: number;
type: 'heart' | 'calorie' | 'step';
}
@Component
export struct DataLightVisualization {
@Prop fitnessData: FitnessData;
@State particles: LightParticle[] = [];
@State calorieGlow: number = 0;
@State stepRipple: number = 0;
@State hrvIrregularity: number = 0;
private particleTimer: number = -1;
private readonly MAX_PARTICLES: number = 50;
aboutToAppear(): void {
this.startParticleSystem();
}
aboutToDisappear(): void {
clearInterval(this.particleTimer);
}
private startParticleSystem(): void {
this.particleTimer = setInterval(() => {
this.updateParticles();
this.emitDataParticles();
}, 100);
}
private emitDataParticles(): void {
// 根据运动数据发射光效粒子
// 心率粒子(每次心跳)
if (Math.random() < this.fitnessData.heartRate / 60 / 10) {
this.particles.push({
x: 0.5 + (Math.random() - 0.5) * 0.2,
y: 0.8,
vx: (Math.random() - 0.5) * 0.01,
vy: -0.02 - Math.random() * 0.02,
life: 1.0,
maxLife: 2.0,
color: this.getHeartRateColor(this.fitnessData.heartRate),
size: 3 + Math.random() * 4,
type: 'heart'
});
}
// 卡路里粒子(累积时)
if (this.fitnessData.calories > 0 && Math.random() < 0.1) {
this.calorieGlow = Math.min(1, this.calorieGlow + 0.01);
this.particles.push({
x: Math.random(),
y: 1.0,
vx: 0,
vy: -0.01,
life: 1.0,
maxLife: 3.0,
color: '#F5A623',
size: 2 + Math.random() * 3,
type: 'calorie'
});
}
// 步数涟漪
if (this.fitnessData.steps > 0 && this.fitnessData.steps % 100 === 0) {
this.stepRipple = 1.0;
}
// 限制粒子数量
if (this.particles.length > this.MAX_PARTICLES) {
this.particles = this.particles.slice(-this.MAX_PARTICLES);
}
}
private updateParticles(): void {
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.05;
p.vy *= 0.98; // 阻力
return p.life > 0;
});
// 衰减累积效果
this.calorieGlow *= 0.99;
this.stepRipple *= 0.95;
// 计算HRV不规则性
this.hrvIrregularity = Math.min(1, this.fitnessData.heartRateVariability / 50);
}
private getHeartRateColor(hr: number): string {
if (hr < 100) return '#4ECDC4';
if (hr < 120) return '#2ECC71';
if (hr < 150) return '#F5A623';
if (hr < 170) return '#FF6B6B';
return '#FF0000';
}
build() {
Stack() {
// 背景层
Column()
.width('100%')
.height('100%')
.backgroundColor('#0a0a0f')
// 卡路里累积光晕
Column()
.width('60%')
.height('40%')
.backgroundColor('#F5A623')
.blur(100)
.opacity(this.calorieGlow * 0.3)
.position({ x: '50%', y: '80%' })
.anchor('50%')
// 步数涟漪
if (this.stepRipple > 0.1) {
Column()
.width(`${this.stepRipple * 100}%`)
.height(`${this.stepRipple * 100}%`)
.backgroundColor('transparent')
.border({
width: 2,
color: `rgba(78, 205, 196, ${this.stepRipple * 0.5})`,
style: BorderStyle.Solid
})
.borderRadius('50%')
.position({ x: '50%', y: '50%' })
.anchor('50%')
.animation({ duration: 100 })
}
// HRV不规则性可视化(光效抖动)
Column()
.width('100%')
.height('100%')
.backgroundColor(this.getHeartRateColor(this.fitnessData.heartRate))
.opacity(this.hrvIrregularity * 0.05)
.blur(50)
.position({
x: (Math.random() - 0.5) * this.hrvIrregularity * 20,
y: (Math.random() - 0.5) * this.hrvIrregularity * 20
})
.animation({ duration: 50 })
// 粒子层
ForEach(this.particles, (particle: LightParticle, index: number) => {
Column()
.width(particle.size * particle.life)
.height(particle.size * particle.life)
.backgroundColor(particle.color)
.blur(particle.size)
.opacity(particle.life * 0.6)
.borderRadius('50%')
.position({
x: `${particle.x * 100}%`,
y: `${particle.y * 100}%`
})
.animation({ duration: 100 })
})
// 数据覆盖层
this.buildDataOverlay()
}
.width('100%')
.height('100%')
}
@Builder
buildDataOverlay(): void {
Column({ space: 16 }) {
// 心率大数字
Row({ space: 8 }) {
Text(`${this.fitnessData.heartRate}`)
.fontSize(72)
.fontWeight(FontWeight.Bold)
.fontColor(this.getHeartRateColor(this.fitnessData.heartRate))
.fontVariant(FontVariant.TabNums)
.shadow({
radius: 20,
color: this.getHeartRateColor(this.fitnessData.heartRate),
offsetX: 0,
offsetY: 0
})
Column() {
Text('BPM')
.fontSize(16)
.fontColor('rgba(255,255,255,0.5)')
// 心率区间指示
Text(this.getHeartRateZone(this.fitnessData.heartRate))
.fontSize(12)
.fontColor(this.getHeartRateColor(this.fitnessData.heartRate))
}
.alignItems(HorizontalAlign.Start)
}
// 其他数据网格
Row({ space: 16 }) {
this.buildDataCard('卡路里', `${Math.floor(this.fitnessData.calories)}`, 'kcal', '#F5A623')
this.buildDataCard('距离', `${(this.fitnessData.distance / 1000).toFixed(2)}`, 'km', '#4ECDC4')
this.buildDataCard('配速', `${this.fitnessData.pace.toFixed(1)}`, '/km', '#9B59B6')
}
// 运动时长
Text(this.formatDuration(this.fitnessData.duration))
.fontSize(24)
.fontColor('rgba(255,255,255,0.8)')
.fontVariant(FontVariant.TabNums)
}
.width('100%')
.height('100%')
.padding(40)
.justifyContent(FlexAlign.Center)
}
@Builder
buildDataCard(label: string, value: string, unit: string, color: string): void {
Column({ space: 4 }) {
Text(label)
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
Row({ space: 4 }) {
Text(value)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(color)
.fontVariant(FontVariant.TabNums)
Text(unit)
.fontSize(12)
.fontColor('rgba(255,255,255,0.4)')
.margin({ top: 8 })
}
}
.width(100)
.height(80)
.backgroundColor('rgba(255,255,255,0.03)')
.backdropFilter($r('sys.blur.10'))
.borderRadius(16)
.border({
width: 1,
color: `${color}33`
})
}
private getHeartRateZone(hr: number): string {
if (hr < 100) return '静息';
if (hr < 120) return '热身';
if (hr < 150) return '燃脂';
if (hr < 170) return '有氧';
return '峰值';
}
private formatDuration(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
}
3.3 训练计划悬浮卡片(WorkoutFloatCard.ets)
代码亮点:在运动过程中,训练计划以悬浮卡片形式呈现,支持语音控制和手势交互。卡片采用玻璃拟态设计,根据当前训练阶段的光效主题动态变化边框颜色,实现"训练即视觉"的沉浸体验。
typescript
// entry/src/main/ets/components/WorkoutFloatCard.ets
// 训练阶段
interface WorkoutPhase {
id: string;
name: string;
duration: number; // 秒
targetHeartRate: number; // 目标心率
description: string;
color: string;
}
@Component
export struct WorkoutFloatCard {
@Prop phases: WorkoutPhase[];
@Prop currentPhaseIndex: number = 0;
@Prop currentPhaseTime: number = 0; // 当前阶段已进行时间
@State isExpanded: boolean = false;
@State currentLightColor: string = '#4ECDC4';
private readonly phaseProgress: number = 0;
aboutToAppear(): void {
// 监听光效变化
AppStorage.watch('fitness_light_color', (color: string) => {
this.currentLightColor = color;
});
}
private getPhaseProgress(): number {
if (this.phases.length === 0) return 0;
const phase = this.phases[this.currentPhaseIndex];
return Math.min(1, this.currentPhaseTime / phase.duration);
}
private getTotalProgress(): number {
if (this.phases.length === 0) return 0;
let totalDuration = 0;
let completedDuration = 0;
this.phases.forEach((phase, index) => {
totalDuration += phase.duration;
if (index < this.currentPhaseIndex) {
completedDuration += phase.duration;
}
});
completedDuration += this.currentPhaseTime;
return completedDuration / totalDuration;
}
build() {
Stack() {
// 悬浮卡片
Column({ space: 12 }) {
// 顶部拖拽条
Column()
.width(40)
.height(4)
.backgroundColor('rgba(255,255,255,0.3)')
.borderRadius(2)
.margin({ top: 8 })
// 阶段信息(始终显示)
Row({ space: 12 }) {
// 当前阶段指示
Stack() {
Column()
.width(48)
.height(48)
.backgroundColor(this.currentLightColor)
.opacity(0.2)
.blur(10)
.borderRadius(24)
Text(`${this.currentPhaseIndex + 1}`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.currentLightColor)
}
.width(48)
.height(48)
Column({ space: 4 }) {
Text(this.phases[this.currentPhaseIndex]?.name ?? '准备')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
Text(`${this.formatTime(this.currentPhaseTime)} / ${this.formatTime(this.phases[this.currentPhaseIndex]?.duration ?? 0)}`)
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 展开/收起按钮
Button() {
Image(this.isExpanded ? $r('app.media.ic_collapse') : $r('app.media.ic_expand'))
.width(20)
.height(20)
.fillColor('rgba(255,255,255,0.6)')
}
.type(ButtonType.Circle)
.backgroundColor('transparent')
.width(36)
.height(36)
.onClick(() => {
this.isExpanded = !this.isExpanded;
})
}
.width('100%')
.padding({ left: 16, right: 16 })
// 阶段进度条
Stack() {
Column()
.width('100%')
.height(4)
.backgroundColor('rgba(255,255,255,0.1)')
.borderRadius(2)
Column()
.width(`${this.getPhaseProgress() * 100}%`)
.height(4)
.backgroundColor(this.currentLightColor)
.borderRadius(2)
.shadow({
radius: 4,
color: this.currentLightColor,
offsetX: 0,
offsetY: 0
})
.animation({ duration: 500 })
}
.width('100%')
.height(4)
.padding({ left: 16, right: 16 })
// 展开后的详细内容
if (this.isExpanded) {
this.buildExpandedContent()
}
// 总进度指示
Row({ space: 8 }) {
Text('总进度')
.fontSize(11)
.fontColor('rgba(255,255,255,0.4)')
Stack() {
Column()
.width('100%')
.height(2)
.backgroundColor('rgba(255,255,255,0.1)')
.borderRadius(1)
Column()
.width(`${this.getTotalProgress() * 100}%`)
.height(2)
.backgroundColor('rgba(255,255,255,0.4)')
.borderRadius(1)
}
.width(60)
.height(2)
Text(`${Math.floor(this.getTotalProgress() * 100)}%`)
.fontSize(11)
.fontColor('rgba(255,255,255,0.4)')
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
.justifyContent(FlexAlign.SpaceBetween)
}
.width('92%')
.backgroundColor('rgba(30, 30, 40, 0.8)')
.backdropFilter($r('sys.blur.25'))
.borderRadius(20)
.border({
width: 1,
color: `${this.currentLightColor}33`
})
.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.3)',
offsetX: 0,
offsetY: 4
})
.animation({
duration: 300,
curve: Curve.EaseInOut
})
}
.width('100%')
.height('auto')
.position({ x: 0, y: 100 })
}
@Builder
buildExpandedContent(): void {
Column({ space: 12 }) {
// 阶段列表
List({ space: 8 }) {
ForEach(this.phases, (phase: WorkoutPhase, index: number) => {
ListItem() {
Row({ space: 12 }) {
// 阶段状态指示
Column()
.width(8)
.height(8)
.backgroundColor(
index < this.currentPhaseIndex ? '#2ECC71' :
index === this.currentPhaseIndex ? this.currentLightColor :
'rgba(255,255,255,0.2)'
)
.borderRadius(4)
Column({ space: 2 }) {
Text(phase.name)
.fontSize(14)
.fontColor(index === this.currentPhaseIndex ? '#FFFFFF' : 'rgba(255,255,255,0.6)')
.fontWeight(index === this.currentPhaseIndex ? FontWeight.Medium : FontWeight.Regular)
Text(`${this.formatTime(phase.duration)} · 目标心率 ${phase.targetHeartRate} BPM`)
.fontSize(11)
.fontColor('rgba(255,255,255,0.4)')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (index < this.currentPhaseIndex) {
Image($r('app.media.ic_check'))
.width(16)
.height(16)
.fillColor('#2ECC71')
}
}
.width('100%')
.padding(12)
.backgroundColor(index === this.currentPhaseIndex ?
`${this.currentLightColor}15` : 'transparent')
.borderRadius(12)
}
})
}
.width('100%')
.height(200)
.padding({ left: 12, right: 12 })
// 当前阶段描述
Text(this.phases[this.currentPhaseIndex]?.description ?? '')
.fontSize(13)
.fontColor('rgba(255,255,255,0.6)')
.width('100%')
.padding({ left: 16, right: 16 })
.lineHeight(20)
// 快捷操作
Row({ space: 12 }) {
Button('跳过此阶段')
.width('48%')
.height(40)
.backgroundColor('rgba(255,255,255,0.05)')
.fontColor('rgba(255,255,255,0.6)')
.fontSize(13)
.borderRadius(10)
Button('延长30秒')
.width('48%')
.height(40)
.backgroundColor(`${this.currentLightColor}30`)
.fontColor(this.currentLightColor)
.fontSize(13)
.borderRadius(10)
}
.width('100%')
.padding({ left: 16, right: 16 })
}
.width('100%')
.animation({
duration: 250,
curve: Curve.EaseInOut
})
}
private formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
四、主页面集成
4.1 运动主页面(WorkoutPage.ets)
typescript
// entry/src/main/ets/pages/WorkoutPage.ets
import { MotionAwareFloatNav, MotionState } from '../components/MotionAwareFloatNav';
import { DataLightVisualization } from '../components/DataLightVisualization';
import { WorkoutFloatCard } from '../components/WorkoutFloatCard';
@Entry
@Component
struct WorkoutPage {
@State currentTab: string = 'dashboard';
@State isWorkoutActive: boolean = false;
@State workoutStartTime: number = 0;
@State currentPhaseIndex: number = 0;
@State currentPhaseTime: number = 0;
// 模拟运动数据
@State fitnessData: {
heartRate: number;
heartRateVariability: number;
calories: number;
distance: number;
pace: number;
steps: number;
duration: number;
} = {
heartRate: 75,
heartRateVariability: 30,
calories: 0,
distance: 0,
pace: 0,
steps: 0,
duration: 0
};
// 训练计划
private workoutPlan = [
{
id: 'warmup',
name: '热身',
duration: 300,
targetHeartRate: 110,
description: '轻松慢跑,激活身体',
color: '#2ECC71'
},
{
id: 'sprint1',
name: '冲刺训练 1',
duration: 180,
targetHeartRate: 160,
description: '全力冲刺,保持高心率',
color: '#FF6B6B'
},
{
id: 'recovery1',
name: '主动恢复',
duration: 120,
targetHeartRate: 130,
description: '慢跑恢复,调整呼吸',
color: '#4ECDC4'
},
{
id: 'sprint2',
name: '冲刺训练 2',
duration: 180,
targetHeartRate: 165,
description: '再次冲刺,突破极限',
color: '#FF6B6B'
},
{
id: 'cooldown',
name: '冷身',
duration: 300,
targetHeartRate: 100,
description: '慢走放松,拉伸肌肉',
color: '#9B59B6'
}
];
private dataTimer: number = -1;
aboutToAppear(): void {
this.startDataSimulation();
}
aboutToDisappear(): void {
clearInterval(this.dataTimer);
}
private startDataSimulation(): void {
this.dataTimer = setInterval(() => {
if (this.isWorkoutActive) {
// 更新运动数据
this.fitnessData.duration++;
this.fitnessData.calories += 0.15;
this.fitnessData.distance += 2.5;
this.fitnessData.steps += 2;
this.fitnessData.pace = 6 + Math.random() * 2;
this.fitnessData.heartRate = 75 + Math.sin(Date.now() / 10000) * 40 + Math.random() * 10;
this.fitnessData.heartRateVariability = 20 + Math.random() * 30;
// 更新阶段时间
this.currentPhaseTime++;
const currentPhase = this.workoutPlan[this.currentPhaseIndex];
if (currentPhase && this.currentPhaseTime >= currentPhase.duration) {
this.currentPhaseIndex++;
this.currentPhaseTime = 0;
}
}
}, 1000);
}
build() {
Stack() {
// 数据光效可视化层
DataLightVisualization({
fitnessData: this.fitnessData
})
// 训练计划卡片(运动中显示)
if (this.isWorkoutActive) {
WorkoutFloatCard({
phases: this.workoutPlan,
currentPhaseIndex: this.currentPhaseIndex,
currentPhaseTime: this.currentPhaseTime
})
}
// 主内容区
Column() {
if (this.currentTab === 'dashboard') {
this.buildDashboard()
} else if (this.currentTab === 'workout') {
this.buildWorkoutView()
} else if (this.currentTab === 'history') {
this.buildHistoryView()
}
}
.width('100%')
.height('100%')
.padding({ top: 40, bottom: 120 })
// 运动感知悬浮导航
MotionAwareFloatNav({
onItemSelect: (item) => {
this.currentTab = item.id;
},
onEmergencyStop: () => {
this.isWorkoutActive = false;
},
onGesture: (gesture) => {
console.info(`Gesture: ${gesture}`);
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#0a0a0f')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
@Builder
buildDashboard(): void {
Column({ space: 24 }) {
// 今日概览
Text('今日运动')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
// 快速开始按钮
Button() {
Column({ space: 8 }) {
Image($r('app.media.ic_play'))
.width(40)
.height(40)
.fillColor('#FFFFFF')
Text(this.isWorkoutActive ? '运动中' : '开始训练')
.fontSize(18)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
}
}
.width(200)
.height(200)
.backgroundColor(this.isWorkoutActive ? '#FF6B6B' : '#4ECDC4')
.borderRadius(100)
.shadow({
radius: 30,
color: this.isWorkoutActive ? '#FF6B6B' : '#4ECDC4',
offsetX: 0,
offsetY: 10
})
.onClick(() => {
this.isWorkoutActive = !this.isWorkoutActive;
if (this.isWorkoutActive) {
this.workoutStartTime = Date.now();
}
})
// 本周统计
Row({ space: 16 }) {
this.buildStatCard('本周运动', '3次', '#4ECDC4')
this.buildStatCard('消耗热量', '1,240', '#F5A623')
this.buildStatCard('运动时长', '4.5h', '#9B59B6')
}
}
.width('100%')
.height('100%')
.padding(24)
}
@Builder
buildWorkoutView(): void {
Column({ space: 20 }) {
Text('训练计划')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
List({ space: 12 }) {
ForEach(this.workoutPlan, (phase, index) => {
ListItem() {
Row({ space: 12 }) {
Column()
.width(48)
.height(48)
.backgroundColor(`${phase.color}20`)
.borderRadius(12)
.border({
width: 2,
color: phase.color
})
Column({ space: 4 }) {
Text(phase.name)
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
Text(`${this.formatTime(phase.duration)} · ${phase.description}`)
.fontSize(13)
.fontColor('rgba(255,255,255,0.5)')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor('rgba(255,255,255,0.03)')
.borderRadius(16)
}
})
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding(24)
}
@Builder
buildHistoryView(): void {
Column() {
Text('历史记录')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width('100%')
.height('100%')
.padding(24)
}
@Builder
buildStatCard(label: string, value: string, color: string): void {
Column({ space: 8 }) {
Text(value)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(color)
.fontVariant(FontVariant.TabNums)
Text(label)
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
}
.width(100)
.height(100)
.backgroundColor('rgba(255,255,255,0.03)')
.backdropFilter($r('sys.blur.10'))
.borderRadius(16)
.justifyContent(FlexAlign.Center)
}
private formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
五、关键技术总结
5.1 运动状态-导航-光效协同矩阵
| 运动状态 | 心率区间 | 导航模式 | 光效特征 | 交互策略 |
|---|---|---|---|---|
| 静止 | <100 | 完整展开 | 青绿慢呼吸 | 全功能可用 |
| 热身 | 100-120 | 侧边迷你 | 绿色温和脉冲 | 基础功能 |
| 燃脂 | 120-150 | 边缘光点 | 橙色活跃闪烁 | 手势优先 |
| 有氧 | 150-170 | 全隐藏 | 红色急促警示 | 仅手势 |
| 峰值 | >170 | 仅紧急停止 | 深红警报 | 紧急优先 |
| 恢复 | 下降中 | 逐步浮现 | 舒缓渐变 | 逐步恢复 |
5.2 生理数据可视化映射
心率 -> 光效颜色 + 脉冲速度
└─ 静息(青) -> 热身(绿) -> 燃脂(橙) -> 有氧(红) -> 峰值(深红)
HRV -> 光效不规则性
└─ 高HRV = 光效平滑规律
└─ 低HRV = 光效抖动不规则
卡路里 -> 底部光晕累积亮度
└─ 持续累积 = 金色光晕逐渐增强
步数 -> 涟漪扩散动画
└─ 每100步 = 一次青色涟漪
5.3 性能与功耗优化
typescript
// 光效性能优化
class LightEffectOptimizer {
// 根据电池状态调整光效质量
static adjustQuality(batteryLevel: number): void {
if (batteryLevel < 20) {
// 低电量:降低粒子数量,停止呼吸动画
AppStorage.setOrCreate('light_quality', 'low');
AppStorage.setOrCreate('particle_count', 20);
} else if (batteryLevel < 50) {
AppStorage.setOrCreate('light_quality', 'medium');
AppStorage.setOrCreate('particle_count', 35);
} else {
AppStorage.setOrCreate('light_quality', 'high');
AppStorage.setOrCreate('particle_count', 50);
}
}
// 运动时降低UI刷新率以节省电量
static setWorkoutRefreshRate(isWorkout: boolean): void {
const rate = isWorkout ? 30 : 60; // 运动时30fps,静止时60fps
// 调用系统API设置帧率
}
}
六、调试与适配建议
- 传感器校准:不同设备的加速度计灵敏度不同,需在真机上校准运动检测阈值
- 心率设备兼容:测试多款HarmonyOS手表的心率数据同步延迟,优化光效同步
- 户外可视性:强光环境下增强光效对比度,或自动切换为高对比度模式
- 防水场景:游泳等水下运动时,导航应切换为物理按键模式,关闭触控
七、结语
HarmonyOS 6的悬浮导航与沉浸光感特性,为运动健康应用开发带来了从"数据展示"到"感官体验"的范式转变。通过"光影律动"的实战案例,我们展示了如何:
- 构建运动感知的自适应导航,根据心率区间自动调整交互形态,防止运动中误触
- 实现生理数据到光效的实时映射,将抽象数字转化为直观的视觉反馈
- 利用玻璃拟态和动态光效创造沉浸式训练环境,提升运动专注度
- 通过多设备传感器融合,打造"身体即界面"的自然交互体验
这些技术不仅提升了健身应用的专业性,更让科技真正服务于人的健康生活。期待HarmonyOS生态中的健康开发者们能够利用这些新特性,创造出更多激励用户、守护健康的优秀应用。
转载自:https://blog.csdn.net/u014727709/article/details/160311451
欢迎 👍点赞✍评论⭐收藏,欢迎指正