鸿蒙NEXT开发案例:转盘

【1】引言(完整代码在最后面)
在鸿蒙NEXT系统中,开发一个有趣且实用的转盘应用不仅可以提升用户体验,还能展示鸿蒙系统的强大功能。本文将详细介绍如何使用鸿蒙NEXT系统开发一个转盘应用,涵盖从组件定义到用户交互的完整过程。
【2】环境准备
电脑系统:windows 10
开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806
工程版本:API 12
真机:mate60 pro
语言:ArkTS、ArkUI
【3】难点分析
1. 扇形路径的计算
难点:创建扇形的路径需要精确计算起始点、结束点和弧线参数。尤其是涉及到三角函数的使用,初学者可能会对如何将角度转换为坐标感到困惑。
解决方案:可以通过绘制简单的示意图来帮助理解扇形的构造,并在代码中添加详细注释,解释每一步的计算过程。
2. 动态角度计算
难点:在转盘旋转时,需要根据单元格的比例动态计算每个单元格的角度和旋转角度。这涉及到累加和比例计算,可能会导致逻辑错误。
解决方案:使用数组的 reduce 方法来计算总比例,并在计算每个单元格的角度时,确保逻辑清晰。可以通过单元测试来验证每个单元格的角度是否正确。
3. 动画效果的实现
难点:实现转盘的旋转动画需要对动画的持续时间、曲线和结束后的状态进行管理。初学者可能会对如何控制动画的流畅性和效果感到困惑。
解决方案:可以参考鸿蒙NEXT的动画文档,了解不同的动画效果和参数设置。通过逐步调试,观察动画效果并进行调整。
4. 用户交互的处理
难点:处理用户点击事件,尤其是在动画进行时,如何禁用按钮以防止重复点击,可能会导致状态管理的复杂性。
解决方案:在按钮的点击事件中,使用状态变量(如 isAnimating)来控制按钮的可用性,并在动画结束后恢复按钮的状态。
5. 组件的状态管理
难点:在多个组件之间传递状态(如当前选中的单元格、转盘的角度等)可能会导致状态管理混乱。
解决方案:使用状态管理工具(如 @State 和 @Trace)来确保状态的统一管理,并在需要的地方进行状态更新,保持组件之间的解耦。
【完整代码】
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | import { CounterComponent, CounterType } from '@kit.ArkUI' ; // 导入计数器组件和计数器类型 // 定义扇形组件 @Component struct Sector { @Prop radius: number; // 扇形的半径 @Prop angle: number; // 扇形的角度 @Prop color: string; // 扇形的颜色 // 创建扇形路径的函数 createSectorPath(radius: number, angle: number): string { const centerX = radius / 2; // 计算扇形中心的X坐标 const centerY = radius / 2; // 计算扇形中心的Y坐标 const startX = centerX; // 扇形起始点的X坐标 const startY = centerY - radius; // 扇形起始点的Y坐标 const halfAngle = angle / 4; // 计算半个角度 // 计算扇形结束点1的坐标 const endX1 = centerX + radius * Math.cos((halfAngle * Math.PI) / 180); const endY1 = centerY - radius * Math.sin((halfAngle * Math.PI) / 180); // 计算扇形结束点2的坐标 const endX2 = centerX + radius * Math.cos((-halfAngle * Math.PI) / 180); const endY2 = centerY - radius * Math.sin((-halfAngle * Math.PI) / 180); // 判断是否为大弧 const largeArcFlag = angle / 2 > 180 ? 1 : 0; const sweepFlag = 1; // 设置弧线方向为顺时针 // 生成SVG路径命令 const pathCommands = `M${startX} ${startY} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${endX1} ${endY1} L${centerX} ${centerY} L${endX2} ${endY2} A${radius} ${radius} 0 ${largeArcFlag} ${1 - sweepFlag} ${startX} ${startY} Z`; return pathCommands; // 返回路径命令 } // 构建扇形组件 build() { Stack() { // 创建第一个扇形路径 Path() .width(`${ this .radius}px`) // 设置宽度为半径 .height(`${ this .radius}px`) // 设置高度为半径 .commands( this .createSectorPath( this .radius, this .angle)) // 设置路径命令 .fillOpacity(1) // 设置填充透明度 .fill( this .color) // 设置填充颜色 .strokeWidth(0) // 设置边框宽度为0 .rotate({ angle: this .angle / 4 - 90 }); // 旋转扇形 // 创建第二个扇形路径 Path() .width(`${ this .radius}px`) // 设置宽度为半径 .height(`${ this .radius}px`) // 设置高度为半径 .commands( this .createSectorPath( this .radius, this .angle)) // 设置路径命令 .fillOpacity(1) // 设置填充透明度 .fill( this .color) // 设置填充颜色 .strokeWidth(0) // 设置边框宽度为0 .rotate({ angle: 180 - ( this .angle / 4 - 90) }); // 旋转扇形 } } } // 定义单元格类 @ObservedV2 class Cell { @Trace angle: number = 0; // 扇形的角度 @Trace title: string; // 当前格子的标题 @Trace color: string; // 背景颜色 @Trace rotate: number = 0; // 在转盘要旋转的角度 angleStart: number = 0; // 轮盘所在区间的起始 angleEnd: number = 0; // 轮盘所在区间的结束 proportion: number = 0; // 所占比例 // 构造函数 constructor(proportion: number, title: string, color: string) { this .proportion = proportion; // 设置比例 this .title = title; // 设置标题 this .color = color; // 设置颜色 } } // 定义转盘组件 @Entry @Component struct Wheel { @State cells: Cell[] = []; // 存储单元格的数组 @State wheelWidth: number = 600; // 转盘的宽度 @State currentAngle: number = 0; // 当前转盘的角度 @State selectedName: string = "" ; // 选中的名称 isAnimating: boolean = false ; // 动画状态 colorIndex: number = 0; // 颜色索引 colorPalette: string[] = [ // 颜色调色板 "#26c2ff" , "#978efe" , "#c389fe" , "#ff85bd" , "#ff7051" , "#fea800" , "#ffcf18" , "#a9c92a" ]; // 组件即将出现时调用 aboutToAppear(): void { // 初始化单元格 this .cells.push( new Cell(1, "跑步" , this .colorPalette[ this .colorIndex++ % this .colorPalette.length])); this .cells.push( new Cell(2, "跳绳" , this .colorPalette[ this .colorIndex++ % this .colorPalette.length])); this .cells.push( new Cell(1, "唱歌" , this .colorPalette[ this .colorIndex++ % this .colorPalette.length])); this .cells.push( new Cell(4, "跳舞" , this .colorPalette[ this .colorIndex++ % this .colorPalette.length])); this .calculateAngles(); // 计算角度 } // 计算每个单元格的角度 private calculateAngles() { // 根据比例计算总比例 const totalProportion = this .cells.reduce((sum, cell) => sum + cell.proportion, 0); this .cells.forEach(cell => { cell.angle = (cell.proportion * 360) / totalProportion; // 计算每个单元格的角度 }); let cumulativeAngle = 0; // 累计角度 this .cells.forEach(cell => { cell.angleStart = cumulativeAngle; // 设置起始角度 cumulativeAngle += cell.angle; // 更新累计角度 cell.angleEnd = cumulativeAngle; // 设置结束角度 cell.rotate = cumulativeAngle - (cell.angle / 2); // 计算旋转角度 }); } // 构建转盘组件 build() { Column() { Row() { Text( '转盘' ).fontSize(20).fontColor( "#0b0e15" ); // 显示转盘标题 }.width( '100%' ).height(44).justifyContent(FlexAlign.Center); // 设置行的宽度和高度 // 显示当前状态 Text( this .isAnimating ? '旋转中' : `${ this .selectedName}`).fontSize(20).fontColor( "#0b0e15" ).height(40); Stack() { Stack() { // 遍历每个单元格并绘制扇形 ForEach( this .cells, (cell: Cell) => { Stack() { Sector({ radius: lpx2px( this .wheelWidth) / 2, angle: cell.angle, color: cell.color }); // 创建扇形 Text(cell.title).fontColor(Color.White).margin({ bottom: `${ this .wheelWidth / 1.4}lpx` }); // 显示单元格标题 }.width( '100%' ).height( '100%' ).rotate({ angle: cell.rotate }); // 设置宽度和高度,并旋转 }); } .borderRadius( '50%' ) // 设置圆角 .backgroundColor(Color.Gray) // 设置背景颜色 .width(`${ this .wheelWidth}lpx`) // 设置转盘宽度 .height(`${ this .wheelWidth}lpx`) // 设置转盘高度 .rotate({ angle: this .currentAngle }); // 旋转转盘 // 创建指针 Polygon({ width: 20, height: 10 }) .points([[0, 0], [10, -20], [20, 0]]) // 设置指针的点 .fill( "#d72b0b" ) // 设置指针颜色 .height(20) // 设置指针高度 .margin({ bottom: '140lpx' }); // 设置指针底部边距 // 创建开始按钮 Button( '开始' ) .fontColor( "#c53a2c" ) // 设置按钮字体颜色 .borderWidth(10) // 设置按钮边框宽度 .borderColor( "#dd2218" ) // 设置按钮边框颜色 .backgroundColor( "#fde427" ) // 设置按钮背景颜色 .width( '200lpx' ) // 设置按钮宽度 .height( '200lpx' ) // 设置按钮高度 .borderRadius( '50%' ) // 设置按钮为圆形 .clickEffect({ level: ClickEffectLevel.LIGHT }) // 设置点击效果 .onClick(() => { // 点击按钮时的回调函数 if ( this .isAnimating) { // 如果正在动画中,返回 return ; } this .selectedName = "" ; // 清空选中的名称 this .isAnimating = true ; // 设置动画状态为正在动画 animateTo({ // 开始动画 duration: 5000, // 动画持续时间为5000毫秒 curve: Curve.EaseInOut, // 动画曲线为缓入缓出 onFinish: () => { // 动画完成后的回调 this .currentAngle %= 360; // 保持当前角度在0到360之间 for (const cell of this .cells) { // 遍历每个单元格 // 检查当前角度是否在单元格的角度范围内 if (360 - this .currentAngle >= cell.angleStart && 360 - this .currentAngle <= cell.angleEnd) { this .selectedName = cell.title; // 设置选中的名称为当前单元格的标题 break ; // 找到后退出循环 } } this .isAnimating = false ; // 设置动画状态为未动画 }, }, () => { // 动画进行中的回调 this .currentAngle += (360 * 5 + Math.floor(Math.random() * 360)); // 更新当前角度,增加随机旋转 }); }); } // 创建滚动区域 Scroll() { Column() { // 遍历每个单元格,创建输入框和计数器 ForEach( this .cells, (item: Cell, index: number) => { Row() { // 创建文本输入框,显示单元格标题 TextInput({ text: item.title }) .layoutWeight(1) // 设置输入框占据剩余空间 .onChange((value) => { // 输入框内容变化时的回调 item.title = value; // 更新单元格标题 }); // 创建计数器组件 CounterComponent({ options: { type: CounterType.COMPACT, // 设置计数器类型为紧凑型 numberOptions: { label: `当前占比`, // 设置计数器标签 value: item.proportion, // 设置计数器初始值 min: 1, // 设置最小值 max: 100, // 设置最大值 step: 1, // 设置步长 onChange: (value: number) => { // 计数器值变化时的回调 item.proportion = value; // 更新单元格的比例 this .calculateAngles(); // 重新计算角度 } } } }); // 创建删除按钮 Button( '删除' ).onClick(() => { this .cells.splice(index, 1); // 从单元格数组中删除当前单元格 this .calculateAngles(); // 重新计算角度 }); }.width( '100%' ).justifyContent(FlexAlign.SpaceBetween) // 设置行的宽度和内容对齐方式 .padding({ left: 40, right: 40 }); // 设置左右内边距 }); }.layoutWeight(1); // 设置滚动区域占据剩余空间 }.layoutWeight(1) // 设置滚动区域占据剩余空间 .margin({ top: 20, bottom: 20 }) // 设置上下外边距 .align(Alignment.Top); // 设置对齐方式为顶部对齐 // 创建添加新内容按钮 Button( '添加新内容' ).onClick(() => { // 向单元格数组中添加新单元格 this .cells.push( new Cell(1, "新内容" , this .colorPalette[ this .colorIndex++ % this .colorPalette.length])); this .calculateAngles(); // 重新计算角度 }).margin({ top: 20, bottom: 20 }); // 设置按钮的上下外边距 } .height( '100%' ) // 设置组件高度为100% .width( '100%' ) // 设置组件宽度为100% .backgroundColor( "#f5f8ff" ); // 设置组件背景颜色 } } |
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库