• [技术干货] 给Web开发者的HarmonyOS指南01-文本样式
    给Web开发者的HarmonyOS指南01-文本样式本系列教程适合 HarmonyOS 初学者,为那些熟悉用 HTML 与 CSS 语法的 Web 前端开发者准备的。本系列教程会将 HTML/CSS 代码片段替换为等价的 HarmonyOS/ArkUI 代码。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15页面结构 HTML 与 ArkUI在 Web 开发中,HTML 文档结构由<html>、<head>和<body>等标签组成,其中<body>标签包含了页面中所有可见的内容。而在 HarmonyOS 的 ArkUI 框架中,使用@Entry和@Component装饰器定义组件,并通过build()方法定义页面内容。基本结构对比HTML文档结构:<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>页面标题</title> </head> <body> <!-- 页面内容放这里 --> </body> </html> ArkUI组件结构:@Entry @Component struct MyComponent { build() { // 页面内容放这里 } } 文本处理对比在Web开发中,我们习惯使用各种HTML标签来表示不同语义的文本内容,例如标题、段落、强调等。而在HarmonyOS的ArkUI中,文本处理方式有所不同,主要依赖 Text 组件和 Span 组件。基本概念对比HTML概念HarmonyOS/ArkUI概念语义化标签 (h1-h6, p等)无需语义化标签,统一使用Text组件标签嵌套组件链式调用和嵌套样式通过CSS设置样式通过链式API设置内联样式直接在组件后链式调用样式方法文本显示对比下面通过具体示例对比HTML和ArkUI的文本显示方式:1. 标题和段落HTML代码:<h1>我是一级标题</h1> <h2>我是二级标题</h2> <h3>我是三级标题</h3> <h4>我是四级标题</h4> <h5>我是五级标题</h5> <h6>我是六级标题</h6> <p>我是一个段落<br>我被换行了</p> ArkUI代码:// 鸿蒙系统中没有语义化标签的概念 // 所有文本都使用Text组件,通过样式区分不同级别 Text('我是一级标题') .fontSize(32) // 通过字体大小区分标题级别 .fontWeight(FontWeight.Bold) Text('我是二级标题') .fontSize(24) .fontWeight(FontWeight.Bold) // 以此类推... // 段落和换行 Text('我是一个段落\n我被换行了') // 注意:在ArkUI中使用\n实现换行,而不是<br>标签 2. 文本样式HTML代码:<p>我是<strong>加粗</strong>的</p> <p>我是<em>倾斜</em>的</p> <p>我是<del>删除线</del>的</p> <p>我是<ins>下划线</ins>的</p> ArkUI代码:// 在ArkUI中,样式化的文本使用Text和Span组合实现 Text() { Span('我是') Span('加粗') .fontWeight(FontWeight.Bold) // 对应HTML的<strong> Span('的文本') } Text() { Span('我是') Span('倾斜') .fontStyle(FontStyle.Italic) // 对应HTML的<em> Span('的文本') } Text() { Span('我是') Span('删除线') .decoration({ type: TextDecorationType.LineThrough }) // 对应HTML的<del> Span('的文本') } Text() { Span('我是') Span('下划线') .decoration({ type: TextDecorationType.Underline }) // 对应HTML的<ins> Span('的文本') } 布局容器在HTML中,我们使用 <div> 作为通用容器来组织内容。在ArkUI中,主要使用 Column 和 Row 等容器。HTML代码:<div class="column"> <!-- 内容放这里 --> </div> <style> * { margin: 0; padding: 0; /* 为了与 ArkUI 盒子模型保持一致,所有 HTML 元素的 CSS 盒模型被设置为 border-box */ box-sizing: border-box; } .column{ display: flex; flex-direction: column; gap: 10px; width: 100%; height: 100%; align-items: center; } </style> ArkUI代码:// 默认为纵向排列的容器,类似于CSS的flex-direction: column Column({ space: 10 }) { // space参数设置子组件之间的间距,类似CSS的gap // 内容放这里 } .width('100%') // 设置宽度,链式API调用 .height('100%') // 设置高度 .alignItems(HorizontalAlign.Center) // 水平对齐方式,类似CSS的align-items 关键区别总结组件化思维:HTML使用标签表示不同语义ArkUI使用组件表示UI元素,不强调语义样式应用方式:HTML/CSS分离内容和样式ArkUI使用链式API直接在组件上设置样式布局方式:HTML依赖CSS盒模型和FlexboxArkUI内置容器组件如Column、Row实现布局语法结构:HTML使用开闭标签和属性ArkUI使用TypeScript语法和方法链学习建议理解组件化思维:将HTML标签概念转变为组件概念掌握ArkUI基础组件:Text:文本组件Span:文本片段Column:纵向容器Row:横向容器链式API调用习惯:样式设置通过链式方法调用而非CSS属性布局思维转变:使用容器组件的嵌套来实现复杂布局总结作为Web开发者,迁移到HarmonyOS开发需要转变思维模式,从标签和CSS的分离到组件和链式API的结合。虽然语法不同,但概念是相通的。只要掌握了基本对应关系,Web开发者能够快速适应HarmonyOS开发。希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的 👍点赞、💬评论、🌟收藏 支持。
  • [技术干货] 鸿蒙开发 HarmonyOS DevEco Studio 常用快捷键
    前言做 HarmonyOS 鸿蒙开发离不开 DevEco Studio 开发工具, DevEco Studio 是基于 IntelliJ IDEA Community 开源版本打造,所以默认的快捷键其实继承于 IntelliJ IDEA 。熟悉 DevEco Studio 的快捷键能提升开发效率和开发体验。下面将详细列出 DevEco Studio 一些常用的快捷键,由黑马程序员整理,希望对大家有帮助,也欢迎大家补充或修正。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15一、编辑快捷键(Win)快捷键(Mac)英文说明中文说明Alt + J^GFind Next / Add Selection for Next Occurrence选择相同词,设置多个光标。(常用,批量选中)Alt + 1⌘1Project显示 或 隐藏 项目区。(常用)Alt + 4⌘4Structure显示 或 隐藏 Run。(常用)Ctrl + E⌘ERecent Files最近的文件(常用,切换文件、切换面板,强烈推荐)Ctrl + P⌘PParameter Info展示方法的参数信息。(常用,类型提示神器)Ctrl + Q无Quick Documentation展示组件的 API 说明文档。(常用,查文档神器)Ctrl + Alt + L⌥⌘LReformat Code格式化代码 。(推荐设置保存自动格式化)Shift + Enter⇧↩Complete Current Statement换行输入。(常用,换行添加新属性)Ctrl + 单击 / Ctrl + B⌘单击 / ⌘BGo to Declaration or Usages跳转源码、跳转文件。(常用,强烈推荐)Ctrl + Alt + T⌥⌘TSurround with…自动生成具有环绕性质的代码。(推荐,生成 if…else,try…catch 等代码块)Ctrl + /⌘/Comment with Line Comment单行注释 //(常用)Ctrl + Shift + /⌥⌘/Comment with Block Comment代码块注释 /**/(常用)Tab / Shift + TabTab / ⇧TabIndent/Unindent Selected Lines缩进或者不缩进一次所选择的代码段。(常用)Ctrl + X⌘XCut剪切选中代码、剪切行、删除行。 (常用)Ctrl + C⌘CCopy复制选中代码、复制行。 (常用)Ctrl + D⌘DDuplicate Line or Selection复印选中代码、复印行。(常用)Ctrl + V⌘VPaste粘贴代码。(常用)Ctrl + Shift + V⇧⌘VPaste from History…剪贴板,复制过的内容都在这里。(推荐)Ctrl + Z⌘ZUndo撤消。(常用)Ctrl + Shift + Z / Ctrl + Y⇧⌘ZRedo重做。Ctrl + Shift + J^⇧JJoin Lines把下一行的代码接续到当前的代码行。(常用,合并行)Ctrl + Shift + U⇧⌘UToggle Case切换大小写。(推荐)Ctrl + (+/-)⌘+ / ⌘-Expand/Collapse折叠或展开代码。 (推荐)Shift + F6⇧F6Refator Rename重构修改命名。(常用,能同步更新路径、变量名、函数名的重命名)Ctrl + F4⌘WClose Tab关闭当前标签页。(建议:Win 系统操作不方便,修改快捷键为 Ctrl + W 操作起来更顺手)Ctrl + W无Extend Selection选中当前光标所在代码块,多次触发会逐级变大。(不常用,Win 系统建议 Ctrl +W 修改为关闭当前标签页)二、查找或替换快捷键(Win)快捷键(Mac)英文说明中文说明Ctrl + F⌘FFind…文件内查找,还支持正则表达式。(常用)Ctrl + Shift + F⇧⌘FFind in Files…项目中查找。(常用)Ctrl + R⌘RReplace…文件内替换。(常用)Ctrl + Shift + R⇧⌘RReplace in Files…项目中替换。(常用)Shift + Shift⇧⇧Fast Find快速查找(常用)三、编译与运行快捷键(Win)快捷键(Mac)英文说明中文说明Shift + F10^RRun运行 entry。 (常用,特别好用)Shift + F9^DDebug调试 entry。Alt + Shift + F10^⌥DChoose and Run Configuration会打开一个已经配置的运行列表,让你选择一个后,再运行。Alt + Shift + F9^⌥DChoose and Debug configuration会打开一个已经配置的运行列表,让你选择一个后,再以调试模式运行。四、调试快捷键(Win)快捷键(Mac)英文说明中文说明F8F8Step Over跳到当前代码下一行。 (常用)F7F7Step Into跳入到调用的方法内部代码。 (常用)Alt + F9⌥F9Run to Cursor让代码运行到当前光标所在处,非常棒的功能。 (常用)Alt + F8⌥F8Evaluate Expression…打开一个表达式面板,然后进行进一步的计算。F9F9Resume Program结束当前断点的本轮调试(因为有可能代码会被调用多次,所以调用后只会结束当前的这一次)如果有下一个断点会跳到下一个断点中。(常用)Ctrl + Shift + F8⇧⌘F8View Breakpoints…打开当前断点的面板,可进行条件过滤。五、其他快捷键(Win)快捷键(Mac)英文说明中文说明Ctrl + Alt + S⌘,Settings / Preferences快速打开设置,配置 IDE 等。
  • [技术干货] 鸿蒙特效教程10-卡片展开/收起效果
    鸿蒙特效教程10-卡片展开/收起效果在移动应用开发中,卡片是一种常见且实用的UI元素,能够将信息以紧凑且易于理解的方式呈现给用户。本教程将详细讲解如何在HarmonyOS中实现卡片的展开/收起效果,通过这个实例,你将掌握ArkUI中状态管理和动画实现的核心技巧。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库一、实现效果预览我们将实现一个包含多个卡片的页面,整个交互过程都有平滑的动画效果。每个卡片默认只显示标题,点击右侧箭头按钮后可以展开显示详细内容,再次点击则收起。实现"全部展开"和"全部收起"的功能按钮。二、实现步骤步骤1:创建基础页面结构首先,我们需要创建一个基本的页面结构,包含一个标题和一个简单的卡片:@Entry @Component struct ToggleCard { build() { Column() { Text('卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 一个简单的卡片 Column() { Text('个人信息') .fontSize(16) .fontWeight(FontWeight.Medium) } .width('90%') .padding(16) .backgroundColor('#ECF2FF') .borderRadius(12) .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 这段代码创建了一个基本的页面,顶部有一个标题,下方有一个简单的卡片,卡片只包含一个标题文本。步骤2:添加卡片标题行和展开按钮接下来,我们为卡片添加一个标题行,并在右侧添加一个展开/收起按钮:@Entry @Component struct ToggleCard { build() { Column() { Text('卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 一个带展开按钮的卡片 Column() { Row() { Text('个人信息') .fontSize(16) .fontWeight(FontWeight.Medium) Blank() // 占位,使按钮靠右显示 Button() { Image($r('sys.media.ohos_ic_public_arrow_down')) .width(24) .height(24) .fillColor('#3F72AF') } .width(36) .height(36) .backgroundColor(Color.Transparent) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) } .width('90%') .padding(16) .backgroundColor('#ECF2FF') .borderRadius(12) .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 现在我们的卡片有了标题和一个展开按钮,但点击按钮还没有任何效果。接下来我们将添加状态管理和交互逻辑。步骤3:添加状态变量控制卡片展开/收起要实现卡片的展开/收起效果,我们需要添加一个状态变量来跟踪卡片是否处于展开状态:@Entry @Component struct ToggleCard { @State isExpanded: boolean = false // 控制卡片展开/收起状态 build() { Column() { Text('卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 一个带展开按钮的卡片 Column() { Row() { Text('个人信息') .fontSize(16) .fontWeight(FontWeight.Medium) Blank() Button() { Image($r('sys.media.ohos_ic_public_arrow_down')) .width(24) .height(24) .fillColor('#3F72AF') } .width(36) .height(36) .backgroundColor(Color.Transparent) .onClick(() => { this.isExpanded = !this.isExpanded // 点击按钮切换状态 }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) // 根据展开状态条件渲染内容 if (this.isExpanded) { Text('这是展开后显示的内容,包含详细信息。') .fontSize(14) .margin({ top: 8 }) } } .width('90%') .padding(16) .backgroundColor('#ECF2FF') .borderRadius(12) .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 现在我们添加了一个@State状态变量isExpanded,并在按钮的onClick事件中切换它的值。同时,我们使用if条件语句根据isExpanded的值决定是否显示卡片的详细内容。步骤4:添加基本动画效果接下来,我们将为卡片的展开/收起添加动画效果,让交互更加流畅自然。HarmonyOS提供了两种主要的动画实现方式:animation属性:直接应用于组件的声明式动画animateTo函数:通过改变状态触发的命令式动画首先,我们使用这两种方式来实现箭头旋转和内容展开的动画效果:@Entry @Component struct ToggleCard { @State isExpanded: boolean = false // 切换卡片展开/收起状态 toggleCard() { // 使用animateTo实现状态变化的动画 animateTo({ duration: 300, // 动画持续时间(毫秒) curve: Curve.EaseOut, // 缓动曲线 onFinish: () => { console.info('卡片动画完成') // 动画完成回调 } }, () => { this.isExpanded = !this.isExpanded // 在动画函数中切换状态 }) } build() { Column() { Text('卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 带动画效果的卡片 Column() { Row() { Text('个人信息') .fontSize(16) .fontWeight(FontWeight.Medium) Blank() Button() { Image($r('sys.media.ohos_ic_public_arrow_down')) .width(24) .height(24) .fillColor('#3F72AF') .rotate({ angle: this.isExpanded ? 180 : 0 }) // 根据状态控制旋转角度 .animation({ // 为旋转添加动画效果 duration: 300, curve: Curve.FastOutSlowIn }) } .width(36) .height(36) .backgroundColor(Color.Transparent) .onClick(() => this.toggleCard()) // 调用切换函数 } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) if (this.isExpanded) { Column() { Text('这是展开后显示的内容,包含详细信息。') .fontSize(14) .layoutWeight(1) } .animation({ // 为内容添加动画效果 duration: 300, curve: Curve.EaseOut }) .height(80) // 固定高度便于观察动画效果 .width('100%') } } .width('90%') .padding(16) .backgroundColor('#ECF2FF') .borderRadius(12) .margin({ top: 20 }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 在这个版本中,我们添加了两种动画实现:使用animateTo函数来实现状态变化时的动画效果使用.animation()属性为箭头旋转和内容展示添加过渡动画这两种动画方式的区别:animation属性:简单直接,适用于属性变化的过渡动画animateTo函数:更灵活,可以一次性动画多个状态变化,有完成回调步骤5:扩展为多卡片结构现在让我们扩展代码,实现多个可独立展开/收起的卡片:// 定义卡片数据接口 interface CardInfo { title: string content: string color: string } @Entry @Component struct ToggleCard { // 使用数组管理多个卡片的展开状态 @State cardsExpanded: boolean[] = [false, false, false] // 卡片数据 private cardsData: CardInfo[] = [ { title: '个人信息', content: '这是个人信息卡片的内容区域,可以放置用户的基本信息,如姓名、年龄、电话等。', color: '#ECF2FF' }, { title: '支付设置', content: '这是支付设置卡片的内容区域,可以放置用户的支付相关信息,包括支付方式、银行卡等信息。', color: '#E7F5EF' }, { title: '隐私设置', content: '这是隐私设置卡片的内容区域,可以放置隐私相关的设置选项,如账号安全、数据权限等内容。', color: '#FFF1E6' } ] // 切换指定卡片的展开/收起状态 toggleCard(index: number) { animateTo({ duration: 300, curve: Curve.EaseOut, onFinish: () => { console.info(`卡片${index}动画完成`) } }, () => { // 创建新数组并更新特定索引的值 let newExpandedState = [...this.cardsExpanded] newExpandedState[index] = !newExpandedState[index] this.cardsExpanded = newExpandedState }) } build() { Column() { Text('多卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 使用ForEach遍历卡片数据,创建多个卡片 ForEach(this.cardsData, (card: CardInfo, index: number) => { // 卡片组件 Column() { Row() { Text(card.title) .fontSize(16) .fontWeight(FontWeight.Medium) Blank() Button() { Image($r('sys.media.ohos_ic_public_arrow_down')) .width(24) .height(24) .fillColor('#3F72AF') .rotate({ angle: this.cardsExpanded[index] ? 180 : 0 }) .animation({ duration: 300, curve: Curve.FastOutSlowIn }) } .width(36) .height(36) .backgroundColor(Color.Transparent) .onClick(() => this.toggleCard(index)) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) if (this.cardsExpanded[index]) { Column() { Text(card.content) .fontSize(14) .layoutWeight(1) } .animation({ duration: 300, curve: Curve.EaseOut }) .height(80) .width('100%') } } .padding(16) .borderRadius(12) .backgroundColor(card.color) .width('90%') .margin({ top: 16 }) }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 在这个版本中,我们添加了以下改进:使用interface定义卡片数据结构创建卡片数据数组和对应的展开状态数组使用ForEach循环创建多个卡片修改toggleCard函数接受索引参数,只切换特定卡片的状态步骤6:添加滚动容器和全局控制按钮最后,我们添加滚动容器和全局控制按钮,完善整个页面功能:// 定义卡片数据接口 interface CardInfo { title: string content: string color: string } @Entry @Component struct ToggleCard { // 使用数组管理多个卡片的展开状态 @State cardsExpanded: boolean[] = [false, false, false, false] // 卡片数据 @State cardsData: CardInfo[] = [ { title: '个人信息', content: '这是个人信息卡片的内容区域,可以放置用户的基本信息,如姓名、年龄、电话等。点击上方按钮可以收起卡片。', color: '#ECF2FF' }, { title: '支付设置', content: '这是支付设置卡片的内容区域,可以放置用户的支付相关信息,包括支付方式、银行卡等信息。点击上方按钮可以收起卡片。', color: '#E7F5EF' }, { title: '隐私设置', content: '这是隐私设置卡片的内容区域,可以放置隐私相关的设置选项,如账号安全、数据权限等内容。点击上方按钮可以收起卡片。', color: '#FFF1E6' }, { title: '关于系统', content: '这是关于系统卡片的内容区域,包含系统版本、更新状态、法律信息等内容。点击上方按钮可以收起卡片。', color: '#F5EDFF' } ] // 切换指定卡片的展开/收起状态 toggleCard(index: number) { animateTo({ duration: 300, curve: Curve.EaseOut, onFinish: () => { console.info(`卡片${index}动画完成`) } }, () => { // 创建新数组并更新特定索引的值 let newExpandedState = [...this.cardsExpanded] newExpandedState[index] = !newExpandedState[index] this.cardsExpanded = newExpandedState }) } build() { Column({ space: 20 }) { Text('多卡片展开/收起示例') .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) // 使用滚动容器,以便在内容较多时可以滚动查看 Scroll() { Column({ space: 16 }) { // 使用ForEach遍历卡片数据,创建多个卡片 ForEach(this.cardsData, (card: CardInfo, index: number) => { // 卡片组件 Column() { Row() { Text(card.title) .fontSize(16) .fontWeight(FontWeight.Medium) Blank() Button() { Image($r('sys.media.ohos_ic_public_arrow_down')) .width(24) .height(24) .fillColor('#3F72AF') .rotate({ angle: this.cardsExpanded[index] ? 180 : 0 }) .animation({ duration: 300, curve: Curve.FastOutSlowIn }) } .width(36) .height(36) .backgroundColor(Color.Transparent) .onClick(() => this.toggleCard(index)) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) if (this.cardsExpanded[index]) { Column({ space: 8 }) { Text(card.content) .fontSize(14) .layoutWeight(1) } .animation({ duration: 300, curve: Curve.EaseOut }) .height(100) .width('100%') } } .padding(16) .borderRadius(12) .backgroundColor(card.color) .width('100%') // 添加阴影效果增强立体感 .shadow({ radius: 4, color: 'rgba(0, 0, 0, 0.1)', offsetX: 0, offsetY: 2 }) }) // 底部间距 Blank() .height(20) } .alignItems(HorizontalAlign.Center) } .align(Alignment.Top) .padding(20) .layoutWeight(1) // 添加底部按钮控制所有卡片 Row({ space: 20 }) { Button('全部展开') .width('40%') .onClick(() => { animateTo({ duration: 300 }, () => { this.cardsExpanded = this.cardsData.map((_: CardInfo) => true) }) }) Button('全部收起') .width('40%') .onClick(() => { animateTo({ duration: 300 }, () => { this.cardsExpanded = this.cardsData.map((_: CardInfo) => false) }) }) } .margin({ bottom: 30 }) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') .alignItems(HorizontalAlign.Center) .expandSafeArea() } } 这个最终版本添加了以下功能:使用Scroll容器,允许内容超出屏幕时滚动查看添加"全部展开"和"全部收起"按钮,使用map函数批量更新状态使用space参数优化布局间距添加阴影效果增强卡片的立体感三、关键技术点讲解1. 状态管理在HarmonyOS的ArkUI框架中,@State装饰器用于声明组件的状态变量。当状态变量改变时,UI会自动更新。在这个示例中:对于单个卡片,我们使用isExpanded布尔值跟踪其展开状态对于多个卡片,我们使用cardsExpanded数组,数组中的每个元素对应一个卡片的状态更新数组类型的状态时,需要创建一个新数组而不是直接修改原数组,这样框架才能检测到变化并更新UI:let newExpandedState = [...this.cardsExpanded] // 创建副本 newExpandedState[index] = !newExpandedState[index] // 修改副本 this.cardsExpanded = newExpandedState // 赋值给状态变量 2. 动画实现HarmonyOS提供了两种主要的动画实现方式:A. animation属性(声明式动画)直接应用于组件,当属性值变化时自动触发动画:.rotate({ angle: this.isExpanded ? 180 : 0 }) // 属性根据状态变化 .animation({ // 动画配置 duration: 300, // 持续时间(毫秒) curve: Curve.FastOutSlowIn, // 缓动曲线 delay: 0, // 延迟时间(毫秒) iterations: 1, // 重复次数 playMode: PlayMode.Normal // 播放模式 }) B. animateTo函数(命令式动画)通过回调函数中改变状态值来触发动画:animateTo({ duration: 300, // 持续时间 curve: Curve.EaseOut, // 缓动曲线 onFinish: () => { // 动画完成回调 console.info('动画完成') } }, () => { // 在这个函数中更改状态值,这些变化将以动画方式呈现 this.isExpanded = !this.isExpanded }) 3. 条件渲染使用if条件语句实现内容的动态显示:if (this.cardsExpanded[index]) { Column() { // 这里的内容只在卡片展开时渲染 } } 4. 数据驱动的UI通过ForEach循环根据数据动态创建UI元素:ForEach(this.cardsData, (card: CardInfo, index: number) => { // 根据每个数据项创建卡片 }) 四、动画曲线详解HarmonyOS提供了多种缓动曲线,可以实现不同的动画效果:Curve.Linear:线性曲线,匀速动画Curve.EaseIn:缓入曲线,动画开始慢,结束快Curve.EaseOut:缓出曲线,动画开始快,结束慢Curve.EaseInOut:缓入缓出曲线,动画开始和结束都慢,中间快Curve.FastOutSlowIn:标准曲线,类似Android标准曲线Curve.LinearOutSlowIn:减速曲线Curve.FastOutLinearIn:加速曲线Curve.ExtremeDeceleration:急缓曲线Curve.Sharp:锐利曲线Curve.Rhythm:节奏曲线Curve.Smooth:平滑曲线Curve.Friction:摩擦曲线/阻尼曲线在我们的示例中:使用Curve.FastOutSlowIn为箭头旋转提供更自然的视觉效果使用Curve.EaseOut为内容展开提供平滑的过渡五、常见问题与解决方案动画不流畅:可能是因为在动画过程中执行了复杂操作。解决方法是将复杂计算从动画函数中移出,或者使用onFinish回调在动画完成后执行。条件渲染内容闪烁:为条件渲染的内容添加.animation()属性可以实现平滑过渡。卡片高度跳变:为卡片内容设置固定高度,或者使用更复杂的布局计算动态高度。多卡片状态管理复杂:使用数组管理多个状态,并记得创建数组副本而不是直接修改原数组。六、扩展与优化你可以进一步扩展这个效果:自定义卡片内容:为每个卡片添加更丰富的内容,如表单、图表或列表记住展开状态:使用持久化存储记住用户的卡片展开偏好添加手势交互:支持滑动展开/收起卡片添加动态效果:比如展开时显示阴影或改变背景优化性能:对于非常多的卡片,可以实现虚拟列表或懒加载七、总结通过本教程,我们学习了如何在HarmonyOS中实现卡片展开/收起效果,掌握了ArkUI中状态管理和动画实现的核心技巧。关键技术点包括:使用@State管理组件状态使用.animation()属性和animateTo()函数实现动画使用条件渲染动态显示内容实现数据驱动的UI创建为多个卡片独立管理状态这些技术不仅适用于卡片展开/收起效果,也是构建其他复杂交互界面的基础。希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的 👍点赞、💬评论、🌟收藏 支持。
  • [技术干货] 鸿蒙特效教程09-深入学习animateTo动画
    鸿蒙特效教程09-深入学习animateTo动画本教程将带领大家从零开始,一步步讲解如何讲解 animateTo 动画,并实现按钮交互效果,使新手也能轻松掌握。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库效果演示通过两个常见的按钮动画效果,深入学习 HarmonyOS Next 的 animateTo 动画,以及探索最佳实践。缩放按钮效果抖动按钮效果一、基础准备1.1 理解ArkUI中的动画机制HarmonyOS的ArkUI框架提供了强大的动画支持,常见有两种实现方式:声明式动画:通过.animation()属性直接应用于组件命令式动画:通过animateTo()方法动态改变状态触发动画本文将主要使用animateTo()方法,因为它更灵活,能实现更复杂的动画效果。1.2 创建基础项目结构首先,我们创建一个基本的页面组件结构:@Entry @Component struct ButtonAnimation { // 状态变量将在后续步骤中添加 build() { Column({ space: 20 }) { Text('按钮交互效果') .fontSize(22) .fontWeight(FontWeight.Bold) // 后续步骤将在这里添加按钮组件 } .width('100%') .height('100%') .padding(20) .backgroundColor('#ffb3d0ff') .justifyContent(FlexAlign.Center) .expandSafeArea() } } 这段代码创建了一个基本的页面布局,包含一个标题文本。接下来,我们将逐步添加按钮和动画效果。二、实现按钮点击缩放效果2.1 添加基础按钮布局首先,添加一个按钮及其容器:// 按钮缩放效果 Column({ space: 10 }) { Text('按钮点击缩放效果') .fontSize(16) .fontWeight(FontWeight.Medium) Button('点击缩放') .width(150) .fontSize(16) // 动画相关属性将在后续步骤添加 .onClick(() => { // 点击处理函数将在后续步骤添加 console.log('按钮被点击了') }) } .padding(16) .borderRadius(12) .backgroundColor('#F0F5FF') .width('100%') .margin({ top: 16 }) .alignItems(HorizontalAlign.Center) 这段代码添加了一个带标题的按钮区域,并为按钮设置了基本样式。2.2 添加状态变量和缩放属性要实现缩放效果,我们需要添加一个状态变量来控制按钮的缩放比例:@State buttonScale: number = 1.0 然后,为按钮添加缩放属性:Button('点击缩放') .width(150) .fontSize(16) .scale({ x: this.buttonScale, y: this.buttonScale }) // 添加缩放属性 .onClick(() => { console.log('按钮被点击了') }) .scale()属性用于设置组件的缩放比例,通过改变buttonScale的值,可以实现按钮的缩放效果。2.3 实现简单的缩放动画现在,添加一个简单的点击缩放效果:// 按钮点击缩放效果 pressButton() { // 缩小 animateTo({ duration: 100, // 动画持续时间(毫秒) curve: Curve.EaseIn // 缓动曲线 }, () => { this.buttonScale = 0.9 // 缩小到90% }) // 延时后恢复原大小 setTimeout(() => { animateTo({ duration: 200, curve: Curve.EaseOut }, () => { this.buttonScale = 1.0 // 恢复原大小 }) }, 100) } 然后修改按钮的点击处理函数:.onClick(() => { this.pressButton() // 调用缩放动画函数 console.log('按钮被点击了') }) 这段代码实现了一个基本的缩放动画:按钮点击时先缩小到90%,然后恢复原大小。但是它使用了setTimeout,我们可以进一步优化。2.4 使用onFinish回调优化动画animateTo()方法提供了onFinish回调,可以在动画完成后执行操作。我们可以使用它来替代setTimeout:@State animationCount: number = 0 // 用于跟踪动画状态 // 按钮点击缩放效果 pressButton() { this.animationCount = 0 // 缩小 animateTo({ duration: 100, curve: Curve.EaseIn, // 缓入曲线 onFinish: () => { // 动画完成后立即开始第二阶段 animateTo({ duration: 200, curve: Curve.ExtremeDeceleration // 急缓曲线 }, () => { this.buttonScale = 1.0 }) } }, () => { this.animationCount++ this.buttonScale = 0.9 }) } 这种实现方式更加优雅,没有使用setTimeout,而是利用动画完成回调来链接多个动画阶段。此外,我们使用了不同的缓动曲线,使动画更加生动:Curve.EaseIn:缓入曲线,动画开始时缓慢,然后加速Curve.ExtremeDeceleration:急缓曲线,开始快速然后迅速减慢,产生弹性的视觉效果三、实现按钮抖动效果3.1 添加抖动按钮布局先添加抖动按钮的UI部分:// 抖动效果 Column({ space: 10 }) { Text('按钮抖动效果') .fontSize(16) .fontWeight(FontWeight.Medium) Button('点击抖动') .width(150) .fontSize(16) // 动画相关属性将在后续步骤添加 .onClick(() => { console.log('按钮被点击了') }) } .padding(16) .borderRadius(12) .backgroundColor('#F0F5FF') .width('100%') .alignItems(HorizontalAlign.Center) 3.2 添加状态变量和位移属性要实现抖动效果,我们需要添加状态变量来控制按钮的水平位移:@State shakeOffset: number = 0 // 控制水平抖动偏移 @State shakeStep: number = 0 // 用于跟踪抖动步骤 然后,为按钮添加平移属性:Button('点击抖动') .width(150) .fontSize(16) .translate({ x: this.shakeOffset }) // 添加水平平移属性 .onClick(() => { console.log('按钮被点击了') }) .translate()属性用于设置组件的平移,通过改变shakeOffset的值,我们可以让按钮左右移动。3.3 使用setTimeout实现连续抖动一个简单的实现方式是使用多个setTimeout来创建连续的抖动:// 抖动效果 startShake() { // 向右移动 animateTo({ duration: 50 }, () => { this.shakeOffset = 5 }) // 向左移动 setTimeout(() => { animateTo({ duration: 50 }, () => { this.shakeOffset = -5 }) }, 50) // 向右小幅移动 setTimeout(() => { animateTo({ duration: 50 }, () => { this.shakeOffset = 3 }) }, 100) // 向左小幅移动 setTimeout(() => { animateTo({ duration: 50 }, () => { this.shakeOffset = -3 }) }, 150) // 回到中心 setTimeout(() => { animateTo({ duration: 50 }, () => { this.shakeOffset = 0 }) }, 200) } 修改按钮的点击处理函数:.onClick(() => { this.startShake() // 调用抖动动画函数 console.log('按钮被点击了') }) 这段代码通过多个setTimeout连续改变按钮的水平偏移量,实现抖动效果。但是使用这么多的setTimeout不够优雅,我们可以进一步优化。3.4 使用递归和onFinish回调优化抖动动画我们可以使用递归和onFinish回调来替代多个setTimeout,使代码更加优雅:// 抖动效果 startShake() { this.shakeStep = 0 this.executeShakeStep() } // 执行抖动的每一步 executeShakeStep() { const shakeValues = [5, -5, 3, -3, 0] // 定义抖动序列 if (this.shakeStep >= shakeValues.length) { return // 所有步骤完成后退出 } animateTo({ duration: 50, curve: Curve.Linear, // 匀速曲线 onFinish: () => { this.shakeStep++ if (this.shakeStep < shakeValues.length) { this.executeShakeStep() // 递归执行下一步抖动 } } }, () => { this.shakeOffset = shakeValues[this.shakeStep] // 设置当前步骤的偏移值 }) } 这种实现方式更加优雅和灵活:使用数组shakeValues定义整个抖动序列通过递归调用executeShakeStep()和onFinish回调,实现连续动画没有使用setTimeout,使代码更加清晰和易于维护四、animateTo API详解animateTo()是HarmonyOS中实现动画的核心API,它的基本语法如下:animateTo(value: AnimateParam, event: () => void): void 4.1 AnimateParam参数AnimateParam是一个配置对象,包含以下主要属性:duration: number - 动画持续时间,单位为毫秒tempo: number - 动画播放速度,值越大动画播放越快,默认值 1curve: Curve - 动画的缓动曲线,控制动画的速度变化delay: number - 动画开始前的延迟时间,单位为毫秒iterations: number - 动画重复次数,-1表示无限循环playMode: PlayMode - 动画播放模式,如正向、反向、交替等onFinish: () => void - 动画完成时的回调函数4.2 常用缓动曲线HarmonyOS提供了多种缓动曲线,可以实现不同的动画效果:Curve.Linear: 线性曲线,动画速度恒定Curve.EaseIn: 缓入曲线,动画开始缓慢,然后加速Curve.EaseOut: 缓出曲线,动画开始快速,然后减速Curve.EaseInOut: 缓入缓出曲线,动画开始和结束都缓慢,中间快速Curve.FastOutSlowIn: 快出慢入曲线,类似于Android的标准曲线Curve.ExtremeDeceleration: 急缓曲线,用于模拟弹性效果curves.springMotion(): 弹簧曲线,模拟物理弹簧效果4.3 动画函数event是一个函数,在这个函数中改变状态变量的值,从而触发动画。例如:animateTo({ duration: 300 }, () => { this.buttonScale = 0.9 // 改变状态变量,触发缩放动画 }) 4.4 连续动画的实现方式有几种方式可以实现连续的动画效果:使用setTimeout(不推荐):animateTo({ duration: 300 }, () => { this.value1 = newValue1 }) setTimeout(() => { animateTo({ duration: 300 }, () => { this.value2 = newValue2 }) }, 300) 使用onFinish回调(推荐):animateTo({ duration: 300, onFinish: () => { animateTo({ duration: 300 }, () => { this.value2 = newValue2 }) } }, () => { this.value1 = newValue1 }) 使用递归和计数器(用于复杂序列):let steps = [value1, value2, value3] let currentStep = 0 function executeNextStep() { if (currentStep >= steps.length) return animateTo({ duration: 300, onFinish: () => { currentStep++ if (currentStep < steps.length) { executeNextStep() } } }, () => { this.value = steps[currentStep] }) } executeNextStep() 五、完整代码实现下面是完整的按钮动画效果实现代码:@Entry @Component struct ButtonAnimation { @State buttonScale: number = 1.0 @State shakeOffset: number = 0 @State animationCount: number = 0 // 用于跟踪动画状态 @State shakeStep: number = 0 // 用于跟踪抖动步骤 // 按钮点击缩放效果 pressButton() { this.animationCount = 0 // 缩小 animateTo({ duration: 100, curve: Curve.EaseIn, // 缓入曲线 onFinish: () => { // 动画完成后立即开始第二阶段 animateTo({ duration: 200, curve: Curve.ExtremeDeceleration // 急缓曲线 }, () => { this.buttonScale = 1.0 }) } }, () => { this.animationCount++ this.buttonScale = 0.9 }) } // 抖动效果 startShake() { this.shakeStep = 0 this.executeShakeStep() } // 执行抖动的每一步 executeShakeStep() { const shakeValues = [5, -5, 3, -3, 0] if (this.shakeStep >= shakeValues.length) { return } animateTo({ duration: 50, curve: Curve.Linear, // 匀速曲线 onFinish: () => { this.shakeStep++ if (this.shakeStep < shakeValues.length) { this.executeShakeStep() // 递归执行下一步抖动 } } }, () => { this.shakeOffset = shakeValues[this.shakeStep] }) } build() { Column({ space: 20 }) { Text('按钮交互效果') .fontSize(22) .fontWeight(FontWeight.Bold) // 按钮缩放效果 Column({ space: 10 }) { Text('按钮点击缩放效果') .fontSize(16) .fontWeight(FontWeight.Medium) Button('点击缩放') .width(150) .fontSize(16) .scale({ x: this.buttonScale, y: this.buttonScale }) .onClick(() => { // 缩放效果 this.pressButton() // 你的业务逻辑 console.log('你的业务逻辑') }) } .padding(16) .borderRadius(12) .backgroundColor('#F0F5FF') .width('100%') .margin({ top: 16 }) .alignItems(HorizontalAlign.Center) // 抖动效果 Column({ space: 10 }) { Text('按钮抖动效果') .fontSize(16) .fontWeight(FontWeight.Medium) Button('点击抖动') .width(150) .fontSize(16) .translate({ x: this.shakeOffset }) .onClick(() => { // 你的业务逻辑 console.log('你的业务逻辑') // 模拟轻微震动反馈,适用于错误提示或注意力引导 this.startShake() }) } .padding(16) .borderRadius(12) .backgroundColor('#F0F5FF') .width('100%') .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') .padding(20) .backgroundColor('#ffb3d0ff') .justifyContent(FlexAlign.Center) .expandSafeArea() } } 六、应用场景和扩展6.1 适用场景缩放效果:适用于提供用户点击反馈,增强交互感抖动效果:适用于错误提示、警告或引起用户注意6.2 可能的扩展结合振动反馈:与设备振动结合,提供触觉反馈添加声音反馈:配合音效,提供听觉反馈组合多种动画:如缩放+旋转、缩放+颜色变化等6.3 性能优化建议避免过于复杂的动画,尤其是在低端设备上合理选择动画持续时间,一般不超过300ms对于频繁触发的动画,考虑增加防抖处理使用onFinish回调代替setTimeout实现连续动画七、总结与心得通过本文,我们学习了如何在HarmonyOS中实现按钮缩放和抖动效果,关键点包括:使用@State状态变量控制动画参数利用animateTo()方法实现流畅的状态变化动画选择合适的缓动曲线让动画更加自然使用onFinish回调和递归实现连续动画,避免使用setTimeout将动画逻辑封装为独立方法,使代码更加清晰动画效果能够显著提升应用的用户体验,希望本文能帮助你在HarmonyOS应用中添加生动、自然的交互动画。随着你对 animateTo() API的深入理解,可以创造出更加复杂和精美的动画效果。希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的点赞、评论、收藏。
  • [技术干货] 鸿蒙特效教程08-幸运大转盘抽奖
    鸿蒙特效教程08-幸运大转盘抽奖本教程将带领大家从零开始,一步步实现一个完整的转盘抽奖效果,包括界面布局、Canvas绘制、动画效果和抽奖逻辑等。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库1. 需求分析与整体设计温馨提醒:本案例有一定难度,建议先收藏起来。在开始编码前,让我们先明确转盘抽奖的基本需求:展示一个可旋转的奖品转盘转盘上有多个奖品区域,每个区域有不同的颜色和奖品名称点击"开始抽奖"按钮后,转盘开始旋转转盘停止后,指针指向的位置即为抽中的奖品每个奖品有不同的中奖概率整体设计思路:使用HarmonyOS的Canvas组件绘制转盘利用动画效果实现转盘旋转根据概率算法确定最终停止位置2. 基础界面布局首先,我们创建基础的页面布局,包括标题、转盘区域和结果显示。@Entry @Component struct LuckyWheel { build() { Column() { // 标题 Text('幸运大转盘') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 20 }) // 抽奖结果显示 Text('点击开始抽奖') .fontSize(20) .fontColor(Color.White) .backgroundColor('#1AFFFFFF') .width('90%') .textAlign(TextAlign.Center) .padding(15) .borderRadius(16) .margin({ bottom: 30 }) // 转盘容器(后续会添加Canvas) Stack({ alignContent: Alignment.Center }) { // 这里稍后会添加Canvas绘制转盘 // 中央开始按钮 Button({ type: ButtonType.Circle }) { Text('开始\n抽奖') .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width(80) .height(80) .backgroundColor('#FF6B6B') } .width('90%') .aspectRatio(1) .backgroundColor('#0DFFFFFF') .borderRadius(16) .padding(15) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor(Color.Black) .linearGradient({ angle: 135, colors: [ ['#1A1B25', 0], ['#2D2E3A', 1] ] }) } } 这个基础布局创建了一个带有标题、结果显示区和转盘容器的页面。转盘容器使用Stack组件,这样我们可以在转盘上方放置"开始抽奖"按钮。3. 定义数据结构接下来,我们需要定义转盘上的奖品数据结构:// 奖品数据接口 interface PrizesItem { name: string // 奖品名称 color: string // 转盘颜色 probability: number // 概率权重 } @Entry @Component struct LuckyWheel { // 奖品数据 private prizes: PrizesItem[] = [ { name: '谢谢参与', color: '#FFD8A8', probability: 30 }, { name: '10积分', color: '#B2F2BB', probability: 20 }, { name: '5元红包', color: '#D0BFFF', probability: 10 }, { name: '优惠券', color: '#A5D8FF', probability: 15 }, { name: '免单券', color: '#FCCFE7', probability: 5 }, { name: '50积分', color: '#BAC8FF', probability: 15 }, { name: '会员月卡', color: '#99E9F2', probability: 3 }, { name: '1元红包', color: '#FFBDBD', probability: 2 } ] // 状态变量 @State isSpinning: boolean = false // 是否正在旋转 @State rotation: number = 0 // 当前旋转角度 @State result: string = '点击开始抽奖' // 抽奖结果 // ...其余代码 } 这里我们定义了转盘上的8个奖品,每个奖品包含名称、颜色和概率权重。同时定义了三个状态变量来跟踪转盘的状态。4. 初始化Canvas现在,让我们初始化Canvas来绘制转盘:@Entry @Component struct LuckyWheel { // Canvas 相关设置 private readonly settings: RenderingContextSettings = new RenderingContextSettings(true); // 启用抗锯齿 private readonly ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); // 转盘相关属性 private canvasWidth: number = 0 // 画布宽度 private canvasHeight: number = 0 // 画布高度 // ...其余代码 build() { Column() { // ...之前的代码 // 转盘容器 Stack({ alignContent: Alignment.Center }) { // 使用Canvas绘制转盘 Canvas(this.ctx) .width('100%') .height('100%') .onReady(() => { // 获取Canvas尺寸 this.canvasWidth = this.ctx.width this.canvasHeight = this.ctx.height // 初始绘制转盘 this.drawWheel() }) // 中央开始按钮 // ...按钮代码 } // ...容器样式 } // ...外层容器样式 } // 绘制转盘(先定义一个空方法,稍后实现) private drawWheel(): void { // 稍后实现 } } 这里我们创建了Canvas绘制上下文,并在onReady回调中获取Canvas尺寸,然后调用drawWheel方法绘制转盘。5. 实现转盘绘制接下来,我们实现drawWheel方法,绘制转盘:// 绘制转盘 private drawWheel(): void { if (!this.ctx) return const centerX = this.canvasWidth / 2 const centerY = this.canvasHeight / 2 const radius = Math.min(centerX, centerY) * 0.85 // 清除画布 this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) // 保存当前状态 this.ctx.save() // 移动到中心点 this.ctx.translate(centerX, centerY) // 应用旋转 this.ctx.rotate((this.rotation % 360) * Math.PI / 180) // 绘制转盘扇形 const anglePerPrize = 2 * Math.PI / this.prizes.length for (let i = 0; i < this.prizes.length; i++) { const startAngle = i * anglePerPrize const endAngle = (i + 1) * anglePerPrize this.ctx.beginPath() this.ctx.moveTo(0, 0) this.ctx.arc(0, 0, radius, startAngle, endAngle) this.ctx.closePath() // 填充扇形 this.ctx.fillStyle = this.prizes[i].color this.ctx.fill() // 绘制边框 this.ctx.strokeStyle = "#FFFFFF" this.ctx.lineWidth = 2 this.ctx.stroke() } // 恢复状态 this.ctx.restore() } 这段代码实现了基本的转盘绘制:计算中心点和半径清除画布平移坐标系到转盘中心应用旋转角度绘制每个奖品的扇形区域运行后,你应该能看到一个彩色的转盘,但还没有文字和指针。6. 添加奖品文字继续完善drawWheel方法,添加奖品文字:// 绘制转盘扇形 const anglePerPrize = 2 * Math.PI / this.prizes.length for (let i = 0; i < this.prizes.length; i++) { // ...之前的扇形绘制代码 // 绘制文字 this.ctx.save() this.ctx.rotate(startAngle + anglePerPrize / 2) this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' this.ctx.fillStyle = '#333333' this.ctx.font = '24px sans-serif' // 旋转文字,使其可读性更好 // 第一象限和第四象限的文字需要额外旋转180度,保证文字朝向 const needRotate = (i >= this.prizes.length / 4) && (i < this.prizes.length * 3 / 4) if (needRotate) { this.ctx.rotate(Math.PI) this.ctx.fillText(this.prizes[i].name, -radius * 0.6, 0, radius * 0.5) } else { this.ctx.fillText(this.prizes[i].name, radius * 0.6, 0, radius * 0.5) } this.ctx.restore() } 这里我们在每个扇形区域添加了奖品文字,并根据位置进行适当旋转,确保文字朝向正确,提高可读性。7. 添加中心圆盘和指针继续完善drawWheel方法,添加中心圆盘和指针:// 恢复状态 this.ctx.restore() // 绘制中心圆盘 this.ctx.beginPath() this.ctx.arc(centerX, centerY, radius * 0.2, 0, 2 * Math.PI) this.ctx.fillStyle = '#FF8787' this.ctx.fill() this.ctx.strokeStyle = '#FFFFFF' this.ctx.lineWidth = 3 this.ctx.stroke() // 绘制指针 - 固定在顶部中央 this.ctx.beginPath() // 三角形指针 this.ctx.moveTo(centerX, centerY - radius - 10) this.ctx.lineTo(centerX - 15, centerY - radius * 0.8) this.ctx.lineTo(centerX + 15, centerY - radius * 0.8) this.ctx.closePath() this.ctx.fillStyle = '#FF6B6B' this.ctx.fill() this.ctx.strokeStyle = '#FFFFFF' this.ctx.lineWidth = 2 this.ctx.stroke() // 绘制中心文字 this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' this.ctx.fillStyle = '#FFFFFF' this.ctx.font = '18px sans-serif' // 绘制两行文字 this.ctx.fillText('开始', centerX, centerY - 10) this.ctx.fillText('抽奖', centerX, centerY + 10) 这段代码添加了:中心的红色圆盘顶部的三角形指针中心的"开始抽奖"文字现在转盘的静态部分已经完成。下一步,我们将实现转盘的旋转动画。8. 实现抽奖逻辑在实现转盘旋转前,我们需要先实现抽奖逻辑,决定最终奖品:// 生成随机目标索引(基于概率权重) private generateTargetIndex(): number { const weights = this.prizes.map(prize => prize.probability) const totalWeight = weights.reduce((a, b) => a + b, 0) const random = Math.random() * totalWeight let currentWeight = 0 for (let i = 0; i < weights.length; i++) { currentWeight += weights[i] if (random < currentWeight) { return i } } return 0 } 这个方法根据每个奖品的概率权重生成一个随机索引,概率越高的奖品被选中的机会越大。9. 实现转盘旋转现在,让我们实现转盘旋转的核心逻辑:// 转盘属性 private spinDuration: number = 4000 // 旋转持续时间(毫秒) private targetIndex: number = 0 // 目标奖品索引 private spinTimer: number = 0 // 旋转定时器 // 开始抽奖 private startSpin(): void { if (this.isSpinning) return this.isSpinning = true this.result = '抽奖中...' // 生成目标奖品索引 this.targetIndex = this.generateTargetIndex() console.info(`抽中奖品索引: ${this.targetIndex}, 名称: ${this.prizes[this.targetIndex].name}`) // 计算目标角度 // 每个奖品占据的角度 = 360 / 奖品数量 const anglePerPrize = 360 / this.prizes.length // 因为Canvas中0度是在右侧,顺时针旋转,而指针在顶部(270度位置) // 所以需要将奖品旋转到270度位置对应的角度 // 目标奖品中心点的角度 = 索引 * 每份角度 + 半份角度 const prizeAngle = this.targetIndex * anglePerPrize + anglePerPrize / 2 // 需要旋转到270度位置的角度 = 270 - 奖品角度 // 但由于旋转方向是顺时针,所以需要计算为正向旋转角度 const targetAngle = (270 - prizeAngle + 360) % 360 // 获取当前角度的标准化值(0-360范围内) const currentRotation = this.rotation % 360 // 计算从当前位置到目标位置需要旋转的角度(确保是顺时针旋转) let deltaAngle = targetAngle - currentRotation if (deltaAngle <= 0) { deltaAngle += 360 } // 最终旋转角度 = 当前角度 + 5圈 + 到目标的角度差 const finalRotation = this.rotation + 360 * 5 + deltaAngle console.info(`当前角度: ${currentRotation}°, 奖品角度: ${prizeAngle}°, 目标角度: ${targetAngle}°, 旋转量: ${deltaAngle}°, 最终角度: ${finalRotation}°`) // 使用基于帧动画的方式旋转,确保视觉上平滑旋转 let startTime = Date.now() let initialRotation = this.rotation // 清除可能存在的定时器 if (this.spinTimer) { clearInterval(this.spinTimer) } // 创建新的动画定时器 this.spinTimer = setInterval(() => { const elapsed = Date.now() - startTime if (elapsed >= this.spinDuration) { // 动画结束 clearInterval(this.spinTimer) this.spinTimer = 0 this.rotation = finalRotation this.drawWheel() this.isSpinning = false this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}` return } // 使用easeOutExpo效果:慢慢减速 const progress = this.easeOutExpo(elapsed / this.spinDuration) this.rotation = initialRotation + progress * (finalRotation - initialRotation) // 重绘转盘 this.drawWheel() }, 16) // 大约60fps的刷新率 } // 缓动函数:指数减速 private easeOutExpo(t: number): number { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t) } 这段代码实现了转盘旋转的核心逻辑:根据概率生成目标奖品计算目标奖品对应的角度计算需要旋转的总角度(多转几圈再停在目标位置)使用定时器实现转盘的平滑旋转使用缓动函数实现转盘的减速效果旋转结束后显示中奖结果10. 连接按钮点击事件现在我们需要将"开始抽奖"按钮与startSpin方法连接起来:// 中央开始按钮 Button({ type: ButtonType.Circle }) { Text('开始\n抽奖') .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width(80) .height(80) .backgroundColor('#FF6B6B') .onClick(() => this.startSpin()) .enabled(!this.isSpinning) .stateEffect(true) // 启用点击效果 这里我们给按钮添加了onClick事件处理器,点击按钮时调用startSpin方法。同时使用enabled属性确保在转盘旋转过程中按钮不可点击。11. 添加资源释放为了防止内存泄漏,我们需要在页面销毁时清理定时器:aboutToDisappear() { // 清理定时器 if (this.spinTimer !== 0) { clearInterval(this.spinTimer) this.spinTimer = 0 } } 12. 添加底部概率说明(可选)最后,我们在页面底部添加奖品概率说明:// 底部说明 Text('奖品说明:概率从高到低排序') .fontSize(14) .fontColor(Color.White) .opacity(0.7) .margin({ top: 20 }) // 概率说明 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { ForEach(this.prizes, (prize: PrizesItem, index) => { Text(`${prize.name}: ${prize.probability}%`) .fontSize(12) .fontColor(Color.White) .backgroundColor(prize.color) .borderRadius(12) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .margin(4) }) } .width('90%') .margin({ top: 10 }) 这段代码在页面底部添加了奖品概率说明,直观展示各个奖品的中奖概率。13. 美化优化为了让转盘更加美观,我们可以进一步优化转盘的视觉效果:// 绘制转盘 private drawWheel(): void { // ...之前的代码 // 绘制转盘外圆边框 this.ctx.beginPath() this.ctx.arc(centerX, centerY, radius + 5, 0, 2 * Math.PI) this.ctx.fillStyle = '#2A2A2A' this.ctx.fill() this.ctx.strokeStyle = '#FFD700' // 金色边框 this.ctx.lineWidth = 3 this.ctx.stroke() // ...其余绘制代码 // 给指针添加渐变色和阴影 let pointerGradient = this.ctx.createLinearGradient( centerX, centerY - radius - 15, centerX, centerY - radius * 0.8 ) pointerGradient.addColorStop(0, '#FF0000') pointerGradient.addColorStop(1, '#FF6666') this.ctx.fillStyle = pointerGradient this.ctx.fill() this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)' this.ctx.shadowBlur = 5 this.ctx.shadowOffsetX = 2 this.ctx.shadowOffsetY = 2 // ...其余代码 } 完整代码以下是完整的实现代码:interface PrizesItem { name: string // 奖品名称 color: string // 转盘颜色 probability: number // 概率权重 } @Entry @Component struct Index { // Canvas 相关设置 private readonly settings: RenderingContextSettings = new RenderingContextSettings(true); // 启用抗锯齿 private readonly ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); // 奖品数据 private prizes: PrizesItem[] = [ { name: '谢谢参与', color: '#FFD8A8', probability: 30 }, { name: '10积分', color: '#B2F2BB', probability: 20 }, { name: '5元红包', color: '#D0BFFF', probability: 1 }, { name: '优惠券', color: '#A5D8FF', probability: 15 }, { name: '免单券', color: '#FCCFE7', probability: 5 }, { name: '50积分', color: '#BAC8FF', probability: 15 }, { name: '会员月卡', color: '#99E9F2', probability: 3 }, { name: '1元红包', color: '#FFBDBD', probability: 2 } ] // 转盘属性 @State isSpinning: boolean = false // 是否正在旋转 @State rotation: number = 0 // 当前旋转角度 @State result: string = '点击开始抽奖' // 抽奖结果 private spinDuration: number = 4000 // 旋转持续时间(毫秒) private targetIndex: number = 0 // 目标奖品索引 private spinTimer: number = 0 // 旋转定时器 private canvasWidth: number = 0 // 画布宽度 private canvasHeight: number = 0 // 画布高度 // 生成随机目标索引(基于概率权重) private generateTargetIndex(): number { const weights = this.prizes.map(prize => prize.probability) const totalWeight = weights.reduce((a, b) => a + b, 0) const random = Math.random() * totalWeight let currentWeight = 0 for (let i = 0; i < weights.length; i++) { currentWeight += weights[i] if (random < currentWeight) { return i } } return 0 } // 开始抽奖 private startSpin(): void { if (this.isSpinning) { return } this.isSpinning = true this.result = '抽奖中...' // 生成目标奖品索引 this.targetIndex = this.generateTargetIndex() console.info(`抽中奖品索引: ${this.targetIndex}, 名称: ${this.prizes[this.targetIndex].name}`) // 计算目标角度 // 每个奖品占据的角度 = 360 / 奖品数量 const anglePerPrize = 360 / this.prizes.length // 因为Canvas中0度是在右侧,顺时针旋转,而指针在顶部(270度位置) // 所以需要将奖品旋转到270度位置对应的角度 // 目标奖品中心点的角度 = 索引 * 每份角度 + 半份角度 const prizeAngle = this.targetIndex * anglePerPrize + anglePerPrize / 2 // 需要旋转到270度位置的角度 = 270 - 奖品角度 // 但由于旋转方向是顺时针,所以需要计算为正向旋转角度 const targetAngle = (270 - prizeAngle + 360) % 360 // 获取当前角度的标准化值(0-360范围内) const currentRotation = this.rotation % 360 // 计算从当前位置到目标位置需要旋转的角度(确保是顺时针旋转) let deltaAngle = targetAngle - currentRotation if (deltaAngle <= 0) { deltaAngle += 360 } // 最终旋转角度 = 当前角度 + 5圈 + 到目标的角度差 const finalRotation = this.rotation + 360 * 5 + deltaAngle console.info(`当前角度: ${currentRotation}°, 奖品角度: ${prizeAngle}°, 目标角度: ${targetAngle}°, 旋转量: ${deltaAngle}°, 最终角度: ${finalRotation}°`) // 使用基于帧动画的方式旋转,确保视觉上平滑旋转 let startTime = Date.now() let initialRotation = this.rotation // 清除可能存在的定时器 if (this.spinTimer) { clearInterval(this.spinTimer) } // 创建新的动画定时器 this.spinTimer = setInterval(() => { const elapsed = Date.now() - startTime if (elapsed >= this.spinDuration) { // 动画结束 clearInterval(this.spinTimer) this.spinTimer = 0 this.rotation = finalRotation this.drawWheel() this.isSpinning = false this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}` return } // 使用easeOutExpo效果:慢慢减速 const progress = this.easeOutExpo(elapsed / this.spinDuration) this.rotation = initialRotation + progress * (finalRotation - initialRotation) // 重绘转盘 this.drawWheel() }, 16) // 大约60fps的刷新率 } // 缓动函数:指数减速 private easeOutExpo(t: number): number { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t) } // 绘制转盘 private drawWheel(): void { if (!this.ctx) { return } const centerX = this.canvasWidth / 2 const centerY = this.canvasHeight / 2 const radius = Math.min(centerX, centerY) * 0.85 // 清除画布 this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) // 保存当前状态 this.ctx.save() // 移动到中心点 this.ctx.translate(centerX, centerY) // 应用旋转 this.ctx.rotate((this.rotation % 360) * Math.PI / 180) // 绘制转盘扇形 const anglePerPrize = 2 * Math.PI / this.prizes.length for (let i = 0; i < this.prizes.length; i++) { const startAngle = i * anglePerPrize const endAngle = (i + 1) * anglePerPrize this.ctx.beginPath() this.ctx.moveTo(0, 0) this.ctx.arc(0, 0, radius, startAngle, endAngle) this.ctx.closePath() // 填充扇形 this.ctx.fillStyle = this.prizes[i].color this.ctx.fill() // 绘制边框 this.ctx.strokeStyle = "#FFFFFF" this.ctx.lineWidth = 2 this.ctx.stroke() // 绘制文字 this.ctx.save() this.ctx.rotate(startAngle + anglePerPrize / 2) this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' this.ctx.fillStyle = '#333333' this.ctx.font = '30px' // 旋转文字,使其可读性更好 // 第一象限和第四象限的文字需要额外旋转180度,保证文字朝向 const needRotate = (i >= this.prizes.length / 4) && (i < this.prizes.length * 3 / 4) if (needRotate) { this.ctx.rotate(Math.PI) this.ctx.fillText(this.prizes[i].name, -radius * 0.6, 0, radius * 0.5) } else { this.ctx.fillText(this.prizes[i].name, radius * 0.6, 0, radius * 0.5) } this.ctx.restore() } // 恢复状态 this.ctx.restore() // 绘制中心圆盘 this.ctx.beginPath() this.ctx.arc(centerX, centerY, radius * 0.2, 0, 2 * Math.PI) this.ctx.fillStyle = '#FF8787' this.ctx.fill() this.ctx.strokeStyle = '#FFFFFF' this.ctx.lineWidth = 3 this.ctx.stroke() // 绘制指针 - 固定在顶部中央 this.ctx.beginPath() // 三角形指针 this.ctx.moveTo(centerX, centerY - radius - 10) this.ctx.lineTo(centerX - 15, centerY - radius * 0.8) this.ctx.lineTo(centerX + 15, centerY - radius * 0.8) this.ctx.closePath() this.ctx.fillStyle = '#FF6B6B' this.ctx.fill() this.ctx.strokeStyle = '#FFFFFF' this.ctx.lineWidth = 2 this.ctx.stroke() // 绘制中心文字 this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' this.ctx.fillStyle = '#FFFFFF' this.ctx.font = '18px sans-serif' // 绘制两行文字 this.ctx.fillText('开始', centerX, centerY - 10) this.ctx.fillText('抽奖', centerX, centerY + 10) } aboutToDisappear() { // 清理定时器 if (this.spinTimer !== 0) { clearInterval(this.spinTimer) // 改成 clearInterval this.spinTimer = 0 } } build() { Column() { // 标题 Text('幸运大转盘') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 20 }) // 抽奖结果显示 Text(this.result) .fontSize(20) .fontColor(Color.White) .backgroundColor('#1AFFFFFF') .width('90%') .textAlign(TextAlign.Center) .padding(15) .borderRadius(16) .margin({ bottom: 30 }) // 转盘容器 Stack({ alignContent: Alignment.Center }) { // 使用Canvas绘制转盘 Canvas(this.ctx) .width('100%') .height('100%') .onReady(() => { // 获取Canvas尺寸 this.canvasWidth = this.ctx.width this.canvasHeight = this.ctx.height // 初始绘制转盘 this.drawWheel() }) // 中央开始按钮 Button({ type: ButtonType.Circle }) { Text('开始\n抽奖') .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width(80) .height(80) .backgroundColor('#FF6B6B') .onClick(() => this.startSpin()) .enabled(!this.isSpinning) .stateEffect(true) // 启用点击效果 } .width('90%') .aspectRatio(1) .backgroundColor('#0DFFFFFF') .borderRadius(16) .padding(15) // 底部说明 Text('奖品概率说明') .fontSize(14) .fontColor(Color.White) .opacity(0.7) .margin({ top: 20 }) // 概率说明 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { ForEach(this.prizes, (prize: PrizesItem) => { Text(`${prize.name}: ${prize.probability}%`) .fontSize(12) .fontColor(Color.White) .backgroundColor(prize.color) .borderRadius(12) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .margin(4) }) } .width('90%') .margin({ top: 10 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor(Color.Black) .linearGradient({ angle: 135, colors: [ ['#1A1B25', 0], ['#2D2E3A', 1] ] }) .expandSafeArea() } } 总结本教程对 Canvas 的使用有一定难度,建议先点赞收藏。这个幸运大转盘效果包含以下知识点:使用Canvas绘制转盘,支持自定义奖品数量和概率平滑的旋转动画和减速效果基于概率权重的抽奖算法美观的UI设计和交互效果在实际应用中,你还可以进一步扩展这个组件:添加音效实现3D效果添加中奖历史记录连接后端API获取真实抽奖结果添加抽奖次数限制希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的点赞、评论、收藏。
  • [技术干货] 鸿蒙特效教程07-九宫格幸运抽奖
    鸿蒙特效教程07-九宫格幸运抽奖在移动应用中,抽奖功能是一种常见且受欢迎的交互方式,能够有效提升用户粘性。本教程将带领大家从零开始,逐步实现一个九宫格抽奖效果,适合HarmonyOS开发的初学者阅读。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库最终效果预览我们将实现一个经典的九宫格抽奖界面,包含以下核心功能:3×3网格布局展示奖品点击中间按钮启动抽奖高亮格子循环移动的动画效果动态变速,模拟真实抽奖过程预设中奖结果的展示实现步骤步骤一:创建基本结构和数据模型首先,我们需要创建一个基础页面结构和定义数据模型。通过定义奖品的数据结构,为后续的九宫格布局做准备。// 定义奖品项的接口 interface PrizeItem { id: number name: string icon: ResourceStr color: string } @Entry @Component struct LuckyDraw { // 基本页面结构 build() { Column() { Text('幸运抽奖') .fontSize(24) .fontColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#121212') } } 在这一步,我们定义了PrizeItem接口来规范奖品的数据结构,并创建了一个基本的页面结构,只包含一个标题。步骤二:创建奖品数据和状态管理接下来,我们添加具体的奖品数据,并定义抽奖功能所需的状态变量。@Entry @Component struct LuckyDraw { // 定义奖品数组 @State prizes: PrizeItem[] = [ { id: 1, name: '谢谢参与', icon: $r('app.media.startIcon'), color: '#FF9500' }, { id: 2, name: '10积分', icon: $r('app.media.startIcon'), color: '#34C759' }, { id: 3, name: '优惠券', icon: $r('app.media.startIcon'), color: '#007AFF' }, { id: 8, name: '1元红包', icon: $r('app.media.startIcon'), color: '#FF3B30' }, { id: 0, name: '开始\n抽奖', icon: $r('app.media.startIcon'), color: '#FF2D55' }, { id: 4, name: '5元红包', icon: $r('app.media.startIcon'), color: '#5856D6' }, { id: 7, name: '免单券', icon: $r('app.media.startIcon'), color: '#E73C39' }, { id: 6, name: '50积分', icon: $r('app.media.startIcon'), color: '#38B0DE' }, { id: 5, name: '会员卡', icon: $r('app.media.startIcon'), color: '#39A5DC' }, ] // 当前高亮的奖品索引 @State currentIndex: number = -1 // 是否正在抽奖 @State isRunning: boolean = false // 中奖结果 @State result: string = '点击开始抽奖' build() { // 页面结构保持不变 } } 在这一步,我们添加了以下内容:创建了一个包含9个奖品的数组,每个奖品都有id、名称、图标和颜色属性添加了三个状态变量:currentIndex:跟踪当前高亮的奖品索引isRunning:标记抽奖是否正在进行result:记录并显示抽奖结果步骤三:实现九宫格布局现在我们来实现九宫格的基本布局,使用Grid组件和ForEach循环遍历奖品数组。build() { Column({ space: 30 }) { // 标题 Text('幸运抽奖') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(Color.White) // 结果显示区域 Column() { Text(this.result) .fontSize(20) .fontColor(Color.White) } .width('90%') .padding(15) .backgroundColor('#0DFFFFFF') .borderRadius(16) // 九宫格抽奖区域 Grid() { ForEach(this.prizes, (prize: PrizeItem, index) => { GridItem() { Column() { if (index === 4) { // 中间的开始按钮 Button({ type: ButtonType.Capsule }) { Text(prize.name) .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width('90%') .height('90%') .backgroundColor(prize.color) } else { // 普通奖品格子 Image(prize.icon) .width(40) .height(40) Text(prize.name) .fontSize(14) .fontColor(Color.White) .margin({ top: 8 }) .textAlign(TextAlign.Center) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(prize.color) .borderRadius(12) .padding(10) } }) } .columnsTemplate('1fr 1fr 1fr') .rowsTemplate('1fr 1fr 1fr') .columnsGap(10) .rowsGap(10) .width('90%') .aspectRatio(1) .backgroundColor('#0DFFFFFF') .borderRadius(16) .padding(10) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor(Color.Black) .linearGradient({ angle: 135, colors: [ ['#121212', 0], ['#242424', 1] ] }) } 在这一步,我们实现了以下内容:创建了整体的页面布局,包括标题、结果显示区域和九宫格区域使用 Grid 组件创建3×3的网格布局使用 ForEach 遍历奖品数组,为每个奖品创建一个格子根据索引判断,为中间位置创建"开始抽奖"按钮,其他位置显示奖品信息为每个格子设置了合适的样式和背景色步骤四:实现高亮效果和点击事件接下来,我们要实现格子的高亮效果,并添加点击事件处理。build() { Column({ space: 30 }) { // 前面的代码保持不变... // 九宫格抽奖区域 Grid() { ForEach(this.prizes, (prize: PrizeItem, index) => { GridItem() { Column() { if (index === 4) { // 中间的开始按钮 Button({ type: ButtonType.Capsule }) { Text(prize.name) .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width('90%') .height('90%') .backgroundColor(prize.color) .onClick(() => this.startLottery()) // 添加点击事件 } else { // 普通奖品格子 Image(prize.icon) .width(40) .height(40) Text(prize.name) .fontSize(14) .fontColor(index === this.currentIndex ? prize.color : Color.White) // 高亮时修改文字颜色 .margin({ top: 8 }) .textAlign(TextAlign.Center) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(index === this.currentIndex && index !== 4 ? Color.White : prize.color) // 高亮时切换背景色 .borderRadius(12) .padding(10) .animation({ // 添加动画效果 duration: 200, curve: Curve.EaseInOut }) } }) } // Grid的其他属性保持不变... } // Column的属性保持不变... } // 添加开始抽奖的空方法 startLottery() { // 在下一步实现 } 在这一步,我们:为中间的"开始抽奖"按钮添加了点击事件处理方法startLottery()实现了格子高亮效果:当格子被选中时(index === this.currentIndex),背景色变为白色,文字颜色变为奖品颜色添加了动画效果,使高亮切换更加平滑预定义了startLottery()方法,暂时为空实现步骤五:实现抽奖动画逻辑现在我们来实现抽奖动画的核心逻辑,包括循环高亮、速度变化和结果控制。@Entry @Component struct LuckyDraw { // 前面的状态变量保持不变... // 添加动画控制相关变量 private timer: number = 0 private speed: number = 100 private totalRounds: number = 30 private currentRound: number = 0 private targetIndex: number = 2 // 假设固定中奖"优惠券" // 开始抽奖 startLottery() { if (this.isRunning) { return // 防止重复点击 } this.isRunning = true this.result = '抽奖中...' this.currentRound = 0 this.speed = 100 // 启动动画 this.runLottery() } // 运行抽奖动画 runLottery() { if (this.timer) { clearTimeout(this.timer) } this.timer = setTimeout(() => { // 更新当前高亮的格子 this.currentIndex = (this.currentIndex + 1) % 9 if (this.currentIndex === 4) { // 跳过中间的"开始抽奖"按钮 this.currentIndex = 5 } this.currentRound++ // 增加速度变化,模拟减速效果 if (this.currentRound > this.totalRounds * 0.7) { this.speed += 10 // 大幅减速 } else if (this.currentRound > this.totalRounds * 0.5) { this.speed += 5 // 小幅减速 } // 结束条件判断 if (this.currentRound >= this.totalRounds && this.currentIndex === this.targetIndex) { // 抽奖结束 this.isRunning = false this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}` } else { // 继续动画 this.runLottery() } }, this.speed) } // 组件销毁时清除定时器 aboutToDisappear() { if (this.timer) { clearTimeout(this.timer) this.timer = 0 } } // build方法保持不变... } 在这一步,我们实现了抽奖动画的核心逻辑:添加了动画控制相关变量:timer:用于存储定时器IDspeed:控制动画速度totalRounds:总共旋转的轮数currentRound:当前已旋转的轮数targetIndex:预设的中奖索引实现了startLottery()方法:防止重复点击初始化抽奖状态调用runLottery()开始动画实现了runLottery()方法:使用setTimeout创建循环动画更新高亮格子的索引,并跳过中间的开始按钮根据进度增加延迟时间,模拟减速效果根据条件判断是否结束动画递归调用自身形成动画循环添加了aboutToDisappear()生命周期方法,确保在组件销毁时清除定时器,避免内存泄漏完整代码最后,我们对代码进行完善和优化,确保抽奖功能正常工作并提升用户体验。完整的代码如下:interface PrizeItem { id: number name: string icon: ResourceStr color: string } @Entry @Component struct LuckyDraw { // 定义奖品数组 @State prizes: PrizeItem[] = [ { id: 1, name: '谢谢参与', icon: $r('app.media.startIcon'), color: '#FF9500' }, { id: 2, name: '10积分', icon: $r('app.media.startIcon'), color: '#34C759' }, { id: 3, name: '优惠券', icon: $r('app.media.startIcon'), color: '#007AFF' }, { id: 8, name: '1元红包', icon: $r('app.media.startIcon'), color: '#FF3B30' }, { id: 0, name: '开始\n抽奖', icon: $r('app.media.startIcon'), color: '#FF2D55' }, { id: 4, name: '5元红包', icon: $r('app.media.startIcon'), color: '#5856D6' }, { id: 7, name: '免单券', icon: $r('app.media.startIcon'), color: '#E73C39' }, { id: 6, name: '50积分', icon: $r('app.media.startIcon'), color: '#38B0DE' }, { id: 5, name: '会员卡', icon: $r('app.media.startIcon'), color: '#39A5DC' }, ] // 当前高亮的奖品索引 @State currentIndex: number = -1 // 是否正在抽奖 @State isRunning: boolean = false // 中奖结果 @State result: string = '点击下方按钮开始抽奖' // 动画定时器 private timer: number = 0 // 动画速度控制 private speed: number = 100 private totalRounds: number = 30 private currentRound: number = 0 // 预设中奖索引(可以根据概率随机生成) private targetIndex: number = 2 // 假设固定中奖"优惠券" // 开始抽奖 startLottery() { if (this.isRunning) { return } this.isRunning = true this.result = '抽奖中...' this.currentRound = 0 this.speed = 100 // 启动动画 this.runLottery() } // 运行抽奖动画 runLottery() { if (this.timer) { clearTimeout(this.timer) } this.timer = setTimeout(() => { // 更新当前高亮的格子 this.currentIndex = (this.currentIndex + 1) % 9 if (this.currentIndex === 4) { // 跳过中间的"开始抽奖"按钮 this.currentIndex = 5 } this.currentRound++ // 增加速度变化,模拟减速效果 if (this.currentRound > this.totalRounds * 0.7) { this.speed += 10 } else if (this.currentRound > this.totalRounds * 0.5) { this.speed += 5 } // 结束条件判断 if (this.currentRound >= this.totalRounds && this.currentIndex === this.targetIndex) { // 抽奖结束 this.isRunning = false this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}` } else { // 继续动画 this.runLottery() } }, this.speed) } // 组件销毁时清除定时器 aboutToDisappear() { if (this.timer) { clearTimeout(this.timer) this.timer = 0 } } build() { Column({ space: 30 }) { // 标题 Text('幸运抽奖') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(Color.White) // 结果显示 Column() { Text(this.result) .fontSize(20) .fontColor(Color.White) } .width('90%') .padding(15) .backgroundColor('#0DFFFFFF') .borderRadius(16) // 九宫格抽奖区域 Grid() { ForEach(this.prizes, (prize: PrizeItem, index) => { GridItem() { Column() { if (index === 4) { // 中间的开始按钮 Button({ type: ButtonType.Capsule }) { Text(prize.name) .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .fontColor(Color.White) } .width('90%') .height('90%') .backgroundColor(prize.color) .onClick(() => this.startLottery()) } else { // 普通奖品格子 Image(prize.icon) .width(40) .height(40) Text(prize.name) .fontSize(14) .fontColor(index === this.currentIndex && index !== 4 ? prize.color : Color.White) .margin({ top: 8 }) .textAlign(TextAlign.Center) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(index === this.currentIndex && index !== 4 ? Color.White : prize.color) .borderRadius(12) .padding(10) .animation({ duration: 200, curve: Curve.EaseInOut }) } }) } .columnsTemplate('1fr 1fr 1fr') .rowsTemplate('1fr 1fr 1fr') .columnsGap(10) .rowsGap(10) .width('90%') .aspectRatio(1) .backgroundColor('#0DFFFFFF') .borderRadius(16) .padding(10) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor(Color.Black) .linearGradient({ angle: 135, colors: [ ['#121212', 0], ['#242424', 1] ] }) .expandSafeArea() // 颜色扩展到安全区域 } } 核心概念解析1. Grid组件Grid组件是实现九宫格布局的核心,它具有以下重要属性:columnsTemplate:定义网格的列模板。'1fr 1fr 1fr'表示三列等宽布局。rowsTemplate:定义网格的行模板。'1fr 1fr 1fr'表示三行等高布局。columnsGap和rowsGap:设置列和行之间的间距。aspectRatio:设置宽高比,确保网格是正方形。2. 动画实现原理抽奖动画的核心是通过定时器和状态更新实现的:循环高亮:通过setTimeout定时更新currentIndex状态,实现格子的循环高亮。动态速度:随着循环轮数的增加,逐渐增加延迟时间(this.speed += 10),实现减速效果。结束条件:当满足两个条件时停止动画:已完成设定的总轮数(this.currentRound >= this.totalRounds)当前高亮的格子是目标奖品(this.currentIndex === this.targetIndex)3. 高亮效果格子的高亮效果是通过条件样式实现的:.backgroundColor(index === this.currentIndex && index !== 4 ? Color.White : prize.color) 当格子被选中时(index === this.currentIndex),背景色变为白色,文字颜色变为奖品颜色,产生对比鲜明的高亮效果。4. 资源清理在组件销毁时,我们需要清除定时器以避免内存泄漏:aboutToDisappear() { if (this.timer) { clearTimeout(this.timer) this.timer = 0 } } 进阶优化思路完成基本功能后,可以考虑以下优化方向:1. 随机中奖结果目前中奖结果是固定的,可以实现一个随机算法,根据概率分配不同奖品:// 根据概率生成中奖索引 generatePrizeIndex() { // 定义各奖品的概率权重 const weights = [50, 10, 5, 3, 0, 2, 1, 8, 20]; // 数字越大概率越高 const totalWeight = weights.reduce((a, b) => a + b, 0); // 生成随机数 const random = Math.random() * totalWeight; // 根据权重决定中奖索引 let currentWeight = 0; for (let i = 0; i < weights.length; i++) { if (i === 4) continue; // 跳过中间的"开始抽奖"按钮 currentWeight += weights[i]; if (random < currentWeight) { return i; } } return 0; // 默认返回第一个奖品 } 2. 抽奖音效添加音效可以提升用户体验:// 播放抽奖音效 playSound(type: 'start' | 'running' | 'end') { // 根据不同阶段播放不同音效 } 3. 振动反馈在抽奖开始和结束时添加振动反馈:// 导入振动模块 import { vibrator } from '@kit.SensorServiceKit'; // 触发振动 triggerVibration() { vibrator.vibrate(50); // 振动50毫秒 } 4. 抽奖次数限制添加抽奖次数限制和剩余次数显示:@State remainingTimes: number = 3; // 剩余抽奖次数 startLottery() { if (this.isRunning || this.remainingTimes <= 0) { return; } this.remainingTimes--; // 其他抽奖逻辑... } 总结本教程从零开始,一步步实现了九宫格抽奖效果,涵盖了以下关键内容:数据结构定义和状态管理网格布局和循环渲染条件样式和动画效果定时器控制和动态速度生命周期管理和资源清理希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的点赞、评论、收藏。
  • [技术干货] 鸿蒙特效教程06-可拖拽网格实现教程
    鸿蒙特效教程06-可拖拽网格实现教程本教程适合 HarmonyOS Next 初学者,通过简单到复杂的步骤,一步步实现类似桌面APP中的可拖拽编辑效果。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库效果预览我们要实现的效果是一个 Grid 网格布局,用户可以通过长按并拖动来调整应用图标的位置顺序。拖拽完成后,底部会显示当前的排序结果。实现步骤步骤一:创建基本结构和数据模型首先,我们需要创建一个基本的页面结构和数据模型。我们将定义一个应用名称数组和一个对应的颜色数组。@Entry @Component struct DragGrid { // 应用名称数组 @State apps: string[] = [ '微信', '支付宝', 'QQ', '抖音', '快手', '微博', '头条', '网易云' ]; build() { Column() { // 这里将放置我们的应用网格 Text('应用网格示例') .fontSize(20) .fontColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#121212') } } 这个基本结构包含一个应用名称数组和一个简单的Column容器。在这个阶段,我们只是显示一个标题文本。步骤二:使用Grid布局展示应用图标接下来,我们将使用Grid组件来创建网格布局,并使用ForEach遍历应用数组,为每个应用创建一个网格项。@Entry @Component struct DragGrid { @State apps: string[] = [ '微信', '支付宝', 'QQ', '抖音', '快手', '微博', '头条', '网易云' ]; build() { Column() { // 使用Grid组件创建网格布局 Grid() { ForEach(this.apps, (item: string) => { GridItem() { Column() { // 应用图标(暂用占位图) Image($r('app.media.startIcon')) .width(60) .aspectRatio(1) // 应用名称 Text(item) .fontSize(12) .fontColor(Color.White) } .padding(10) } }) } .columnsTemplate('1fr 1fr 1fr 1fr') // 4列等宽布局 .rowsGap(10) // 行间距 .columnsGap(10) // 列间距 .padding(20) // 内边距 } .width('100%') .height('100%') .backgroundColor('#121212') } } 在这一步,我们添加了Grid组件,它具有以下关键属性:columnsTemplate:定义网格的列模板,'1fr 1fr 1fr 1fr’表示四列等宽布局。rowsGap:行间距,设置为10。columnsGap:列间距,设置为10。padding:内边距,设置为20。每个GridItem包含一个Column布局,里面有一个Image(应用图标)和一个Text(应用名称)。步骤三:优化图标布局和样式现在我们有了基本的网格布局,接下来优化图标的样式和布局。我们将创建一个自定义的Builder函数来构建每个应用图标项,并添加一些颜色来区分不同应用。@Entry @Component struct DragGrid { @State apps: string[] = [ '微信', '支付宝', 'QQ', '抖音', '快手', '微博', '头条', '网易云', '腾讯视频', '爱奇艺', '优酷', 'B站' ]; // 定义应用图标颜色 private appColors: string[] = [ '#34C759', '#007AFF', '#5856D6', '#FF2D55', '#FF9500', '#FF3B30', '#E73C39', '#D33A31', '#38B0DE', '#39A5DC', '#22C8BD', '#00A1D6' ]; // 创建应用图标项的构建器 @Builder itemBuilder(name: string, index: number) { Column({ space: 2 }) { // 应用图标 Image($r('app.media.startIcon')) .width(80) .padding(10) .aspectRatio(1) .backgroundColor(this.appColors[index % this.appColors.length]) .borderRadius(16) // 应用名称 Text(name) .fontSize(12) .fontColor(Color.White) } } build() { Column() { Grid() { ForEach(this.apps, (item: string, index: number) => { GridItem() { this.itemBuilder(item, index) } }) } .columnsTemplate('1fr 1fr 1fr 1fr') .rowsGap(20) .columnsGap(20) .padding(20) } .width('100%') .height('100%') .backgroundColor('#121212') } } 在这一步,我们:添加了appColors数组,定义了各个应用图标的背景颜色。创建了@Builder itemBuilder函数,用于构建每个应用图标项,使代码更加模块化。为图标添加了背景颜色和圆角边框,使其更加美观。在ForEach中添加了index参数,用于获取当前项的索引,以便为不同应用使用不同的颜色。步骤四:添加拖拽功能现在我们有了美观的网格布局,下一步是添加拖拽功能。我们需要设置Grid的editMode属性为true,并添加相应的拖拽事件处理函数。@Entry @Component struct DragGrid { @State apps: string[] = [ '微信', '支付宝', 'QQ', '抖音', '快手', '微博', '头条', '网易云', '腾讯视频', '爱奇艺', '优酷', 'B站' ]; private appColors: string[] = [ '#34C759', '#007AFF', '#5856D6', '#FF2D55', '#FF9500', '#FF3B30', '#E73C39', '#D33A31', '#38B0DE', '#39A5DC', '#22C8BD', '#00A1D6' ]; @Builder itemBuilder(name: string) { Column({ space: 2 }) { Image($r('app.media.startIcon')) .draggable(false) // 禁止图片本身被拖拽 .width(80) .padding(10) .aspectRatio(1) Text(name) .fontSize(12) .fontColor(Color.White) } } // 交换两个应用的位置 changeIndex(a: number, b: number) { let temp = this.apps[a]; this.apps[a] = this.apps[b]; this.apps[b] = temp; } build() { Column() { Grid() { ForEach(this.apps, (item: string) => { GridItem() { this.itemBuilder(item) } }) } .columnsTemplate('1fr 1fr 1fr 1fr') .rowsGap(20) .columnsGap(20) .padding(20) .supportAnimation(true) // 启用动画 .editMode(true) // 启用编辑模式 // 拖拽开始事件 .onItemDragStart((_event: ItemDragInfo, itemIndex: number) => { return this.itemBuilder(this.apps[itemIndex]); }) // 拖拽放置事件 .onItemDrop((_event: ItemDragInfo, itemIndex: number, insertIndex: number, isSuccess: boolean) => { if (!isSuccess || insertIndex >= this.apps.length) { return; } this.changeIndex(itemIndex, insertIndex); }) .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#121212') } } 在这一步,我们添加了拖拽功能的关键部分:设置supportAnimation(true)来启用动画效果。设置editMode(true)来启用编辑模式,这是实现拖拽功能的必要设置。添加onItemDragStart事件处理函数,当用户开始拖拽时触发,返回被拖拽项的UI表示。添加onItemDrop事件处理函数,当用户放置拖拽项时触发,处理位置交换逻辑。创建changeIndex方法,用于交换数组中两个元素的位置。在Image上设置draggable(false),确保是整个GridItem被拖拽,而不是图片本身。步骤五:添加排序结果展示为了让用户更直观地看到排序结果,我们在网格下方添加一个区域,用于显示当前的应用排序结果。@Entry @Component struct DragGrid { // 前面的代码保持不变... build() { Column() { // 应用网格部分保持不变... // 添加排序结果展示区域 Column({ space: 10 }) { Text('应用排序结果') .fontSize(16) .fontColor(Color.White) Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { ForEach(this.apps, (item: string, index: number) => { Text(item) .fontSize(12) .fontColor(Color.White) .backgroundColor(this.appColors[index % this.appColors.length]) .borderRadius(12) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .margin(4) }) } .width('100%') } .width('100%') .padding(10) .backgroundColor('#0DFFFFFF') // 半透明背景 .borderRadius({ topLeft: 20, topRight: 20 }) // 上方圆角 } .width('100%') .height('100%') .backgroundColor('#121212') } } 在这一步,我们添加了一个展示排序结果的区域:使用Column容器,顶部显示"应用排序结果"的标题。使用Flex布局,设置wrap: FlexWrap.Wrap允许内容换行,justifyContent: FlexAlign.Center使内容居中对齐。使用ForEach循环遍历应用数组,为每个应用创建一个带有背景色的文本标签。为结果区域添加半透明背景和上方圆角,使其更加美观。步骤六:美化界面最后,我们美化整个界面,添加渐变背景和一些视觉改进。@Entry @Component struct DragGrid { // 前面的代码保持不变... build() { Column() { // 网格和结果区域代码保持不变... } .width('100%') .height('100%') .expandSafeArea() // 扩展到安全区域 .linearGradient({ // 渐变背景 angle: 135, colors: [ ['#121212', 0], ['#242424', 1] ] }) } } 在这一步,我们:添加expandSafeArea()确保内容可以扩展到设备的安全区域。使用linearGradient创建渐变背景,角度为135度,从深色(#121212)渐变到稍浅的色调(#242424)。完整代码以下是完整的实现代码:@Entry @Component struct DragGrid { // 应用名称数组,用于显示和排序 @State apps: string[] = [ '微信', '支付宝', 'QQ', '抖音', '快手', '微博', '头条', '网易云', '腾讯视频', '爱奇艺', '优酷', 'B站', '小红书', '美团', '饿了么', '滴滴', '高德', '携程' ]; // 定义应用图标对应的颜色数组 private appColors: string[] = [ '#34C759', '#007AFF', '#5856D6', '#FF2D55', '#FF9500', '#FF3B30', '#E73C39', '#D33A31', '#38B0DE', '#39A5DC', '#22C8BD', '#00A1D6', '#FF3A31', '#FFD800', '#4290F7', '#FF7700', '#4AB66B', '#2A9AF1' ]; /** * 构建单个应用图标项 * @param name 应用名称 * @return 返回应用图标的UI组件 */ @Builder itemBuilder(name: string) { // 垂直布局,包含图标和文字 Column({ space: 2 }) { // 应用图标图片 Image($r('app.media.startIcon')) .draggable(false)// 禁止图片本身被拖拽,确保整个GridItem被拖拽 .width(80) .aspectRatio(1)// 保持1:1的宽高比 .padding(10) // 应用名称文本 Text(name) .fontSize(12) .fontColor(Color.White) } } /** * 交换两个应用在数组中的位置 * @param a 第一个索引 * @param b 第二个索引 */ changeIndex(a: number, b: number) { // 使用临时变量交换两个元素位置 let temp = this.apps[a]; this.apps[a] = this.apps[b]; this.apps[b] = temp; } /** * 构建组件的UI结构 */ build() { // 主容器,垂直布局 Column() { // 应用网格区域 Grid() { // 遍历所有应用,为每个应用创建一个网格项 ForEach(this.apps, (item: string) => { GridItem() { // 使用自定义builder构建网格项内容 this.itemBuilder(item) } }) } // 网格样式和行为设置 .columnsTemplate('1fr '.repeat(4)) // 设置4列等宽布局 .columnsGap(20) // 列间距 .rowsGap(20) // 行间距 .padding(20) // 内边距 .supportAnimation(true) // 启用动画支持 .editMode(true) // 启用编辑模式,允许拖拽 // 拖拽开始事件处理 .onItemDragStart((_event: ItemDragInfo, itemIndex: number) => { // 返回被拖拽项的UI return this.itemBuilder(this.apps[itemIndex]); }) // 拖拽放置事件处理 .onItemDrop((_event: ItemDragInfo, itemIndex: number, insertIndex: number, isSuccess: boolean) => { // 如果拖拽失败或目标位置无效,则不执行操作 if (!isSuccess || insertIndex >= this.apps.length) { return; } // 交换元素位置 this.changeIndex(itemIndex, insertIndex); }) .layoutWeight(1) // 使网格区域占用剩余空间 // 结果显示区域 Column({ space: 10 }) { // 标题文本 Text('应用排序结果') .fontSize(16) .fontColor(Color.White) // 弹性布局,允许换行 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { // 遍历应用数组,为每个应用创建一个彩色标签 ForEach(this.apps, (item: string, index: number) => { Text(item) .fontSize(12) .fontColor(Color.White) .backgroundColor(this.appColors[index % this.appColors.length]) .borderRadius(12) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .margin(4) }) } .width('100%') } .width('100%') .padding(10) // 内边距 .backgroundColor('#0DFFFFFF') // 半透明背景 .expandSafeArea() // 扩展到安全区域 .borderRadius({ topLeft: 20, topRight: 20 }) // 上左右圆角 } // 主容器样式设置 .width('100%') .height('100%') .expandSafeArea() // 扩展到安全区域 .linearGradient({ angle: 135, // 渐变角度 colors: [ ['#121212', 0], // 起点色 ['#242424', 1] // 终点色 ] }) } } Grid组件的关键属性详解Grid是鸿蒙系统中用于创建网格布局的重要组件,它有以下关键属性:columnsTemplate: 定义网格的列模板。例如'1fr 1fr 1fr 1fr'表示四列等宽布局。'1fr’中的’fr’是fraction(分数)的缩写,表示按比例分配空间。rowsTemplate: 定义网格的行模板。如果不设置,行高将根据内容自动调整。columnsGap: 列之间的间距。rowsGap: 行之间的间距。editMode: 是否启用编辑模式。设置为true时启用拖拽功能。supportAnimation: 是否支持动画。设置为true时,拖拽过程中会有平滑的动画效果。拖拽功能的关键事件详解实现拖拽功能主要依赖以下事件:onItemDragStart: 当用户开始拖拽某个项时触发。参数:event(拖拽事件信息),itemIndex(被拖拽项的索引)返回值:被拖拽项的UI表示onItemDrop: 当用户放置拖拽项时触发。参数:event(拖拽事件信息),itemIndex(原始位置索引),insertIndex(目标位置索引),isSuccess(是否成功)功能:处理元素位置交换逻辑此外,还有一些可选的事件可以用于增强拖拽体验:onItemDragEnter: 当拖拽项进入某个位置时触发。onItemDragMove: 当拖拽项在网格中移动时触发。onItemDragLeave: 当拖拽项离开某个位置时触发。小结与进阶提示通过本教程,我们实现了一个功能完整的可拖拽应用网格界面。主要学习了以下内容:使用Grid组件创建网格布局使用@Builder创建可复用的UI构建函数实现拖拽排序功能优化UI和用户体验进阶提示:可以添加长按震动反馈,增强交互体验。可以实现数据持久化,保存用户的排序结果。可以添加编辑模式切换,只有在特定模式下才允许拖拽排序。可以为拖拽过程添加更丰富的动画效果,如缩放、阴影等。希望本教程对你有所帮助,让你掌握鸿蒙系统中Grid组件和拖拽功能的使用方法!
  • [技术干货] 鸿蒙特效教程05-鸿蒙很开门
    鸿蒙特效教程05-鸿蒙很开门本教程适合HarmonyOS初学者,通过简单到复杂的步骤,通过 Stack 层叠布局 + animation 动画,一步步实现这个"鸿蒙很开门"特效。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库最终效果预览屏幕上有一个双开门,点击中间的按钮后,两侧门会向打开,露出开门后面的内容。当用户再次点击按钮时,门会关闭。实现步骤我们将通过以下步骤逐步构建这个效果:用层叠布局搭建基础UI结构用层叠布局创建门的装饰实现开关门动画效果步骤1:搭建基础UI结构首先,我们需要创建一个基本的页面结构。在这个效果中,最关键的是使用Stack组件来实现层叠效果。@Entry @Component struct OpenTheDoor { build() { Stack() { // 背景层 Column() { Text('鸿蒙很开门') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#1E2247') // 按钮 Button({ type: ButtonType.Circle }) { Text('开') .fontSize(20) .fontColor(Color.White) } .width(60) .height(60) .backgroundColor('#4CAF50') .position({ x: '50%', y: '85%' }) .translate({ x: '-50%', y: '-50%' }) } .width('100%') .height('100%') .backgroundColor(Color.Black) } } 代码说明:Stack组件是一个层叠布局容器,子组件会按照添加顺序从底到顶叠放。我们首先放置了一个背景层,它包含了将来门打开后要显示的内容。然后放置了一个圆形按钮,用于触发开门动作。使用position和translate组合定位按钮在屏幕底部中间。此时,只有一个简单的背景和按钮,还没有门的效果。步骤2:创建门的设计接下来,我们在Stack层叠布局中添加左右两扇门:@Entry @Component struct OpenTheDoor { build() { Stack() { // 背景层 Column() { Text('鸿蒙很开门') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#1E2247') // 左门 Stack() { // 门本体 Column() .width('96%') .height('100%') .backgroundColor('#333333') .borderWidth({ right: 2 }) .borderColor('#444444') // 门上装饰 Column() { Circle() .width(40) .height(40) .fill('#666666') Rect() .width(120) .height(200) .radiusWidth(10) .stroke('#555555') .strokeWidth(2) .fill('none') .margin({ top: 40 }) } .width('80%') .alignItems(HorizontalAlign.Center) } .width('50%') .height('100%') // 右门 Stack() { // 门本体 Column() .width('96%') .height('100%') .backgroundColor('#333333') .borderWidth({ left: 2 }) .borderColor('#444444') // 门上装饰 Column() { Circle() .width(40) .height(40) .fill('#666666') Rect() .width(120) .height(200) .radiusWidth(10) .stroke('#555555') .strokeWidth(2) .fill('none') .margin({ top: 40 }) } .width('80%') .alignItems(HorizontalAlign.Center) } .width('50%') .height('100%') // 门框 Column() .width('100%') .height('100%') .border({ width: 8, color: '#666' }) // 按钮 Button({ type: ButtonType.Circle }) { Text('开') .fontSize(20) .fontColor(Color.White) } .width(60) .height(60) .backgroundColor('#4CAF50') .position({ x: '50%', y: '85%' }) .translate({ x: '-50%', y: '-50%' }) } .width('100%') .height('100%') .backgroundColor(Color.Black) } } 代码说明:我们添加了左右两扇门,每扇门占屏幕宽度的50%。每扇门自身是一个Stack,包含门本体和装饰元素。门本体使用Column组件,设置背景色和边框。装饰元素包括圆形"门把手"和矩形装饰。添加门框作为装饰元素,增强立体感。使用zIndex控制层叠顺序(虽然代码中未显示,但在最终代码中会用到)。此时我们有了一个静态的门的外观,但它还不能打开和关闭。步骤3:实现开关门动画现在我们需要添加状态变量和动画逻辑,使门能够打开和关闭:@Entry @Component struct OpenTheDoor { // 门打开的最大位移(百分比) private doorOpenMaxOffset: number = 110 // 当前门打开的位移 @State doorOpenOffset: number = 0 // 是否正在动画中 @State isAnimating: boolean = false // 切换门的状态 toggleDoor() { this.isAnimating = true if (this.doorOpenOffset <= 0) { // 开门动画 animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false } }, () => { this.doorOpenOffset = this.doorOpenMaxOffset }) } else { // 关门动画 animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false } }, () => { this.doorOpenOffset = 0 }) } } build() { Stack() { // 背景层(保持不变) ... // 左门 Stack() { // 门本体和装饰(保持不变) ... } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : (-this.doorOpenOffset) + '%' }) // 右门 Stack() { // 门本体和装饰(保持不变) ... } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : this.doorOpenOffset + '%' }) // 门框(保持不变) ... // 按钮 Button({ type: ButtonType.Circle }) { Text(this.doorOpenOffset > 0 ? '关' : '开') .fontSize(20) .fontColor(Color.White) } .width(60) .height(60) .backgroundColor(this.doorOpenOffset > 0 ? '#FF5252' : '#4CAF50') .position({ x: '50%', y: '85%' }) .translate({ x: '-50%', y: '-50%' }) .onClick(() => { if (!this.isAnimating) { this.toggleDoor() } }) } .width('100%') .height('100%') .backgroundColor(Color.Black) } } 代码说明:添加了状态变量:doorOpenMaxOffset: 门打开的最大位移doorOpenOffset: 当前门的位移状态isAnimating: 标记动画是否正在进行使用translate属性绑定到doorOpenOffset状态,实现门的移动效果:左门向左移动:translate({ x: (-this.doorOpenOffset) + '%' })右门向右移动:translate({ x: this.doorOpenOffset + '%' })实现toggleDoor方法,使用animateTo函数创建动画:animateTo是HarmonyOS中用于创建显式动画的API设置动画时长1500毫秒使用EaseInOut曲线使动画更加平滑通过改变doorOpenOffset状态触发UI更新按钮样式和文本随门的状态变化:门关闭时显示"开",背景绿色门打开时显示"关",背景红色添加点击事件调用toggleDoor方法使用isAnimating防止动画进行中重复触发此时,门可以通过动画打开和关闭,但门后的内容没有渐变效果。步骤4:添加门后内容和渐变效果现在我们为门后的内容添加渐变显示效果:@Entry @Component struct OpenTheDoor { // 已有的状态变量 private doorOpenMaxOffset: number = 110 @State doorOpenOffset: number = 0 @State isAnimating: boolean = false // 新增状态变量 @State showContent: boolean = false @State backgroundOpacity: number = 0 toggleDoor() { this.isAnimating = true if (this.doorOpenOffset <= 0) { // 开门动画 animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false this.showContent = true } }, () => { this.doorOpenOffset = this.doorOpenMaxOffset this.backgroundOpacity = 1 }) } else { // 关门动画 this.showContent = false animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false } }, () => { this.doorOpenOffset = 0 this.backgroundOpacity = 0 }) } } build() { Stack() { // 背景层 - 门后内容 Column() { Text('鸿蒙很开门') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .opacity(this.backgroundOpacity) .margin({ bottom: 20 }) Image($r('app.media.startIcon')) .width(100) .height(100) .objectFit(ImageFit.Contain) .opacity(this.backgroundOpacity) .animation({ duration: 800, curve: Curve.EaseOut, delay: 500, iterations: 1, playMode: PlayMode.Normal }) Text('探索无限可能') .fontSize(20) .fontColor(Color.White) .opacity(this.backgroundOpacity) .margin({ top: 20 }) .visibility(this.showContent ? Visibility.Visible : Visibility.Hidden) .animation({ duration: 800, curve: Curve.EaseOut, delay: 100, iterations: 1, playMode: PlayMode.Normal }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor('#1E2247') // 其他部分(左门、右门、按钮等)保持不变 ... } .width('100%') .height('100%') .backgroundColor(Color.Black) } } 代码说明:添加新的状态变量:showContent: 控制额外内容的显示与隐藏backgroundOpacity: 控制背景内容的透明度在toggleDoor方法中同时控制门的位移和内容的透明度:开门时,门位移增加到最大值,同时透明度从0变为1关门时,门位移减少到0,同时透明度从1变为0在开门动画完成后设置showContent为true,显示额外内容为内容元素添加动画效果:使用opacity属性绑定到backgroundOpacity状态为图片添加animation属性,设置渐入效果为第二段文本添加条件显示visibility属性两个元素使用不同的延迟时间,创造错落有致的动画效果这样,当门打开时,背景内容会平滑地渐入,创造更加连贯的用户体验。步骤5:优化交互体验最后,我们添加一些细节来增强交互体验:@Entry @Component struct OpenTheDoor { // 状态变量保持不变 private doorOpenMaxOffset: number = 110 @State doorOpenOffset: number = 0 @State isAnimating: boolean = false @State showContent: boolean = false @State backgroundOpacity: number = 0 // toggleDoor方法保持不变 ... build() { Stack() { // 背景层保持不变 ... // 左门和右门保持不变,但添加zIndex Stack() { ... } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : (-this.doorOpenOffset) + '%' }) .zIndex(3) Stack() { ... } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : this.doorOpenOffset + '%' }) .zIndex(3) // 门框 Column() .width('100%') .height('100%') .zIndex(5) .opacity(0.7) .border({ width: 8, color: '#666' }) // 按钮 Button({ type: ButtonType.Circle, stateEffect: true }) { Stack() { Circle() .width(60) .height(60) .fill('#00000060') if (!this.isAnimating) { // 用文本替代图片 Text(this.doorOpenOffset > 0 ? '关' : '开') .fontSize(20) .fontColor(Color.White) .fontWeight(FontWeight.Bold) } else { // 加载动效 LoadingProgress() .width(30) .height(30) .color(Color.White) } } } .width(60) .height(60) .backgroundColor(this.doorOpenOffset > 0 ? '#FF5252' : '#4CAF50') .position({ x: '50%', y: '85%' }) .translate({ x: '-50%', y: '-50%' }) .zIndex(10) .onClick(() => { if (!this.isAnimating) { this.toggleDoor() } }) } .width('100%') .height('100%') .backgroundColor(Color.Black) .expandSafeArea() } } 代码说明:添加了zIndex属性来控制组件的层叠顺序:背景内容:默认层级最低左右门:zIndex为3门框:zIndex为5,确保在门的上层按钮:zIndex为10,确保始终在最上层改进按钮状态反馈:添加stateEffect: true使按钮有按下效果在动画过程中显示LoadingProgress加载指示器非动画状态下显示"开"或"关"文本添加expandSafeArea()以全屏显示效果,覆盖刘海屏、挖孔屏的安全区域完整代码以下是完整的实现代码:@Entry @Component struct OpenTheDoor { // 门打开的位移 private doorOpenMaxOffset: number = 110 // 门打开的幅度 @State doorOpenOffset: number = 0 // 是否正在动画 @State isAnimating: boolean = false // 是否显示内容 @State showContent: boolean = false // 背景透明度 @State backgroundOpacity: number = 0 toggleDoor() { this.isAnimating = true if (this.doorOpenOffset <= 0) { // 开门动画 animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false this.showContent = true } }, () => { this.doorOpenOffset = this.doorOpenMaxOffset this.backgroundOpacity = 1 }) } else { // 关门动画 this.showContent = false animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { this.isAnimating = false } }, () => { this.doorOpenOffset = 0 this.backgroundOpacity = 0 }) } } build() { // 层叠布局 Stack() { // 背景层 - 门后内容 Column() { Text('鸿蒙很开门') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .opacity(this.backgroundOpacity) .margin({ bottom: 20 }) // 图片 Image($r('app.media.startIcon')) .width(100) .height(100) .objectFit(ImageFit.Contain) .opacity(this.backgroundOpacity) .animation({ duration: 800, curve: Curve.EaseOut, delay: 500, iterations: 1, playMode: PlayMode.Normal }) Text('探索无限可能') .fontSize(20) .fontColor(Color.White) .opacity(this.backgroundOpacity) .margin({ top: 20 }) .visibility(this.showContent ? Visibility.Visible : Visibility.Hidden) .animation({ duration: 800, curve: Curve.EaseOut, delay: 100, iterations: 1, playMode: PlayMode.Normal }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor('#1E2247') .expandSafeArea() // 左门 Stack() { // 门 Column() .width('96%') .height('100%') .backgroundColor('#333333') .borderWidth({ right: 2 }) .borderColor('#444444') // 装饰图案 Column() { // 简单的门把手和几何图案设计 Circle() .width(40) .height(40) .fill('#666666') .opacity(0.8) Rect() .width(120) .height(200) .radiusWidth(10) .stroke('#555555') .strokeWidth(2) .fill('none') .margin({ top: 40 }) // 添加门上的小装饰 Grid() { ForEach(Array.from({ length: 4 }), () => { GridItem() { Circle() .width(8) .height(8) .fill('#777777') } }) } .columnsTemplate('1fr 1fr') .rowsTemplate('1fr 1fr') .width(60) .height(60) .margin({ top: 20 }) } .width('80%') .alignItems(HorizontalAlign.Center) } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : (-this.doorOpenOffset) + '%' }) .zIndex(3) // 右门 Stack() { // 门 Column() .width('96%') .height('100%') .backgroundColor('#333333') .borderWidth({ left: 2 }) .borderColor('#444444') // 装饰图案 Column() { // 简单的门把手和几何图案设计 Circle() .width(40) .height(40) .fill('#666666') .opacity(0.8) Rect() .width(120) .height(200) .radiusWidth(10) .stroke('#555555') .strokeWidth(2) .fill('none') .margin({ top: 40 }) // 添加门上的小装饰 Grid() { ForEach(Array.from({ length: 4 }), () => { GridItem() { Circle() .width(8) .height(8) .fill('#777777') } }) } .columnsTemplate('1fr 1fr') .rowsTemplate('1fr 1fr') .width(60) .height(60) .margin({ top: 20 }) } .width('80%') .alignItems(HorizontalAlign.Center) } .width('50%') .height('100%') .translate({ x: this.doorOpenOffset <= 0 ? '0%' : this.doorOpenOffset + '%' }) .zIndex(3) // 门框 Column() .width('100%') .height('100%') .zIndex(5) .opacity(0.7) .border({ width: 8, color: '#666' }) // 控制按钮 Button({ type: ButtonType.Circle, stateEffect: true }) { Stack() { Circle() .width(60) .height(60) .fill('#00000060') if (!this.isAnimating) { // 用文本替代图片 Text(this.doorOpenOffset > 0 ? '关' : '开') .fontSize(20) .fontColor(Color.White) .fontWeight(FontWeight.Bold) } else { // 加载动效 LoadingProgress() .width(30) .height(30) .color(Color.White) } } } .width(60) .height(60) .backgroundColor(this.doorOpenOffset > 0 ? '#FF5252' : '#4CAF50') .position({ x: '50%', y: '85%' }) .translate({ x: '-50%', y: '-50%' }) .zIndex(10) // 按钮位置在最上方 .onClick(() => { // 防止多点 if (!this.isAnimating) { this.toggleDoor() } }) } .width('100%') .height('100%') .backgroundColor(Color.Black) .expandSafeArea() } } 总结与技术要点涉及了以下HarmonyOS开发中的重要技术点:1. Stack布局Stack组件是实现这种叠加效果,允许子组件按照添加顺序从底到顶叠放。使用时有以下注意点:使用 zIndex 属性控制层叠顺序使用 alignContent 参数控制子组件对齐2. 动画系统本教程中使用了两种动画机制:animateTo:显式动画API,用于创建状态变化时的过渡效果 animateTo({ duration: 1500, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal, onFinish: () => { /* 动画完成回调 */ } }, () => { // 状态变化,触发动画 this.doorOpenOffset = this.doorOpenMaxOffset }) animation:属性动画,直接在组件上定义 .animation({ duration: 800, curve: Curve.EaseOut, delay: 500, iterations: 1, playMode: PlayMode.Normal }) 3. 状态管理我们使用以下几个状态来控制整个效果:doorOpenOffset:控制门的位移isAnimating:标记动画状态,防止重复触发backgroundOpacity:控制背景内容的透明度showContent:控制特定内容的显示与隐藏4. translate 位移使用translate属性实现门的移动效果:.translate({ x: this.doorOpenOffset <= 0 ? '0%' : this.doorOpenOffset + '%' }) 扩展与改进这个效果还有很多可以改进和扩展的地方:门的样式:可以添加更多细节,如纹理、把手、贴图等开门音效:添加音效增强3D效果:添加透视效果更多内容过渡:门后可以更有趣手势交互:添加滑动手势来开关门希望这个教程能够帮助你理解HarmonyOS中的层叠布局和动画系统!
  • [技术干货] 鸿蒙特效教程04-直播点赞动画效果实现教程
    鸿蒙特效教程04-直播点赞动画效果实现教程在时下流行的直播、短视频等应用中,点赞动画是提升用户体验的重要元素。当用户点击屏幕时,屏幕上会出现飘动的点赞图标。本教程适合HarmonyOS初学者,通过简单到复杂的步骤,通过HarmonyOS的Canvas组件,一步步实现时下流行的点赞动画效果。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库效果预览我们将实现的效果是:用户点击屏幕时,在点击位置生成一个emoji表情图标,逐步添加了以下动画效果:向上移动:让图标从点击位置向上飘移非线性运动:使用幂函数让移动更加自然渐隐效果:让图标在上升过程中逐渐消失放大效果:让图标从小变大左右摆动:增加水平方向的微妙摆动1. 基础结构搭建首先,我们创建一个基本的页面结构和数据模型,用于管理点赞图标和动画。定义图标数据结构// 定义点赞图标数据结构 interface LikeIcon { x: number // X坐标 y: number // Y坐标 initialX: number // 初始X坐标 initialY: number // 初始Y坐标 radius: number // 半径 emoji: string // emoji表情 fontSize: number // 字体大小 opacity: number // 透明度 createTime: number // 创建时间 lifespan: number // 生命周期(毫秒) scale: number // 当前缩放比例 initialScale: number // 初始缩放比例 maxScale: number // 最大缩放比例 maxOffset: number // 最大摆动幅度 direction: number // 摆动方向 (+1或-1) } 这个接口定义了每个点赞图标所需的所有属性,从位置到动画参数,为后续的动画实现提供了数据基础。组件基本结构@Entry @Component struct CanvasLike { // 用来配置CanvasRenderingContext2D对象的参数,开启抗锯齿 private settings: RenderingContextSettings = new RenderingContextSettings(true) // 创建CanvasRenderingContext2D对象 private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) @State likeIcons: LikeIcon[] = [] // 存储所有点赞图标 private animationId: number = 0 // 动画ID // emoji表情数组 private readonly emojis: string[] = [ '❤️', '🧡', '💛', '💚', '💙', '💜', '🐻', '🐼', '🐨', '🦁', '🐯', '🦊', '🎁', '🎀', '🎉', '🎊', '✨', '⭐' ] // 生命周期方法和核心功能将在后续步骤中添加 build() { Column() { Stack() { Text('直播点赞效果') Canvas(this.context) .width('100%') .height('100%') .onClick((event: ClickEvent) => { // 点击处理逻辑将在后续步骤中添加 }) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(Color.White) } } 这里我们创建了基本的页面结构,包含一个标题和一个全屏的Canvas组件,用于绘制和响应点击事件。2. 实现静态图标绘制首先,我们实现最基础的功能:在Canvas上绘制一个静态的emoji图标。创建图标生成函数// 创建一个图标对象 createIcon(x: number, y: number, radius: number, emoji: string): LikeIcon { return { x: x, y: y, initialX: x, initialY: y, radius: radius, emoji: emoji, fontSize: Math.floor(radius * 1.2), opacity: 1.0, createTime: Date.now(), lifespan: 1000, // 1秒钟生命周期 scale: 1.0, // 暂时不缩放 initialScale: 1.0, maxScale: 1.0, maxOffset: 0, // 暂时不偏移 direction: 1 } } // 获取随机emoji getRandomEmoji(): string { return this.emojis[Math.floor(Math.random() * this.emojis.length)] } // 添加新的点赞图标 addLikeIcon(x: number, y: number) { const radius = 80 // 固定大小 const emoji = this.getRandomEmoji() this.likeIcons.push(this.createIcon(x, y, radius, emoji)) this.drawAllIcons() // 重新绘制所有图标 } 实现基本绘制函数// 绘制所有图标 drawAllIcons() { // 清除画布 this.context.clearRect(0, 0, this.context.width, this.context.height) // 绘制所有图标 for (let icon of this.likeIcons) { // 绘制emoji this.context.font = `${icon.fontSize}px` this.context.textAlign = 'center' this.context.textBaseline = 'middle' this.context.fillText(icon.emoji, icon.x, icon.y) } } 绑定点击事件.onClick((event: ClickEvent) => { console.info(`Clicked at: ${event.x}, ${event.y}`) this.addLikeIcon(event.x, event.y) }) 此时,每次点击Canvas,就会在点击位置绘制一个随机的emoji图标。但这些图标是静态的,不会移动或消失。3. 添加动画循环系统为了实现动画效果,我们需要一个动画循环系统,定期更新图标状态并重新绘制。aboutToAppear() { // 启动动画循环 this.startAnimation() } aboutToDisappear() { // 清除动画循环 clearInterval(this.animationId) } // 开始动画循环 startAnimation() { this.animationId = setInterval(() => { this.updateIcons() this.drawAllIcons() }, 16) // 约60fps的刷新率 } // 更新所有图标状态 updateIcons() { const currentTime = Date.now() const newIcons: LikeIcon[] = [] for (let icon of this.likeIcons) { // 计算图标已存在的时间 const existTime = currentTime - icon.createTime if (existTime < icon.lifespan) { // 保留未完成生命周期的图标 newIcons.push(icon) } } // 更新图标数组 this.likeIcons = newIcons } 现在,我们有了一个基本的动画系统,但图标仍然是静态的。接下来,我们将逐步添加各种动画效果。4. 实现向上移动效果让我们首先让图标动起来,实现一个简单的向上移动效果。// 更新所有图标状态 updateIcons() { const currentTime = Date.now() const newIcons: LikeIcon[] = [] for (let icon of this.likeIcons) { // 计算图标已存在的时间 const existTime = currentTime - icon.createTime if (existTime < icon.lifespan) { // 计算存在时间比例 const progress = existTime / icon.lifespan // 更新Y坐标 - 向上移动 icon.y = icon.initialY - 120 * progress // 保留未完成生命周期的图标 newIcons.push(icon) } } // 更新图标数组 this.likeIcons = newIcons } 现在,图标会在1秒内向上移动120像素,然后消失。这是一个简单的线性移动,看起来有些机械。5. 添加非线性运动效果为了让动画更加自然,我们可以使用幂函数来模拟非线性运动,使图标开始时移动较慢,然后加速。// 更新Y坐标 - 向上移动,速度变化更明显 const verticalDistance = 120 * Math.pow(progress, 0.7) // 使用幂函数让上升更快 icon.y = icon.initialY - verticalDistance幂指数0.7使得图标的上升速度随时间增加,创造出更加自然的加速效果。6. 添加渐隐效果接下来,让图标在上升过程中逐渐消失,增加视觉上的层次感。// 更新透明度 - 前60%保持不变,后40%逐渐消失 if (progress > 0.6) { // 在最后40%的生命周期内改变透明度,使消失更快 icon.opacity = 1.0 - ((progress - 0.6) / 0.4) } else { icon.opacity = 1.0 } // 在绘制时应用透明度 this.context.globalAlpha = icon.opacity这样,图标在生命周期的前60%保持完全不透明,后40%时间内逐渐变透明直到完全消失。这种设计让用户有足够的时间看清图标,然后它才开始消失。7. 实现放大效果现在,让我们添加图标从小变大的动画效果,这会让整个动画更加生动。// 创建图标时设置初始和最大缩放比例 createIcon(x: number, y: number, radius: number, emoji: string): LikeIcon { // 为图标生成随机属性 const initialScale = 0.4 + Math.random() * 0.2 // 初始缩放比例0.4-0.6 const maxScale = 1.0 + Math.random() * 0.3 // 最大缩放比例1.0-1.3 // ... 其他属性设置 ... return { // ... 其他属性 ... scale: initialScale, // 当前缩放比例 initialScale: initialScale, // 初始缩放比例 maxScale: maxScale, // 最大缩放比例 // ... 其他属性 ... } } // 在updateIcons中更新缩放比例 // 更新缩放比例 - 快速放大 // 在生命周期的前20%阶段(0.2s),缩放从initialScale增大到maxScale if (progress < 0.2) { // 平滑插值从initialScale到maxScale icon.scale = icon.initialScale + (icon.maxScale - icon.initialScale) * (progress / 0.2) } else { // 保持maxScale icon.scale = icon.maxScale } // 在绘制时应用缩放 // 设置缩放(从中心点缩放) this.context.translate(icon.x, icon.y) this.context.scale(icon.scale, icon.scale) this.context.translate(-icon.x, -icon.y) 现在,图标会在短时间内从小变大,然后保持大小不变,直到消失。为了确保变换正确,我们使用了translate和scale组合来实现从中心点缩放。8. 添加左右摆动效果最后,我们来实现图标左右摆动的效果,让整个动画更加生动自然。// 创建图标时设置摆动参数 createIcon(x: number, y: number, radius: number, emoji: string): LikeIcon { // ... 其他参数设置 ... // 减小摆动幅度,改为最大8-15像素 const maxOffset = 8 + Math.random() * 7 // 最大摆动幅度8-15像素 // 随机决定初始摆动方向 const direction = Math.random() > 0.5 ? 1 : -1 return { // ... 其他属性 ... maxOffset: maxOffset, // 最大摆动幅度 direction: direction // 初始摆动方向 } } // 在updateIcons中添加水平摆动逻辑 // 更新X坐标 - 快速的左右摆动 // 每0.25秒一个阶段,总共1秒4个阶段 let horizontalOffset = 0; if (progress < 0.25) { // 0-0.25s: 无偏移,专注于放大 horizontalOffset = 0; } else if (progress < 0.5) { // 0.25-0.5s: 向左偏移 const phaseProgress = (progress - 0.25) / 0.25; horizontalOffset = -icon.maxOffset * phaseProgress * icon.direction; } else if (progress < 0.75) { // 0.5-0.75s: 从向左偏移变为向右偏移 const phaseProgress = (progress - 0.5) / 0.25; horizontalOffset = icon.maxOffset * (2 * phaseProgress - 1) * icon.direction; } else { // 0.75-1s: 从向右偏移回到向左偏移 const phaseProgress = (progress - 0.75) / 0.25; horizontalOffset = icon.maxOffset * (1 - 2 * phaseProgress) * icon.direction; } icon.x = icon.initialX + horizontalOffset; 这个摆动算法将1秒的生命周期分为4个阶段:前25%时间:保持在原点,没有摆动,专注于放大效果25%-50%时间:向左偏移到最大值50%-75%时间:从向左偏移变为向右偏移75%-100%时间:从向右偏移变回向左偏移这样就形成了一个完整的"向左向右向左"摆动轨迹,非常符合物理世界中物体的运动规律。9. 优化绘制代码最后,我们需要优化绘制代码,正确处理状态保存和恢复,确保每个图标的绘制不会相互影响。// 绘制所有图标 drawAllIcons() { // 清除画布 this.context.clearRect(0, 0, this.context.width, this.context.height) // 绘制所有图标 for (let icon of this.likeIcons) { this.context.save() // 保存当前状态 // 设置透明度 this.context.globalAlpha = icon.opacity // 设置缩放(从中心点缩放) this.context.translate(icon.x, icon.y) this.context.scale(icon.scale, icon.scale) this.context.translate(-icon.x, -icon.y) // 绘制emoji this.context.font = `${icon.fontSize}px` this.context.textAlign = 'center' this.context.textBaseline = 'middle' this.context.fillText(icon.emoji, icon.x, icon.y) this.context.restore() // 恢复之前保存的状态 } } 每次绘制图标前调用save()方法,绘制完成后调用restore()方法,确保每个图标的绘制参数不会影响其他图标。10. 完整代码将上述所有步骤整合起来,我们就得到了一个完整的点赞动画效果。下面是完整的代码实现:@Entry @Component struct CanvasLike { // 用来配置CanvasRenderingContext2D对象的参数,开启抗锯齿 private settings: RenderingContextSettings = new RenderingContextSettings(true) // 正确创建CanvasRenderingContext2D对象 private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) @State likeIcons: LikeIcon[] = [] // 存储所有点赞图标 private animationId: number = 0 // 动画ID // emoji表情数组 private readonly emojis: string[] = [ '❤️', '🧡', '💛', '💚', '💙', '💜', '🐻', '🐼', '🐨', '🦁', '🐯', '🦊', '🎁', '🎀', '🎉', '🎊', '✨', '⭐' ] aboutToAppear() { // 启动动画循环 this.startAnimation() } aboutToDisappear() { // 清除动画循环 clearInterval(this.animationId) } // 创建一个图标对象 createIcon(x: number, y: number, radius: number, emoji: string): LikeIcon { // 为图标生成随机属性 const initialScale = 0.4 + Math.random() * 0.2 // 初始缩放比例0.4-0.6 const maxScale = 1.0 + Math.random() * 0.3 // 最大缩放比例1.0-1.3 // 减小摆动幅度,改为最大8-15像素 const maxOffset = 8 + Math.random() * 7 // 最大摆动幅度8-15像素 // 随机决定初始摆动方向 const direction = Math.random() > 0.5 ? 1 : -1 return { x: x, y: y, initialX: x, // 记录初始X坐标 initialY: y, // 记录初始Y坐标 radius: radius, emoji: emoji, fontSize: Math.floor(radius * 1.2), opacity: 1.0, createTime: Date.now(), lifespan: 1000, // 1秒钟生命周期 scale: initialScale, // 当前缩放比例 initialScale: initialScale, // 初始缩放比例 maxScale: maxScale, // 最大缩放比例 maxOffset: maxOffset, // 最大摆动幅度 direction: direction // 初始摆动方向 } } // 获取随机emoji getRandomEmoji(): string { return this.emojis[Math.floor(Math.random() * this.emojis.length)] } // 添加新的点赞图标 addLikeIcon(x: number, y: number) { const radius = 80 + Math.random() * 20 // 随机大小80-100 const emoji = this.getRandomEmoji() this.likeIcons.push(this.createIcon(x, y, radius, emoji)) } // 开始动画循环 startAnimation() { this.animationId = setInterval(() => { this.updateIcons() this.drawAllIcons() }, 16) // 约60fps的刷新率 } // 更新所有图标状态 updateIcons() { const currentTime = Date.now() const newIcons: LikeIcon[] = [] for (let icon of this.likeIcons) { // 计算图标已存在的时间 const existTime = currentTime - icon.createTime if (existTime < icon.lifespan) { // 计算存在时间比例 const progress = existTime / icon.lifespan // 1. 更新Y坐标 - 向上移动,速度变化更明显 const verticalDistance = 120 * Math.pow(progress, 0.7) // 使用幂函数让上升更快 icon.y = icon.initialY - verticalDistance // 2. 更新X坐标 - 快速的左右摆动 // 每0.25秒一个阶段,总共1秒4个阶段 let horizontalOffset = 0; if (progress < 0.25) { // 0-0.25s: 无偏移,专注于放大 horizontalOffset = 0; } else if (progress < 0.5) { // 0.25-0.5s: 向左偏移 const phaseProgress = (progress - 0.25) / 0.25; horizontalOffset = -icon.maxOffset * phaseProgress * icon.direction; } else if (progress < 0.75) { // 0.5-0.75s: 从向左偏移变为向右偏移 const phaseProgress = (progress - 0.5) / 0.25; horizontalOffset = icon.maxOffset * (2 * phaseProgress - 1) * icon.direction; } else { // 0.75-1s: 从向右偏移回到向左偏移 const phaseProgress = (progress - 0.75) / 0.25; horizontalOffset = icon.maxOffset * (1 - 2 * phaseProgress) * icon.direction; } icon.x = icon.initialX + horizontalOffset; // 3. 更新缩放比例 - 快速放大 // 在生命周期的前20%阶段(0.2s),缩放从initialScale增大到maxScale if (progress < 0.2) { // 平滑插值从initialScale到maxScale icon.scale = icon.initialScale + (icon.maxScale - icon.initialScale) * (progress / 0.2) } else { // 保持maxScale icon.scale = icon.maxScale } // 4. 更新透明度 - 前60%保持不变,后40%逐渐消失 if (progress > 0.6) { // 在最后40%的生命周期内改变透明度,使消失更快 icon.opacity = 1.0 - ((progress - 0.6) / 0.4) } else { icon.opacity = 1.0 } // 保留未完成生命周期的图标 newIcons.push(icon) } } // 更新图标数组 this.likeIcons = newIcons } // 绘制所有图标 drawAllIcons() { // 清除画布 this.context.clearRect(0, 0, this.context.width, this.context.height) // 绘制所有图标 for (let icon of this.likeIcons) { this.context.save() // 设置透明度 this.context.globalAlpha = icon.opacity // 设置缩放(从中心点缩放) this.context.translate(icon.x, icon.y) this.context.scale(icon.scale, icon.scale) this.context.translate(-icon.x, -icon.y) // 绘制emoji this.context.font = `${icon.fontSize}px` this.context.textAlign = 'center' this.context.textBaseline = 'middle' this.context.fillText(icon.emoji, icon.x, icon.y) this.context.restore() } } build() { Column() { Stack() { Text('直播点赞效果') Canvas(this.context) .width('100%') .height('100%') .onReady(() => { // Canvas已准备好,可以开始绘制 console.info(`Canvas size: ${this.context.width} x ${this.context.height}`) }) .onClick((event: ClickEvent) => { console.info(`Clicked at: ${event.x}, ${event.y}`) this.addLikeIcon(event.x, event.y) }) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(Color.White) .expandSafeArea() } } 总结通过本教程,我们学习了如何使用HarmonyOS的Canvas组件实现直播点赞动画效果。我们从最基础的静态图标绘制开始,逐步形成了一个生动自然的点赞动画。在实现过程中,我们学习了以下重要知识点:Canvas的基本使用方法动画循环系统的实现图形变换(缩放、平移)透明度控制非线性动画实现状态管理的重要性通过这些技术,你可以创建出更多丰富多彩的动画效果,提升你的应用的用户体验。希望本教程对你有所帮助!
  • [技术干货] 鸿蒙特效教程03-水波纹动画效果实现教程
    鸿蒙特效教程03-水波纹动画效果实现教程本教程适合HarmonyOS初学者,通过简单到复杂的步骤,一步步实现漂亮的水波纹动画效果。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库最终效果预览我们将实现以下功能:点击屏幕任意位置,在点击处生成一个水波纹触摸并滑动屏幕,波纹会实时跟随手指位置生成波纹从小到大扩散,同时逐渐消失波纹颜色随机变化,增加视觉多样性一、创建基础布局首先,我们需要创建一个基础页面布局。这个布局包含一个占满屏幕的区域,用于展示水波纹动画。@Entry @Component struct WaterRipple { build() { Column() { // 水波纹动画效果区域 Stack() { // 提示文本 Column() { Text('点击屏幕任意位置产生水波纹') .fontSize(16) .fontColor('#333') .margin({ top: 20 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') } .width('100%') .height('100%') .backgroundColor('#EDEDED') .expandSafeArea() } } 这段代码创建了一个简单的页面,包含以下元素:最外层是一个占满整个屏幕的Column内部是一个Stack组件,它允许我们将多个元素堆叠在一起在Stack中放置了一个包含提示文本的ColumnStack组件很重要,因为我们后续要在这里动态添加水波纹元素。二、定义水波纹数据结构在实现动画效果之前,我们需要先定义水波纹的数据结构。每个水波纹都有自己的位置、大小、颜色等属性:// 水波纹项目类型定义 interface RippleItem { id: number // 唯一标识 x: number // 中心点X坐标 y: number // 中心点Y坐标 size: number // 当前大小 maxSize: number // 最大大小 opacity: number // 透明度 color: string // 颜色 } @Entry @Component struct WaterRipple { // 水波纹数组,用于存储所有活动的水波纹 @State ripples: RippleItem[] = [] // 触摸状态 @State isTouching: boolean = false // 波纹生成定时器 private rippleTimer: number = -1 // 当前触摸位置 private touchX: number = 0 private touchY: number = 0 // 波纹生成间隔(毫秒) private readonly rippleInterval: number = 200 // 其余代码保持不变... } 使用@State装饰器标记ripples数组和isTouching状态,这样当它们的内容发生变化时,UI会自动更新。我们还定义了一些用于跟踪触摸状态和位置的变量。三、实现波纹绘制与点击事件接下来,我们需要实现波纹的绘制,并处理基本的点击事件:@Entry @Component struct WaterRipple { // 前面定义的属性不变... build() { Column() { // 水波纹动画效果 Stack() { // 水波纹展示区域 Column() { Text('点击屏幕任意位置产生水波纹') .fontSize(16) .fontColor('#333') .margin({ top: 20 }) Text('触摸并滑动,波纹会跟随手指') .fontSize(16) .fontColor('#333') .margin({ top: 10 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) // 绘制所有波纹 ForEach(this.ripples, (item: RippleItem) => { Circle() .fill(item.color) .width(item.size) .height(item.size) .opacity(item.opacity) .position({ x: item.x - item.size / 2, y: item.y - item.size / 2 }) }) } .width('100%') .height('100%') // 触摸事件处理将在后面添加 } // 其余代码保持不变... } // 创建水波纹的方法(暂未实现) createRipple(x: number, y: number) { console.info(`点击位置: x=${x}, y=${y}`) } } 在这一步中,我们添加了以下内容:使用ForEach遍历ripples数组,为每个波纹创建一个Circle组件为提示文本添加了第二行,说明触摸滑动功能添加了createRipple方法的基本结构(目前只打印坐标)注意Circle组件的定位方式:由于圆形是以左上角为基准定位的,而我们希望以圆心定位,所以需要从坐标中减去圆形半径(即size/2)。四、实现水波纹创建逻辑下一步,我们来实现水波纹的创建逻辑:// 创建一个新的水波纹 createRipple(x: number, y: number) { // 创建随机颜色 const colors = ['#2196F3', '#03A9F4', '#00BCD4', '#4CAF50', '#8BC34A'] const color = colors[Math.floor(Math.random() * colors.length)] // 创建新波纹 const newRipple: RippleItem = { id: Date.now(), // 使用时间戳作为唯一标识 x: x, // X坐标 y: y, // Y坐标 size: 0, // 初始大小为0 maxSize: 300, // 最大扩散到300像素 opacity: 0.6, // 初始透明度 color: color // 随机颜色 } // 添加到波纹数组 this.ripples.push(newRipple) // 启动波纹动画 this.animateRipple(newRipple) } 在这个方法中:我们定义了一个颜色数组,每次随机选择一种颜色创建一个新的波纹对象,初始大小为0,最大扩散尺寸为300像素将新波纹添加到数组中,这会触发UI更新调用animateRipple方法开始动画(下一步实现)五、实现动画效果现在,我们来实现波纹的扩散和消失动画:// 动画处理波纹的扩散和消失 animateRipple(ripple: RippleItem) { let animationStep = 0 const totalSteps = 60 // 总动画帧数 const intervalTime = 16 // 每帧间隔时间(约60fps) const sizeStep = ripple.maxSize / totalSteps // 每帧增加的尺寸 const opacityStep = ripple.opacity / totalSteps // 每帧减少的透明度 const timer = setInterval(() => { animationStep++ // 更新波纹状态 const index = this.ripples.findIndex(item => item.id === ripple.id) if (index !== -1) { // 增加大小 this.ripples[index].size += sizeStep // 降低透明度 this.ripples[index].opacity -= opacityStep // 更新状态触发重绘 this.ripples = [...this.ripples] // 动画结束,移除波纹 if (animationStep >= totalSteps) { clearInterval(timer) this.ripples = this.ripples.filter(item => item.id !== ripple.id) } } else { // 波纹已被其他方式移除 clearInterval(timer) } }, intervalTime) } 这个方法使用了setInterval定时器来创建动画,主要逻辑包括:设置动画参数:总步数、帧间隔、每帧尺寸增量和透明度减量每帧更新波纹的大小和透明度,实现从小到大、从清晰到透明的效果使用[...this.ripples]创建数组的新实例,触发UI更新当动画完成(步数达到总步数)或波纹被移除时,清除定时器动画结束后从数组中移除该波纹,释放内存至此,我们已经实现了基本的水波纹效果。下一步将添加触摸滑动功能。六、实现触摸跟踪功能最后,我们来实现触摸跟踪功能,让波纹能够跟随手指移动:.onTouch((event: TouchEvent) => { // 获取当前触摸点坐标 this.touchX = event.touches[0].x this.touchY = event.touches[0].y // 根据触摸状态处理 switch (event.type) { case TouchType.Down: // 开始触摸,立即创建一个波纹 this.isTouching = true this.createRipple(this.touchX, this.touchY) // 启动定时器连续生成波纹 if (this.rippleTimer === -1) { this.rippleTimer = setInterval(() => { if (this.isTouching) { // 添加小偏移让效果更自然 const offsetX = Math.random() * 10 - 5 const offsetY = Math.random() * 10 - 5 this.createRipple(this.touchX + offsetX, this.touchY + offsetY) } }, this.rippleInterval) } break case TouchType.Move: // 移动时保持触摸状态,波纹会在定时器中根据新坐标创建 this.isTouching = true break case TouchType.Up: case TouchType.Cancel: // 触摸结束,停止生成波纹 this.isTouching = false if (this.rippleTimer !== -1) { clearInterval(this.rippleTimer) this.rippleTimer = -1 } break } }) 在这段代码中,我们使用onTouch事件监听器来处理触摸事件,主要功能包括:触摸开始(Down)时:记录触摸位置立即创建一个波纹启动定时器,以固定间隔创建波纹触摸移动(Move)时:更新触摸位置保持触摸状态,定时器会在新位置创建波纹触摸结束(Up)或取消(Cancel)时:停止触摸状态清除定时器,不再创建新波纹这样实现后,当用户触摸并滑动屏幕时,波纹会实时跟随手指位置生成,创造出一种水流般的视觉效果。完整代码下面是最终的完整代码:// 水波纹项目类型定义 interface RippleItem { id: number // 唯一标识 x: number // 中心点X坐标 y: number // 中心点Y坐标 size: number // 当前大小 maxSize: number // 最大大小 opacity: number // 透明度 color: string // 颜色 } @Entry @Component struct WaterRipple { // 水波纹数组 @State ripples: RippleItem[] = [] // 触摸状态 @State isTouching: boolean = false // 波纹生成定时器 private rippleTimer: number = -1 // 当前触摸位置 private touchX: number = 0 private touchY: number = 0 // 波纹生成间隔(毫秒) private readonly rippleInterval: number = 200 build() { Column() { // 水波纹动画效果 Stack() { // 水波纹展示区域 Column() { Text('点击屏幕任意位置产生水波纹') .fontSize(16) .fontColor('#333') .margin({ top: 20 }) Text('触摸并滑动,波纹会跟随手指') .fontSize(16) .fontColor('#333') .margin({ top: 10 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) // 绘制所有波纹 ForEach(this.ripples, (item: RippleItem) => { Circle() .fill(item.color) .width(item.size) .height(item.size) .opacity(item.opacity) .position({ x: item.x - item.size / 2, y: item.y - item.size / 2 }) }) } .width('100%') .height('100%') .onTouch((event: TouchEvent) => { // 获取当前触摸点坐标 this.touchX = event.touches[0].x this.touchY = event.touches[0].y // 根据触摸状态处理 switch (event.type) { case TouchType.Down: // 开始触摸,立即创建一个波纹 this.isTouching = true this.createRipple(this.touchX, this.touchY) // 启动定时器连续生成波纹 if (this.rippleTimer === -1) { this.rippleTimer = setInterval(() => { if (this.isTouching) { // 添加小偏移让效果更自然 const offsetX = Math.random() * 10 - 5 const offsetY = Math.random() * 10 - 5 this.createRipple(this.touchX + offsetX, this.touchY + offsetY) } }, this.rippleInterval) } break case TouchType.Move: // 移动时保持触摸状态,波纹会在定时器中根据新坐标创建 this.isTouching = true break case TouchType.Up: case TouchType.Cancel: // 触摸结束,停止生成波纹 this.isTouching = false if (this.rippleTimer !== -1) { clearInterval(this.rippleTimer) this.rippleTimer = -1 } break } }) } .width('100%') .height('100%') .backgroundColor('#EDEDED') .expandSafeArea() } // 创建一个新的水波纹 createRipple(x: number, y: number) { // 创建随机颜色 const colors = ['#2196F3', '#03A9F4', '#00BCD4', '#4CAF50', '#8BC34A'] const color = colors[Math.floor(Math.random() * colors.length)] // 创建新波纹 const newRipple: RippleItem = { id: Date.now(), x: x, y: y, size: 0, maxSize: 300, opacity: 0.6, color: color } // 添加到波纹数组 this.ripples.push(newRipple) // 启动波纹动画 this.animateRipple(newRipple) } // 动画处理波纹的扩散和消失 animateRipple(ripple: RippleItem) { let animationStep = 0 const totalSteps = 60 const intervalTime = 16 // 约60fps const sizeStep = ripple.maxSize / totalSteps const opacityStep = ripple.opacity / totalSteps const timer = setInterval(() => { animationStep++ // 更新波纹状态 const index = this.ripples.findIndex(item => item.id === ripple.id) if (index !== -1) { // 增加大小 this.ripples[index].size += sizeStep // 降低透明度 this.ripples[index].opacity -= opacityStep // 更新状态触发重绘 this.ripples = [...this.ripples] // 动画结束,移除波纹 if (animationStep >= totalSteps) { clearInterval(timer) this.ripples = this.ripples.filter(item => item.id !== ripple.id) } } else { // 波纹已被其他方式移除 clearInterval(timer) } }, intervalTime) } } 总结通过这个教程,我们学习了如何一步步实现水波纹动画效果:ArkUI 搭建基础布局,创建用于展示水波纹的容器@State 定义水波纹数据结构,设计存储和管理波纹的方式实现基本的波纹绘制和触摸事件 onTouch创建水波纹生成逻辑,包括随机颜色 Math.random使用 setInterval 定时器实现波纹扩散和消失的动画效果添加 TouchEvent 触摸跟踪功能,让波纹能够跟随手指滑动这个简单而美观的水波纹效果可以应用在你的应用中的各种交互场景,例如按钮点击、图片查看、页面切换等。通过调整参数,你还可以创造出不同风格的波纹效果。希望这个教程对你有所帮助,祝你在鸿蒙应用开发中创造出更多精彩的交互体验!
  • [技术干货] 鸿蒙特效教程02-微信语音录制动画效果实现教程
    鸿蒙特效教程02-微信语音录制动画效果实现教程本教程适合HarmonyOS初学者,通过简单到复杂的步骤,一步步实现类似微信APP中的语音录制动画效果。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库最终效果预览我们将实现以下功能:长按"按住说话"按钮:显示录音界面和声波动画录音过程中显示实时时长手指上滑:取消录音发送松开手指:根据状态发送或取消录音一、基础布局实现首先,我们需要创建基本的界面布局,模拟微信聊天界面的结构。@Entry @Component struct WeChatRecorder { build() { Column() { // 聊天内容区域(模拟) Stack({ alignContent: Alignment.Center }) { } .layoutWeight(1) // 底部输入栏 Row() { // 录音按钮 Text('按住 说话') .fontSize(16) .fontColor('#333333') .backgroundColor('#F5F5F5') .borderRadius(4) .textAlign(TextAlign.Center) .width('100%') .height(40) .padding({ left: 10, right: 10 }) } .width('100%') .backgroundColor(Color.White) .expandSafeArea() .padding({ left: 15, right: 15, top: 15 }) .border({ width: { top: 1 }, color: '#E5E5E5' }) } .width('100%') .height('100%') .backgroundColor('#EDEDED') .expandSafeArea() } } 这一步我们创建了一个基本的聊天界面布局,包含两部分:顶部聊天内容区域:使用Stack布局,目前为空底部输入栏:包含一个"按住 说话"按钮二、添加状态变量接下来,我们需要添加一些状态变量来跟踪录音状态和动画效果。@Entry @Component struct WeChatRecorder { // 是否正在录音 @State isRecording: boolean = false // 是否显示取消提示(上滑状态) @State isCancel: boolean = false // 录音时长(秒) @State recordTime: number = 0 // 声波高度变化数组 @State waveHeights: number[] = [20, 30, 25, 40, 35, 28, 32, 37] // 计时器ID private timerId: number = 0 // 波形动画计时器ID private waveTimerId: number = 0 // 触摸起始位置 private touchStartY: number = 0 // 触摸移动阈值,超过该值显示取消提示 private readonly cancelThreshold: number = 50 build() { // 之前的布局代码 } } 我们添加了以下状态变量:isRecording:跟踪是否正在录音isCancel:跟踪是否处于取消录音状态(上滑)recordTime:记录录音时长(秒)waveHeights:存储声波高度数组,用于实现波形动画timerId:存储计时器ID,用于后续清除waveTimerId:存储波形动画计时器IDtouchStartY:记录触摸起始位置,用于计算上滑距离cancelThreshold:定义上滑多少距离触发取消状态三、添加基础方法在实现UI交互前,我们先添加一些基础方法来处理录音状态和动画效果。@Entry @Component struct WeChatRecorder { // 状态变量定义... /** * 开始录音,初始化状态及启动计时器 */ startRecording() { this.isRecording = true this.isCancel = false this.recordTime = 0 // 启动计时器,每秒更新录音时长 this.timerId = setInterval(() => { this.recordTime++ }, 1000) // 启动波形动画计时器,随机更新波形高度 this.waveTimerId = setInterval(() => { this.updateWaveHeights() }, 200) } /** * 结束录音,清理计时器和状态 */ stopRecording() { // 清除计时器 if (this.timerId !== 0) { clearInterval(this.timerId) this.timerId = 0 } if (this.waveTimerId !== 0) { clearInterval(this.waveTimerId) this.waveTimerId = 0 } // 如果是取消状态,则显示取消提示 if (this.isCancel) { console.info('录音已取消') } else if (this.recordTime > 0) { // 如果录音时长大于0,则模拟发送语音 console.info(`发送语音,时长: ${this.recordTime}秒`) } // 重置状态 this.isRecording = false this.isCancel = false this.recordTime = 0 } /** * 更新波形高度以产生动画效果 */ updateWaveHeights() { // 创建新的波形高度数组 const newHeights = this.waveHeights.map(() => { // 生成20-40之间的随机高度 return Math.floor(Math.random() * 20) + 20 }) this.waveHeights = newHeights } /** * 格式化时间显示,将秒转换为"00:00"格式 */ formatTime(seconds: number): string { const minutes = Math.floor(seconds / 60) const secs = seconds % 60 return `${minutes.toString() .padStart(2, '0')}:${secs.toString() .padStart(2, '0')}` } aboutToDisappear() { // 组件销毁时清除计时器 if (this.timerId !== 0) { clearInterval(this.timerId) this.timerId = 0 } if (this.waveTimerId !== 0) { clearInterval(this.waveTimerId) this.waveTimerId = 0 } } build() { // 之前的布局代码 } } 在这一步中,我们实现了以下方法:startRecording:开始录音,初始化状态并启动计时器stopRecording:结束录音,清理计时器和状态updateWaveHeights:更新波形高度数组,产生动画效果formatTime:将秒数格式化为"00:00"格式的时间显示aboutToDisappear:组件销毁时清理计时器,防止内存泄漏四、实现长按事件处理接下来,我们为"按住 说话"按钮添加触摸事件处理,实现长按开始录音的功能。@Entry @Component struct WeChatRecorder { // 之前的代码... build() { Column() { Stack({ alignContent: Alignment.Center }) { // 暂时留空,后面会添加录音界面 } .layoutWeight(1) // 底部输入栏 Row() { // 录音按钮 Text(this.isRecording ? '松开 发送' : '按住 说话') .fontSize(16) .fontColor(this.isRecording ? Color.White : '#333333') .backgroundColor(this.isRecording ? '#07C160' : '#F5F5F5') .borderRadius(4) .textAlign(TextAlign.Center) .width('100%') .height(40) .padding({ left: 10, right: 10 }) // 添加触摸事件 .onTouch((event) => { if (event.type === TouchType.Down) { // 按下时,记录起始位置,开始录音 this.touchStartY = event.touches[0].y this.startRecording() } else if (event.type === TouchType.Move) { // 移动时,检测是否上滑到取消区域 const moveDistance = this.touchStartY - event.touches[0].y this.isCancel = moveDistance > this.cancelThreshold } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { // 松开或取消触摸时,结束录音 this.stopRecording() } }) } .width('100%') .backgroundColor(this.isRecording ? Color.Transparent : Color.White) .expandSafeArea() .padding({ left: 15, right: 15, top: 15 }) .border({ width: { top: 1 }, color: '#E5E5E5' }) } .width('100%') .height('100%') .backgroundColor('#EDEDED') .expandSafeArea() } } 在这一步中,我们:为按钮文本添加了动态内容,根据录音状态显示不同文字为按钮添加了触摸事件处理,包括按下、移动和松开/取消根据录音状态动态改变底部栏的背景色五、实现录音界面和声波动画最后,我们添加录音状态下的界面显示,包括上滑取消提示和声波动画。@Entry @Component struct WeChatRecorder { // 之前的代码... build() { Column() { // 聊天内容区域 Stack({ alignContent: Alignment.Center }) { // 录音状态提示 if (this.isRecording) { // 遮罩背景 Column() .width('100%') .height('100%') .backgroundColor('#80000000') .expandSafeArea() Column() { // 上滑取消提示 Text(this.isCancel ? '松开手指,取消发送' : '手指上滑,取消发送') .fontSize(14) .fontColor(this.isCancel ? Color.Red : '#999999') .backgroundColor(this.isCancel ? '#FFE9E9' : '#D1D1D1') .borderRadius(4) .padding({ left: 10, right: 10, top: 5, bottom: 5 }) .margin({ bottom: 20 }) // 录音界面容器 Column() { // 声波动画容器 Row() { ForEach(this.waveHeights, (height: number, index) => { Column() .width(4) .height(height) .backgroundColor('#7ED321') .borderRadius(2) .margin({ left: 3, right: 3 }) }) } .width(160) .height(100) .justifyContent(FlexAlign.Center) .margin({ bottom: 15 }) // 录音时间显示 Text(`${this.formatTime(this.recordTime)}`) .fontSize(16) .fontColor('#999999') } .width(180) .backgroundColor(Color.White) .borderRadius(8) .justifyContent(FlexAlign.Center) .padding(10) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } .layoutWeight(1) // 底部输入栏 // 与之前的代码相同 } // 与之前的代码相同 } } 在这一步中,我们添加了:录音状态下的遮罩背景,使用半透明黑色背景上滑取消提示,根据 isCancel 状态显示不同内容和样式声波动画容器,使用 ForEach 循环遍历 waveHeights 数组创建多个柱状条录音时间显示,使用 formatTime 方法格式化时间六、完整实现下面是完整的实现代码:/** * 微信语音录制动画效果 * 实现功能: * 1. 长按按钮: 显示录音动画 * 2. 上滑取消: 模拟取消录音 * 3. 松开发送: 模拟发送语音 */ @Entry @Component struct WeChatRecorder { // 是否正在录音 @State isRecording: boolean = false // 是否显示取消提示(上滑状态) @State isCancel: boolean = false // 录音时长(秒) @State recordTime: number = 0 // 声波高度变化数组 @State waveHeights: number[] = [20, 30, 25, 40, 35, 28, 32, 37] // 计时器ID private timerId: number = 0 // 波形动画计时器ID private waveTimerId: number = 0 // 触摸起始位置 private touchStartY: number = 0 // 触摸移动阈值,超过该值显示取消提示 private readonly cancelThreshold: number = 50 /** * 开始录音,初始化状态及启动计时器 */ startRecording() { this.isRecording = true this.isCancel = false this.recordTime = 0 // 启动计时器,每秒更新录音时长 this.timerId = setInterval(() => { this.recordTime++ }, 1000) // 启动波形动画计时器,随机更新波形高度 this.waveTimerId = setInterval(() => { this.updateWaveHeights() }, 200) } /** * 结束录音,清理计时器和状态 */ stopRecording() { // 清除计时器 if (this.timerId !== 0) { clearInterval(this.timerId) this.timerId = 0 } if (this.waveTimerId !== 0) { clearInterval(this.waveTimerId) this.waveTimerId = 0 } // 如果是取消状态,则显示取消提示 if (this.isCancel) { console.info('录音已取消') } else if (this.recordTime > 0) { // 如果录音时长大于0,则模拟发送语音 console.info(`发送语音,时长: ${this.recordTime}秒`) } // 重置状态 this.isRecording = false this.isCancel = false this.recordTime = 0 } /** * 更新波形高度以产生动画效果 */ updateWaveHeights() { // 创建新的波形高度数组 const newHeights = this.waveHeights.map(() => { // 生成20-40之间的随机高度 return Math.floor(Math.random() * 20) + 20 }) this.waveHeights = newHeights } /** * 格式化时间显示,将秒转换为"00:00"格式 */ formatTime(seconds: number): string { const minutes = Math.floor(seconds / 60) const secs = seconds % 60 return `${minutes.toString() .padStart(2, '0')}:${secs.toString() .padStart(2, '0')}` } aboutToDisappear() { // 组件销毁时清除计时器 if (this.timerId !== 0) { clearInterval(this.timerId) this.timerId = 0 } if (this.waveTimerId !== 0) { clearInterval(this.waveTimerId) this.waveTimerId = 0 } } build() { Column() { // 聊天内容区域(模拟) Stack({ alignContent: Alignment.Center }) { // 录音状态提示 if (this.isRecording) { // 遮罩背景 Column() .width('100%') .height('100%') .backgroundColor('#80000000') .expandSafeArea() Column() { // 上滑取消提示 Text(this.isCancel ? '松开手指,取消发送' : '手指上滑,取消发送') .fontSize(14) .fontColor(this.isCancel ? Color.Red : '#999999') .backgroundColor(this.isCancel ? '#FFE9E9' : '#D1D1D1') .borderRadius(4) .padding({ left: 10, right: 10, top: 5, bottom: 5 }) .margin({ bottom: 20 }) // 录音界面容器 Column() { // 声波动画容器 Row() { ForEach(this.waveHeights, (height: number, index) => { Column() .width(4) .height(height) .backgroundColor('#7ED321') .borderRadius(2) .margin({ left: 3, right: 3 }) }) } .width(160) .height(100) .justifyContent(FlexAlign.Center) .margin({ bottom: 15 }) // 录音时间显示 Text(`${this.formatTime(this.recordTime)}`) .fontSize(16) .fontColor('#999999') } .width(180) .backgroundColor(Color.White) .borderRadius(8) .justifyContent(FlexAlign.Center) .padding(10) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } .layoutWeight(1) // 底部输入栏 Row() { // 录音按钮 Text(this.isRecording ? '松开 发送' : '按住 说话') .fontSize(16) .fontColor(this.isRecording ? Color.White : '#333333') .backgroundColor(this.isRecording ? '#07C160' : '#F5F5F5') .borderRadius(4) .textAlign(TextAlign.Center) .width('100%') .height(40) .padding({ left: 10, right: 10 })// 添加触摸事件 .onTouch((event) => { if (event.type === TouchType.Down) { // 按下时,记录起始位置,开始录音 this.touchStartY = event.touches[0].y this.startRecording() } else if (event.type === TouchType.Move) { // 移动时,检测是否上滑到取消区域 const moveDistance = this.touchStartY - event.touches[0].y this.isCancel = moveDistance > this.cancelThreshold } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { // 松开或取消触摸时,结束录音 this.stopRecording() } }) } .width('100%') .backgroundColor(this.isRecording ? Color.Transparent : Color.White) .expandSafeArea() .padding({ left: 15, right: 15, top: 15 }) .border({ width: { top: 1 }, color: '#E5E5E5' }) } .width('100%') .height('100%') .backgroundColor('#EDEDED') .expandSafeArea() } } 拓展与优化以上是基本的实现,如果想进一步优化,可以考虑:真实的录音功能:使用HarmonyOS的媒体录制API实现实际录音声音波形实时变化:根据实际录音音量调整波形高度振动反馈:在录音开始、取消或发送时添加振动反馈显示已录制的语音消息:将录制好的语音添加到聊天消息列表中录音时长限制:添加最长录音时间限制(如微信的60秒)总结通过这个教程,我们从零开始实现了类似微信的语音录制动画效果。主要用到了以下技术:HarmonyOS的ArkUI布局系统状态管理(@State)触摸事件处理定时器和动画条件渲染组件生命周期处理这些技术和概念不仅适用于这个特定效果,还可以应用于各种交互设计中。希望这个教程能帮助你更好地理解HarmonyOS开发,并创建出更加精美的应用界面!
  • [技术干货] 鸿蒙特效教程01-哔哩哔哩点赞与一键三连
    鸿蒙特效教程01-哔哩哔哩点赞与一键三连本教程适合HarmonyOS初学者,通过简单到复杂的步骤,一步步实现类似哔哩哔哩APP中的点赞及一键三连效果。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15下载代码仓库最终效果预览我们将实现以下两个效果:点击点赞按钮:实现点赞/取消点赞的切换效果,包含图标变色和缩放动画长按点赞按钮:实现一键三连效果,包含旋转、缩放和粒子动画一、基础布局实现首先,我们需要创建基本的界面布局。这一步非常简单,只需要放置一个点赞图标。@Entry @Component struct BilibiliLike { build() { // 使用Stack布局,使元素居中对齐 Stack({ alignContent: Alignment.Center }) { // 点赞图标 Image($r('app.media.ic_public_like')) .width(50) // 设置图标宽度 .aspectRatio(1) // 保持宽高比为1:1 .objectFit(ImageFit.Contain) // 设置图片适应方式 } .width('100%') // 容器占满宽度 .height('100%') // 容器占满高度 .backgroundColor('#f1f1f1') // 设置背景颜色为浅灰色 } } 这一步实现了最基础的布局,我们使用了Stack布局使元素居中,并添加了一个点赞图标。点赞图标大家可以去 iconfont.cn 平台下载一个,通过 $() 函数导入资源$r('app.media.ic_public_like')。二、添加状态和颜色切换接下来,我们添加状态变量来跟踪点赞状态,并实现点击时的颜色切换效果。@Entry @Component struct BilibiliLike { // 是否已点赞状态 @State isLiked: boolean = false // 未点赞时的颜色(灰色) @State unlikeColor: string = '#757575' // 点赞后的颜色(蓝色) @State likeColor: string = '#4eabe6' build() { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.ic_public_like')) .width(50) .aspectRatio(1) .objectFit(ImageFit.Contain) .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) // 根据点赞状态设置颜色 .onClick(() => { // 点击时切换点赞状态 this.isLiked = !this.isLiked }) } .width('100%') .height('100%') .backgroundColor('#f1f1f1') } } 这一步我们添加了:isLiked 状态变量,用于跟踪是否已点赞两个颜色变量 unlikeColor 和 likeColor在图标上添加 fillColor 属性,根据点赞状态切换颜色为图标添加 onClick 事件,实现点击时切换点赞状态现在点击图标时,图标会在灰色和蓝色之间切换,实现了基本的点赞/取消点赞效果。三、添加点击动画效果点击时的颜色变化已经实现了,但用户体验还不够好。我们需要添加一些动画效果,让点赞操作更加生动。@Entry @Component struct BilibiliLike { @State isLiked: boolean = false @State unlikeColor: string = '#757575' @State likeColor: string = '#4eabe6' // 添加控制缩放的状态变量 @State scaleValue: number = 1 build() { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.ic_public_like')) .width(50) .aspectRatio(1) .objectFit(ImageFit.Contain) .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) .scale({ x: this.scaleValue, y: this.scaleValue }) // 添加缩放效果 .onClick(() => { this.toggleLike() // 调用点赞切换方法 }) } .width('100%') .height('100%') .backgroundColor('#f1f1f1') } // 处理点赞动画效果 toggleLike() { // 切换点赞状态 this.isLiked = !this.isLiked // 点赞动画 - 第一阶段:放大 animateTo({ duration: 300, // 动画持续300毫秒 curve: Curve.Friction, // 使用摩擦曲线,效果更自然 delay: 0 // 无延迟立即执行 }, () => { this.scaleValue = 1.5 // 图标放大到1.5倍 }) // 点赞动画 - 第二阶段:恢复原大小 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.Friction, // 使用摩擦曲线 delay: 300 // 延迟300毫秒执行,等第一阶段完成 }, () => { this.scaleValue = 1 // 图标恢复到原始大小 }) } } 这一步我们添加了:scaleValue 状态变量,用于控制图标的缩放比例图标添加 scale 属性,绑定到 scaleValue 状态创建了 toggleLike 方法,实现点赞状态切换和动画效果使用 animateTo 方法创建两段动画:先放大,再恢复原大小现在点击图标时,不仅会变色,还会有一个放大再缩小的动画效果,让交互更加生动。四、添加长按手势识别接下来,我们要实现长按触发一键三连的功能,首先需要添加长按手势识别。@Entry @Component struct BilibiliLike { @State isLiked: boolean = false @State unlikeColor: string = '#757575' @State likeColor: string = '#4eabe6' @State scaleValue: number = 1 // 添加一个标记三连效果的状态 @State isTripleAction: boolean = false build() { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.ic_public_like')) .width(50) .aspectRatio(1) .objectFit(ImageFit.Contain) .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) .scale({ x: this.scaleValue, y: this.scaleValue }) .draggable(false) // 防止用户拖拽图片,导致点赞按钮无法长按 // 添加长按手势 .gesture( LongPressGesture({ repeat: false }) // 长按手势,不重复触发 .onAction(() => { console.info("检测到长按事件") // 设置状态 this.isTripleAction = true this.isLiked = true }) ) .onClick(() => { this.toggleLike() }) } .width('100%') .height('100%') .backgroundColor('#f1f1f1') } // 点赞动画方法保持不变 toggleLike() { // 代码同上... } } 这一步我们添加了:isTripleAction 状态变量,用于标记是否触发了一键三连效果使用 gesture 方法添加长按手势识别在长按触发时设置相关状态注意图片添加 draggable(false),防止用户拖拽图片,导致点赞按钮无法长按现在,长按图标后,会将点赞状态设为已点赞,并触发三连状态,但还没有具体的动画效果。五、添加长按动画效果下一步,我们为长按添加更丰富的动画效果,包括旋转和缩放。@Entry @Component struct BilibiliLike { @State isLiked: boolean = false @State isTripleAction: boolean = false @State scaleValue: number = 1 @State unlikeColor: string = '#757575' @State likeColor: string = '#4eabe6' // 添加旋转角度状态 @State rotation: number = 0 build() { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.ic_public_like')) .width(50) .aspectRatio(1) .objectFit(ImageFit.Contain) .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) .scale({ x: this.scaleValue, y: this.scaleValue }) .rotate({ z: 1, angle: this.rotation }) // 添加旋转效果 .draggable(false) .gesture( LongPressGesture({ repeat: false }) .onAction(() => { this.triggerTripleAction() // 调用三连效果方法 }) ) .onClick(() => { this.toggleLike() }) } .width('100%') .height('100%') .backgroundColor('#f1f1f1') } // 点赞动画方法保持不变 toggleLike() { // 代码同上... } // 处理一键三连动画效果 triggerTripleAction() { // 设置三连状态和点赞状态 this.isTripleAction = true this.isLiked = true // 阶段一:点赞按钮放大并旋转 animateTo({ duration: 200, // 动画持续200毫秒 curve: Curve.EaseInOut // 使用缓入缓出曲线 }, () => { this.scaleValue = 1.8 // 图标放大到1.8倍 this.rotation = 20 // 图标旋转20度 }) // 阶段二:点赞按钮缩小并恢复角度 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.EaseInOut, // 使用缓入缓出曲线 delay: 200 // 延迟200毫秒,等阶段一完成 }, () => { this.scaleValue = 1.2 // 图标缩小到1.2倍 this.rotation = 0 // 图标恢复原始角度 }) // 阶段三:点赞按钮恢复原始大小 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.EaseInOut, // 使用缓入缓出曲线 delay: 300 // 延迟300毫秒,等阶段二完成 }, () => { this.scaleValue = 1 // 图标恢复原始大小 }) // 重置三连状态 setTimeout(() => { this.isTripleAction = false }, 1000) // 延迟1000毫秒(1秒)执行 } } 这一步我们添加了:rotation 状态变量,用于控制图标的旋转角度图标添加 rotate 属性,实现旋转效果创建了 triggerTripleAction 方法,实现三连动画效果三段连续动画,实现放大旋转、缩小恢复的动画序列现在,长按图标会触发一系列动画:图标先放大并顺时针旋转,然后缩小并恢复原始角度,最后恢复原始大小,整个过程非常流畅。六、添加粒子爆炸效果最后,我们添加粒子爆炸效果,让一键三连的视觉效果更加丰富。@Entry @Component struct BilibiliLike { @State isLiked: boolean = false @State isTripleAction: boolean = false @State scaleValue: number = 1 @State rotation: number = 0 @State unlikeColor: string = '#757575' @State likeColor: string = '#4eabe6' // 添加粒子透明度状态 @State iconOpacity: number = 0 build() { Stack({ alignContent: Alignment.Center }) { // 粒子效果容器 Column() { // 当触发三连效果时显示粒子 if (this.isTripleAction) { // 创建8个粒子,均匀分布在圆周上 ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item: number) => { Circle() .width(8) // 粒子宽度 .height(8) // 粒子高度 .fill(this.likeColor) // 使用点赞颜色填充粒子 // 使用三角函数计算粒子在圆周上的位置 .position({ x: 25 + 50 * Math.cos(item * Math.PI / 4), y: 25 + 50 * Math.sin(item * Math.PI / 4) }) .opacity(this.iconOpacity) // 控制粒子的透明度 }) } } .width(50) // 设置容器宽度 .height(50) // 设置容器高度 // 点赞按钮 Image($r('app.media.ic_public_like')) .width(50) .aspectRatio(1) .objectFit(ImageFit.Contain) .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) .scale({ x: this.scaleValue, y: this.scaleValue }) .rotate({ z: 1, angle: this.rotation }) .draggable(false) .gesture( LongPressGesture({ repeat: false }) .onAction(() => { this.triggerTripleAction() }) ) .onClick(() => { this.toggleLike() }) } .width('100%') .height('100%') .backgroundColor('#f1f1f1') } // 点赞动画方法保持不变 toggleLike() { // 代码同上... } // 处理一键三连动画效果 triggerTripleAction() { // 设置三连状态和点赞状态 this.isTripleAction = true this.isLiked = true // 点赞按钮动画部分保持不变 // ... // 添加粒子出现动画 animateTo({ duration: 500, // 动画持续500毫秒 curve: Curve.EaseOut, // 使用缓出曲线,开始快结束慢 delay: 200 // 延迟200毫秒执行 }, () => { this.iconOpacity = 1 // 粒子透明度变为1,完全显示 }) // 粒子消失动画 animateTo({ duration: 300, // 动画持续300毫秒 curve: Curve.Linear, // 使用线性曲线,匀速变化 delay: 700 // 延迟700毫秒,等粒子完全显示一段时间 }, () => { this.iconOpacity = 0 // 粒子透明度变为0,完全消失 }) // 重置三连状态 setTimeout(() => { this.isTripleAction = false }, 1000) // 延迟1000毫秒(1秒)执行 } } 这一步我们添加了:iconOpacity 状态变量,用于控制粒子的透明度在Stack中添加粒子效果容器使用ForEach和条件渲染,当触发三连效果时创建并显示粒子使用三角函数计算粒子在圆周上的位置添加粒子的动画效果,包括出现和消失七、完整实现最后,我们来看一下最终的完整代码实现。这里整合了前面所有的步骤,并添加了一些额外的细节优化。/** * 哔哩哔哩点赞和一键三连效果组件 * 实现功能: * 1. 普通点击: 实现点赞/取消点赞效果 * 2. 长按: 触发一键三连效果(点赞+粒子动画) */ @Entry @Component struct BilibiliLike { // 是否已点赞状态 @State isLiked: boolean = false // 是否触发一键三连效果状态 @State isTripleAction: boolean = false // 控制图标缩放比例 @State scaleValue: number = 1 // 控制图标透明度 @State opacityValue: number = 1 // 控制图标X轴偏移量 @State translateXValue: number = 0 // 控制图标Y轴偏移量 @State translateYValue: number = 0 // 控制图标旋转角度 @State rotation: number = 0 // 控制粒子效果透明度 @State iconOpacity: number = 0 // 未点赞时的颜色(灰色) @State unlikeColor: string = '#757575' // 点赞后的颜色(蓝色) @State likeColor: string = '#4eabe6' build() { // 使用Stack布局,所有元素居中对齐 Stack({ alignContent: Alignment.Center }) { // 粒子效果容器 Column() { // 当触发三连效果时显示粒子 if (this.isTripleAction) { // 创建8个粒子,均匀分布在圆周上 ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item: number) => { Circle() .width(8) // 粒子宽度 .height(8) // 粒子高度 .fill(this.likeColor) // 使用点赞颜色填充粒子 // 使用三角函数计算粒子在圆周上的位置 .position({ x: 25 + 50 * Math.cos(item * Math.PI / 4), y: 25 + 50 * Math.sin(item * Math.PI / 4) }) .opacity(this.iconOpacity) // 控制粒子的透明度 }) } } .width(50) // 设置容器宽度 .height(50) // 设置容器高度 // 点赞按钮 Image($r('app.media.ic_public_like')) // 使用点赞图标资源 .width(50) // 设置图标宽度 .aspectRatio(1) // 保持宽高比为1:1 .objectFit(ImageFit.Contain) // 设置图片适应方式 .fillColor(this.isLiked ? this.likeColor : this.unlikeColor) // 根据点赞状态设置颜色 .scale({ x: this.scaleValue, y: this.scaleValue }) // 设置缩放值 .opacity(this.opacityValue) // 设置透明度 .rotate({ z: 1, angle: this.rotation }) // 设置旋转角度,绕z轴旋转 .translate({ x: this.translateXValue, y: this.translateYValue }) // 设置平移值 .draggable(false) // 防止用户拖拽图片,导致点赞按钮无法长按 // 添加长按手势,触发一键三连效果 .gesture( LongPressGesture({ repeat: false }) // 长按手势,不重复触发 .onAction(() => { this.triggerTripleAction() // 调用三连效果方法 }) ) // 添加点击事件,触发普通点赞效果 .onClick(() => { this.toggleLike() // 调用点赞切换方法 }) } .width('100%') // 容器占满宽度 .height('100%') // 容器占满高度 .backgroundColor('#f1f1f1') // 设置背景颜色为浅灰色 .expandSafeArea() // 扩展到安全区域 } /** * 处理点赞/取消点赞动画效果 * 点击时切换点赞状态并播放放大缩小动画 */ toggleLike() { // 切换点赞状态 this.isLiked = !this.isLiked // 点赞动画 - 第一阶段:放大 animateTo({ duration: 300, // 动画持续300毫秒 curve: Curve.Friction, // 使用摩擦曲线,效果更自然 delay: 0 // 无延迟立即执行 }, () => { this.scaleValue = 1.5 // 图标放大到1.5倍 }) // 点赞动画 - 第二阶段:恢复原大小 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.Friction, // 使用摩擦曲线 delay: 300 // 延迟300毫秒执行,等第一阶段完成 }, () => { this.scaleValue = 1 // 图标恢复到原始大小 }) } /** * 处理一键三连动画效果 * 包含多个动画阶段:放大旋转、粒子出现与消失 */ triggerTripleAction() { // 设置三连状态和点赞状态 this.isTripleAction = true // 启用三连效果 this.isLiked = true // 设置为已点赞状态 // 阶段一:点赞按钮放大并旋转 animateTo({ duration: 200, // 动画持续200毫秒 curve: Curve.EaseInOut // 使用缓入缓出曲线 }, () => { this.scaleValue = 1.8 // 图标放大到1.8倍 this.rotation = 20 // 图标旋转20度 }) // 阶段二:点赞按钮缩小并恢复角度 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.EaseInOut, // 使用缓入缓出曲线 delay: 200 // 延迟200毫秒,等阶段一完成 }, () => { this.scaleValue = 1.2 // 图标缩小到1.2倍 this.rotation = 0 // 图标恢复原始角度 }) // 阶段三:点赞按钮恢复原始大小 animateTo({ duration: 100, // 动画持续100毫秒 curve: Curve.EaseInOut, // 使用缓入缓出曲线 delay: 300 // 延迟300毫秒,等阶段二完成 }, () => { this.scaleValue = 1 // 图标恢复原始大小 this.isLiked = true // 确保点赞状态为true }) // 阶段四:粒子出现动画 animateTo({ duration: 500, // 动画持续500毫秒 curve: Curve.EaseOut, // 使用缓出曲线,开始快结束慢 delay: 200 // 延迟200毫秒执行 }, () => { this.iconOpacity = 1 // 粒子透明度变为1,完全显示 }) // 阶段五:粒子消失动画 animateTo({ duration: 300, // 动画持续300毫秒 curve: Curve.Linear, // 使用线性曲线,匀速变化 delay: 700 // 延迟700毫秒,等粒子完全显示一段时间 }, () => { this.iconOpacity = 0 // 粒子透明度变为0,完全消失 }) // 延时重置三连状态,完成整个动画周期 setTimeout(() => { this.isTripleAction = false // 关闭三连效果状态 }, 1000) // 延迟1000毫秒(1秒)执行 } } 拓展与优化以上是基本的实现,如果想进一步优化,可以考虑:添加声音效果:在点赞和三连时添加声音反馈添加震动反馈:利用设备振动提供触觉反馈优化粒子效果:使用更复杂的粒子系统,例如随机大小、颜色和速度添加投币和收藏图标:真正实现完整的"一键三连"效果,显示投币和收藏图标总结通过这个教程,我们从零开始实现了哔哩哔哩的点赞和一键三连效果。主要用到了以下技术:HarmonyOS的ArkUI布局系统状态管理(@State)手势处理(点击、长按)动画系统(animateTo)条件渲染数学计算(用于粒子位置)这些技术和概念不仅适用于这个特定效果,还可以应用于各种交互设计中。希望这个教程能帮助你更好地理解HarmonyOS开发,并创建出更加精美的应用界面!
  • [技术干货] 鸿蒙NEXT开发案例:随机数生成
    【引言】本项目是一个简单的随机数生成器应用,用户可以通过设置随机数的范围和个数,并选择是否允许生成重复的随机数,来生成所需的随机数列表。生成的结果可以通过点击“复制”按钮复制到剪贴板。【环境准备】• 操作系统:Windows 10• 开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806• 目标设备:华为Mate60 Pro• 开发语言:ArkTS• 框架:ArkUI• API版本:API 12【关键技术点】1. 用户界面设计用户界面主要包括以下几个部分:• 标题栏:显示应用名称。• 输入框:用户可以输入随机数的起始值、结束值和生成个数。• 开关:用户可以选择生成的随机数是否允许重复。• 生成按钮:点击后生成随机数。• 结果显示区:显示生成的随机数,并提供复制功能。2. 随机数生成算法随机数生成是本项目的重点。根据用户是否允许生成重复的随机数,算法分为两种情况:2.1 不允许重复当用户选择不允许生成重复的随机数时,程序使用一个 Set 来存储生成的随机数,利用 Set 的特性自动去重。具体步骤如下:1)计算范围:计算用户指定的随机数范围 range = endValue - startValue + 1。2)生成随机数:使用一个临时数组 tempArray 来辅助生成不重复的随机数。每次生成一个随机索引 randomIndex,从 tempArray 中取出或计算一个新的随机数 randomNum,并将其添加到 Set 中。3)更新临时数组:将 tempArray 中末尾的元素移动到随机位置,以确保下次生成的随机数仍然是唯一的。if (!this.isUnique) { if (countValue > range) { // 显示错误提示 this.getUIContext().showAlertDialog({ title: '错误提示', message: `请求的随机数数量超过了范围内的总数`, confirm: { defaultFocus: true, value: '我知道了', fontColor: Color.White, backgroundColor: this.primaryColor, action: () => {} }, onWillDismiss: () => {}, alignment: DialogAlignment.Center, }); return; } for (let i = 0; i < countValue; i++) { let randomIndex = Math.floor(Math.random() * (range - i)); let randomNum = 0; if (tempArray[randomIndex] !== undefined) { randomNum = tempArray[randomIndex]; } else { randomNum = startValue + randomIndex; } generatedNumbers.add(randomNum); if (tempArray[range - 1 - i] === undefined) { tempArray[range - 1 - i] = startValue + range - 1 - i; } tempArray[randomIndex] = tempArray[range - 1 - i]; } this.generatedNumbers = JSON.stringify(Array.from(generatedNumbers)); }3. 剪贴板功能为了方便用户使用,程序提供了将生成的随机数复制到剪贴板的功能。具体实现如下:private copyToClipboard(text: string): void { const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); const systemPasteboard = pasteboard.getSystemPasteboard(); systemPasteboard.setData(pasteboardData); promptAction.showToast({ message: '已复制' }); }完整代码// 导入剪贴板服务模块,用于后续实现复制功能 import { pasteboard } from '@kit.BasicServicesKit'; // 导入用于显示提示信息的服务 import { promptAction } from '@kit.ArkUI'; // 使用装饰器定义一个入口组件,这是应用的主界面 @Entry @Component struct RandomNumberGenerator { // 定义基础间距,用于布局中的间距设置 @State private baseSpacing: number = 30; // 存储生成的随机数字符串 @State private generatedNumbers: string = ''; // 应用的主题色 @State private primaryColor: string = '#fea024'; // 文本的颜色 @State private fontColor: string = "#2e2e2e"; // 输入框是否获取焦点的状态变量 @State private isFocusStart: boolean = false; @State private isFocusEnd: boolean = false; @State private isFocusCount: boolean = false; // 是否允许生成的随机数重复 @State private isUnique: boolean = true; // 随机数生成的起始值 @State private startValue: number = 0; // 随机数生成的结束值 @State private endValue: number = 0; // 要生成的随机数个数 @State private countValue: number = 0; // 生成随机数的方法 private generateRandomNumbers(): void { const startValue = this.startValue; // 获取当前设定的起始值 const endValue = this.endValue; // 获取当前设定的结束值 const countValue = this.countValue; // 获取当前设定的生成个数 const range: number = endValue - startValue + 1; // 计算生成范围 // 用于存储生成的随机数 const generatedNumbers = new Set<number>(); // 使用Set来自动去重 const tempArray: number[] = []; // 临时数组,用于辅助生成不重复的随机数 // 如果不允许重复,则使用去重算法生成随机数 if (!this.isUnique) { // 如果请求的随机数数量超过了范围内的总数,则显示错误提示 if (countValue > range) { this.getUIContext().showAlertDialog({ title: '错误提示', message: `请求的随机数数量超过了范围内的总数`, confirm: { defaultFocus: true, value: '我知道了', fontColor: Color.White, backgroundColor: this.primaryColor, action: () => {} // 点击确认后的回调 }, onWillDismiss: () => {}, // 对话框即将关闭时的回调 alignment: DialogAlignment.Center, // 对话框的对齐方式 }); return; } for (let i = 0; i < countValue; i++) { let randomIndex = Math.floor(Math.random() * (range - i)); // 在剩余范围内选择一个随机索引 let randomNum = 0; if (tempArray[randomIndex] !== undefined) { // 如果索引位置已有值,则使用该值 randomNum = tempArray[randomIndex]; } else { randomNum = startValue + randomIndex; // 否则计算新的随机数 } generatedNumbers.add(randomNum); // 添加到Set中,自动去重 if (tempArray[range - 1 - i] === undefined) { // 更新末尾元素的位置 tempArray[range - 1 - i] = startValue + range - 1 - i; } tempArray[randomIndex] = tempArray[range - 1 - i]; // 将末尾元素移到随机位置 } // 将生成的随机数转换成JSON格式的字符串 this.generatedNumbers = JSON.stringify(Array.from(generatedNumbers)); } else { // 如果允许重复,则直接生成随机数 for (let i = 0; i < this.countValue; i++) { let randomNumber = this.startValue + Math.floor(Math.random() * (this.endValue - this.startValue)); tempArray.push(randomNumber); } // 将生成的随机数转换成JSON格式的字符串 this.generatedNumbers = JSON.stringify(tempArray); } } // 将生成的随机数复制到剪贴板的方法 private copyToClipboard(text: string): void { const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); // 创建剪贴板数据 const systemPasteboard = pasteboard.getSystemPasteboard(); // 获取系统剪贴板 systemPasteboard.setData(pasteboardData); // 设置剪贴板数据 // 显示复制成功的提示信息 promptAction.showToast({ message: '已复制' }); } // 构建页面布局的方法 build() { Column() { // 标题栏,展示应用名 Text("随机数生成") .width('100%') // 设置宽度为100% .height(54) // 设置高度为54 .fontSize(18) // 设置字体大小为18 .fontWeight(600) // 设置字体粗细为600 .backgroundColor(Color.White) // 设置背景颜色为白色 .textAlign(TextAlign.Center) // 设置文本居中对齐 .fontColor(this.fontColor); // 设置文本颜色 // 随机数范围设置区域 Column() { Row() { Text(`随机数范围`) .fontWeight(600) // 设置字体粗细为600 .fontSize(18) // 设置字体大小为18 .fontColor(this.fontColor); // 设置文本颜色 } .margin({ top: `${this.baseSpacing}lpx`, left: `${this.baseSpacing}lpx` }); // 设置边距 // 输入随机数范围的两个值 Row() { TextInput({ placeholder: '开始(>=)' }) // 输入框,显示占位符 .layoutWeight(1) // 设置布局权重为1 .type(InputType.Number) // 设置输入类型为数字 .placeholderColor(this.isFocusStart ? this.primaryColor : Color.Gray) // 设置占位符颜色 .fontColor(this.isFocusStart ? this.primaryColor : this.fontColor) // 设置文本颜色 .borderColor(this.isFocusStart ? this.primaryColor : Color.Gray) // 设置边框颜色 .borderWidth(1) // 设置边框宽度 .borderRadius(10) // 设置圆角半径 .backgroundColor(Color.White) // 设置背景颜色 .showUnderline(false) // 不显示下划线 .onBlur(() => this.isFocusStart = false) // 输入框失去焦点时的处理 .onFocus(() => this.isFocusStart = true) // 输入框获得焦点时的处理 .onChange((value: string) => this.startValue = Number(value)); // 输入值变化时的处理 // 分隔符 Line().width(10) // 设置分隔符宽度 TextInput({ placeholder: '结束(<=)' }) // 输入框,显示占位符 .layoutWeight(1) // 设置布局权重为1 .type(InputType.Number) // 设置输入类型为数字 .placeholderColor(this.isFocusEnd ? this.primaryColor : Color.Gray) // 设置占位符颜色 .fontColor(this.isFocusEnd ? this.primaryColor : this.fontColor) // 设置文本颜色 .borderColor(this.isFocusEnd ? this.primaryColor : Color.Gray) // 设置边框颜色 .borderWidth(1) // 设置边框宽度 .borderRadius(10) // 设置圆角半径 .backgroundColor(Color.White) // 设置背景颜色 .showUnderline(false) // 不显示下划线 .onBlur(() => this.isFocusEnd = false) // 输入框失去焦点时的处理 .onFocus(() => this.isFocusEnd = true) // 输入框获得焦点时的处理 .onChange((value: string) => this.endValue = Number(value)); // 输入值变化时的处理 } .margin({ left: `${this.baseSpacing}lpx`, // 左边距 right: `${this.baseSpacing}lpx`, // 右边距 top: `${this.baseSpacing}lpx`, // 上边距 }); // 输入生成随机数的个数 Text('生成随机数个数') .fontWeight(600) // 设置字体粗细为600 .fontSize(18) // 设置字体大小为18 .fontColor(this.fontColor) // 设置文本颜色 .margin({ left: `${this.baseSpacing}lpx`, top: `${this.baseSpacing}lpx` }); // 设置边距 Row() { TextInput({ placeholder: '' }) // 输入框,显示占位符 .layoutWeight(1) // 设置布局权重为1 .type(InputType.Number) // 设置输入类型为数字 .placeholderColor(this.isFocusCount ? this.primaryColor : Color.Gray) // 设置占位符颜色 .fontColor(this.isFocusCount ? this.primaryColor : this.fontColor) // 设置文本颜色 .borderColor(this.isFocusCount ? this.primaryColor : Color.Gray) // 设置边框颜色 .borderWidth(1) // 设置边框宽度 .borderRadius(10) // 设置圆角半径 .backgroundColor(Color.White) // 设置背景颜色 .showUnderline(false) // 不显示下划线 .onBlur(() => this.isFocusCount = false) // 输入框失去焦点时的处理 .onFocus(() => this.isFocusCount = true) // 输入框获得焦点时的处理 .onChange((value: string) => this.countValue = Number(value)); // 输入值变化时的处理 } .margin({ left: `${this.baseSpacing}lpx`, // 左边距 right: `${this.baseSpacing}lpx`, // 右边距 top: `${this.baseSpacing}lpx`, // 上边距 }); // 设置数字是否可重复的开关 Row() { Text('数字是否可重复') .fontWeight(400) // 设置字体粗细为400 .fontSize(16) // 设置字体大小为16 .fontColor(this.fontColor) // 设置文本颜色 .layoutWeight(1); // 设置布局权重为1 Toggle({ type: ToggleType.Checkbox, isOn: this.isUnique }) // 切换按钮 .width('100lpx') // 设置宽度 .height('50lpx') // 设置高度 .borderColor(Color.Gray) // 设置边框颜色 .selectedColor(this.primaryColor) // 设置选中时的颜色 .onChange((isOn: boolean) => this.isUnique = isOn) // 切换状态变化时的处理 .align(Alignment.End); // 设置对齐方式为右对齐 } .margin({ top: `${this.baseSpacing}lpx`, // 上边距 }) .width('100%') // 设置宽度为100% .padding({ left: `${this.baseSpacing}lpx`, // 左内边距 right: `${this.baseSpacing}lpx`, // 右内边距 top: `${this.baseSpacing / 3}lpx`, // 上内边距 }) .hitTestBehavior(HitTestMode.Block) // 设置点击测试行为 .onClick(() => this.isUnique = !this.isUnique); // 点击时切换状态 // 生成随机数的按钮 Text('开始生成') .fontColor(Color.White) // 设置文本颜色为白色 .backgroundColor(this.primaryColor) // 设置背景颜色为主题色 .height(54) // 设置高度为54 .textAlign(TextAlign.Center) // 设置文本居中对齐 .borderRadius(10) // 设置圆角半径 .fontSize(18) // 设置字体大小为18 .width(`${650 - this.baseSpacing * 2}lpx`) // 设置宽度 .margin({ top: `${this.baseSpacing}lpx`, // 上边距 left: `${this.baseSpacing}lpx`, // 左边距 right: `${this.baseSpacing}lpx`, // 右边距 bottom: `${this.baseSpacing}lpx` // 下边距 }) .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) // 设置点击效果 .onClick(() => this.generateRandomNumbers()); // 点击时生成随机数 } .width('650lpx') // 设置宽度 .margin({ top: 20 }) // 设置上边距 .backgroundColor(Color.White) // 设置背景颜色为白色 .borderRadius(10) // 设置圆角半径 .alignItems(HorizontalAlign.Start); // 设置水平对齐方式为左对齐 // 显示生成的随机数 Column() { Text(`生成的随机数为:`) .fontWeight(600) // 设置字体粗细为600 .fontSize(18) // 设置字体大小为18 .fontColor(this.fontColor) // 设置文本颜色 .margin({ top: `${this.baseSpacing}lpx`, // 上边距 left: `${this.baseSpacing}lpx`, // 左边距 }); Text(`${this.generatedNumbers}`) // 显示生成的随机数 .width('650lpx') // 设置宽度 .fontColor(this.primaryColor) // 设置文本颜色为主题色 .fontSize(18) // 设置字体大小为18 .textAlign(TextAlign.Center) // 设置文本居中对齐 .padding({ left: 5, right: 5 }) // 设置内边距 .margin({ top: `${this.baseSpacing / 3}lpx` // 上边距 }); // 复制生成的随机数到剪贴板的按钮 Text('复制') .enabled(this.generatedNumbers ? true : false) // 按钮是否可用 .fontColor(Color.White) // 设置文本颜色为白色 .backgroundColor(this.primaryColor) // 设置背景颜色为主题色 .height(54) // 设置高度为54 .textAlign(TextAlign.Center) // 设置文本居中对齐 .borderRadius(10) // 设置圆角半径 .fontSize(18) // 设置字体大小为18 .width(`${650 - this.baseSpacing * 2}lpx`) // 设置宽度 .margin({ top: `${this.baseSpacing}lpx`, // 上边距 left: `${this.baseSpacing}lpx`, // 左边距 right: `${this.baseSpacing}lpx`, // 右边距 bottom: `${this.baseSpacing}lpx` // 下边距 }) .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) // 设置点击效果 .onClick(() => this.copyToClipboard(this.generatedNumbers)); // 点击时复制随机数 } .width('650lpx') // 设置宽度 .backgroundColor(Color.White) // 设置背景颜色为白色 .borderRadius(10) // 设置圆角半径 .margin({ top: `${this.baseSpacing}lpx` }) // 设置上边距 .alignItems(HorizontalAlign.Start); // 设置水平对齐方式为左对齐 } .height('100%') // 设置高度为100% .width('100%') // 设置宽度为100% .backgroundColor("#f2f3f5"); // 设置背景颜色 } }转载自https://www.cnblogs.com/zhongcx/p/18556821
  • [技术干货] 鸿蒙NEXT开发案例:简体繁体转换器
     【引言】简体繁体转换器是一个实用的小工具,它可以帮助用户轻松地在简体中文和繁体中文之间进行转换。对于需要频繁处理两岸三地文档的用户来说,这样的工具无疑是提高工作效率的好帮手。本案例将展示如何利用鸿蒙NEXT提供的组件和服务,结合第三方库@nutpi/chinese_transverter,来实现这一功能。【环境准备】• 操作系统:Windows 10• 开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806• 目标设备:华为Mate60 Pro• 开发语言:ArkTS• 框架:ArkUI• API版本:API 12• 三方库:chinese_transverter【实现步骤】1. 项目初始化首先,确保你的开发环境已经安装了鸿蒙NEXT的相关工具链。然后,创建一个新的鸿蒙NEXT项目。2. 引入第三方库使用ohpm命令安装@nutpi/chinese_transverter库:1ohpm install @nutpi/chinese_transverter3. 编写核心逻辑接下来,在项目的主组件中引入所需的模块,并定义好状态变量和方法。这里的关键在于设置监听器以响应输入文本的变化,并调用转换函数来获取转换结果。import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"; @Entry @Component struct SimplifiedTraditionalConverter { @State @Watch('onInputTextChanged') inputText: string = ''; @State simplifiedResult: string = ''; @State traditionalResult: string = ''; @State isInputFocused: boolean = false; onInputTextChanged() { this.simplifiedResult = transverter({ type: TransverterType.SIMPLIFIED, str: this.inputText, language: TransverterLanguage.ZH_CN }); this.traditionalResult = transverter({ type: TransverterType.TRADITIONAL, str: this.inputText, language: TransverterLanguage.ZH_CN }); } private copyToClipboard(text: string): void { const clipboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); const systemClipboard = pasteboard.getSystemPasteboard(); systemClipboard.setData(clipboardData); promptAction.showToast({ message: '已复制到剪贴板' }); } build() { // UI构建代码... } }4. 构建用户界面在build方法中,我们构建了应用的用户界面。这里主要包括一个可滚动的容器、输入区域、结果展示区以及操作按钮。Scroll() { Column() { Text("简体繁体转换器") .width('100%') .height(54) .fontSize(18) .fontWeight(600) .backgroundColor(Color.White) .textAlign(TextAlign.Center) .fontColor(this.textColor); // 输入区域与清空按钮 Column() { // ...省略部分代码... Text('清空') .borderWidth(1) .borderColor(this.themeColor) .fontColor(this.themeColor) .height(50) .textAlign(TextAlign.Center) .borderRadius(10) .fontSize(18) .width(`${650 - this.basePadding * 2}lpx`) .margin({ top: `${this.basePadding}lpx` }) .onClick(() => this.inputText = ""); } .width('650lpx') .padding(`${this.basePadding}lpx`) .margin({ top: 20 }) .backgroundColor(Color.White) .borderRadius(10) .alignItems(HorizontalAlign.Start); // 结果展示区 // ...省略部分代码... } .width('100%') .height('100%') .backgroundColor("#f2f3f5") .align(Alignment.Top) .padding({ bottom: `${this.basePadding}lpx` }); } 完整代码// 导入必要的转换库,提供简体与繁体之间的转换功能 import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"; // 导入剪贴板服务,用于将文本复制到系统剪贴板 import { pasteboard } from '@kit.BasicServicesKit'; // 导入提示服务,用于向用户显示消息 import { promptAction } from '@kit.ArkUI'; // 使用@Entry装饰器标记此组件为应用的入口点 @Entry // 使用@Component装饰器定义一个名为SimplifiedTraditionalConverter的组件 @Component struct SimplifiedTraditionalConverter { // 定义状态变量inputText,存储用户输入的原始文本,当其值变化时触发onInputTextChanged方法 @State @Watch('onInputTextChanged') inputText: string = ''; // 定义状态变量simplifiedResult,存储转换后的简体结果 @State simplifiedResult: string = ''; // 定义状态变量traditionalResult,存储转换后的繁体结果 @State traditionalResult: string = ''; // 定义状态变量isInputFocused,表示输入框是否获得了焦点 @State isInputFocused: boolean = false; // 定义主题颜色 @State private themeColor: string = '#439fff'; // 定义文本颜色 @State private textColor: string = "#2e2e2e"; // 定义基础内边距大小 @State private basePadding: number = 30; // 定义最小文本区域高度 @State private minTextAreaHeight: number = 50; // 定义最大文本区域高度 @State private maxTextAreaHeight: number = 300; // 当inputText状态改变时触发的方法,用于更新转换结果 onInputTextChanged() { // 将inputText转换为简体,并更新simplifiedResult this.simplifiedResult = transverter({ type: TransverterType.SIMPLIFIED, str: this.inputText, language: TransverterLanguage.ZH_CN }); // 将inputText转换为繁体,并更新traditionalResult this.traditionalResult = transverter({ type: TransverterType.TRADITIONAL, str: this.inputText, language: TransverterLanguage.ZH_CN }); } // 将给定的文本复制到剪贴板,并显示提示信息 private copyToClipboard(text: string): void { const clipboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); // 创建剪贴板数据 const systemClipboard = pasteboard.getSystemPasteboard(); // 获取系统剪贴板 systemClipboard.setData(clipboardData); // 设置剪贴板数据 promptAction.showToast({ message: '已复制到剪贴板' }); // 显示复制成功的提示 } // 构建组件的UI build() { Scroll() { // 创建可滚动的容器 Column() { // 在滚动容器中创建垂直布局 // 创建标题文本 Text("简体繁体转换器") .width('100%') .height(54) .fontSize(18) .fontWeight(600) .backgroundColor(Color.White) .textAlign(TextAlign.Center) .fontColor(this.textColor); // 创建用户输入区域 Column() { // 创建多行文本输入框 TextArea({ text: $$this.inputText, placeholder: '请输入简体/繁体字(支持混合输入)' }) .fontSize(18) .placeholderColor(this.isInputFocused ? this.themeColor : Color.Gray) .fontColor(this.isInputFocused ? this.themeColor : this.textColor) .borderColor(this.isInputFocused ? this.themeColor : Color.Gray) .caretColor(this.themeColor) .onBlur(() => this.isInputFocused = false) // 当输入框失去焦点时,更新isInputFocused状态 .onFocus(() => this.isInputFocused = true) // 当输入框获得焦点时,更新isInputFocused状态 .borderWidth(1) .borderRadius(10) .backgroundColor(Color.White) .constraintSize({ minHeight: this.minTextAreaHeight, maxHeight: this.maxTextAreaHeight }); // 创建清空按钮 Text('清空') .borderWidth(1) .borderColor(this.themeColor) .fontColor(this.themeColor) .height(50) .textAlign(TextAlign.Center) .borderRadius(10) .fontSize(18) .width(`${650 - this.basePadding * 2}lpx`) .margin({ top: `${this.basePadding}lpx` }) .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) // 添加点击效果 .onClick(() => this.inputText = ""); // 清空输入框 } .width('650lpx') .padding(`${this.basePadding}lpx`) .margin({ top: 20 }) .backgroundColor(Color.White) .borderRadius(10) .alignItems(HorizontalAlign.Start); // 创建繁体结果展示与复制区域 Column() { // 创建繁体结果标题 Text(`繁体结果:`) .fontWeight(600) .fontSize(18) .fontColor(this.textColor); // 创建繁体结果展示文本 Text(`${this.traditionalResult}`) .constraintSize({ minHeight: this.minTextAreaHeight, maxHeight: this.maxTextAreaHeight }) .fontColor(this.themeColor) .fontSize(18) .textAlign(TextAlign.Start) .copyOption(CopyOptions.InApp) .margin({ top: `${this.basePadding / 3}lpx` }); // 创建复制繁体结果按钮 Text('复制') .enabled(this.traditionalResult ? true : false) // 只有当有繁体结果时,按钮才可用 .fontColor(Color.White) .backgroundColor(this.themeColor) .height(50) .textAlign(TextAlign.Center) .borderRadius(10) .fontSize(18) .width(`${650 - this.basePadding * 2}lpx`) .margin({ top: `${this.basePadding}lpx` }) .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) .onClick(() => this.copyToClipboard(this.traditionalResult)); // 复制繁体结果到剪贴板 } .width('650lpx') .padding(`${this.basePadding}lpx`) .backgroundColor(Color.White) .borderRadius(10) .margin({ top: `${this.basePadding}lpx` }) .alignItems(HorizontalAlign.Start); // 创建简体结果展示与复制区域 Column() { // 创建简体结果标题 Text(`简体结果:`) .fontWeight(600) .fontSize(18) .fontColor(this.textColor); // 创建简体结果展示文本 Text(`${this.simplifiedResult}`) .constraintSize({ minHeight: this.minTextAreaHeight, maxHeight: this.maxTextAreaHeight }) .fontColor(this.themeColor) .fontSize(18) .textAlign(TextAlign.Start) .copyOption(CopyOptions.InApp) .margin({ top: `${this.basePadding / 3}lpx` }); // 创建复制简体结果按钮 Text('复制') .enabled(this.simplifiedResult ? true : false) // 只有当有简体结果时,按钮才可用 .fontColor(Color.White) .backgroundColor(this.themeColor) .height(50) .textAlign(TextAlign.Center) .borderRadius(10) .fontSize(18) .width(`${650 - this.basePadding * 2}lpx`) .margin({ top: `${this.basePadding}lpx` }) .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) .onClick(() => this.copyToClipboard(this.simplifiedResult)); // 复制简体结果到剪贴板 } .width('650lpx') .padding(`${this.basePadding}lpx`) .backgroundColor(Color.White) .borderRadius(10) .margin({ top: `${this.basePadding}lpx` }) .alignItems(HorizontalAlign.Start); } } .width('100%') .height('100%') .backgroundColor("#f2f3f5") .align(Alignment.Top) .padding({ bottom: `${this.basePadding}lpx` }); } }转载自https://www.cnblogs.com/zhongcx/p/18559952 
  • [技术干货] 鸿蒙NEXT开发案例:血型遗传计算
     【引言】血型遗传计算器是一个帮助用户根据父母的血型预测子女可能的血型的应用。通过选择父母的血型,应用程序能够快速计算出孩子可能拥有的血型以及不可能拥有的血型。这个过程不仅涉及到了简单的数据处理逻辑,还涉及到UI设计与交互体验的设计。【环境准备】• 操作系统:Windows 10• 开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806• 目标设备:华为Mate60 Pro• 开发语言:ArkTS• 框架:ArkUI• API版本:API 12【开发步骤】1. 创建组件首先,我们使用@Entry和@Component装饰器创建一个名为BloodTypeCalculator的组件。这标志着我们的组件是鸿蒙NEXT应用的一个入口点。12345@Entry@Componentstruct BloodTypeCalculator {  // 组件的状态和方法定义}2. 定义状态为了控制组件的外观和行为,我们定义了一系列的状态变量,如主题颜色、文本颜色、边框颜色等。同时,我们也定义了两个数组来存储可能和不可能的血型结果。12345@State private themeColor: string | Color = Color.Orange;@State private textColor: string = "#2e2e2e";@State private lineColor: string = "#d5d5d5";@State private possibleBloodTypesText: string = "";@State private impossibleBloodTypesText: string = "";3. 血型逻辑实现接下来,实现了几个关键的方法来处理血型相关的逻辑。这些方法包括根据血型获取基因组合、组合父母的基因以获得子代可能的基因组合、根据基因组合确定血型等。123getGenes(bloodType: string): string[];combineGenes(fatherGenes: string[], motherGenes: string[]): string[];getBloodTypesFromGenes(genes: string[]): string[];4. 交互逻辑为了实现用户选择父母血型后自动计算子代血型的功能,我们使用了@Watch装饰器监听选择的变化,并在变化时调用计算方法更新结果显示。123456@State @Watch('capsuleSelectedIndexesChanged') fatherBloodTypeIndex: number[] = [0];@State @Watch('capsuleSelectedIndexesChanged') motherBloodTypeIndex: number[] = [0]; capsuleSelectedIndexesChanged() {  // 更新血型信息}5. UI设计最后,我们构建了用户界面,包括页面标题、工具介绍、父母血型选择区域以及结果显示区域。这里使用了Column、Row、Text和SegmentButton等组件来布局和美化界面。12345678910build() {  Column() {    // 页面标题    Text('血型遗传计算');    // 其他UI元素...  }  .height('100%')  .width('100%')  .backgroundColor("#f4f8fb");}总结通过上述步骤,我们成功地开发了一个基于鸿蒙NEXT的血型遗传计算器。这个案例不仅展示了鸿蒙NEXT框架下组件化开发的基本流程,同时也体现了通过合理的状态管理和逻辑处理,可以轻松实现复杂的业务需求。对于希望深入了解鸿蒙NEXT框架的开发者来说,这是一个很好的实践案例。希望这篇文章能为你提供灵感,鼓励你在鸿蒙NEXT的开发道路上继续前行。【完整代码】// 导入SegmentButton及其相关类型定义 import { SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions } from '@kit.ArkUI'; // 使用@Entry装饰器标记此组件为入口点 @Entry // 使用@Component装饰器标记此结构体为一个组件 @Component // 定义一个名为BloodTypeCalculator的结构体,用于实现血型遗传计算功能 struct BloodTypeCalculator { // 定义主题颜色,默认为橙色 @State private themeColor: string | Color = Color.Orange; // 定义文本颜色,默认为深灰色 @State private textColor: string = "#2e2e2e"; // 定义边框颜色,默认为浅灰色 @State private lineColor: string = "#d5d5d5"; // 定义基础内边距大小,默认为30 @State private basePadding: number = 30; // 存储可能的血型结果 @State private possibleBloodTypesText: string = ""; // 存储不可能的血型结果 @State private impossibleBloodTypesText: string = ""; // 定义血型列表,包含A、B、AB、O四种血型 @State private bloodTypeList: object[] = [Object({ text: 'A' }), Object({ text: 'B' }), Object({ text: 'AB' }), Object({ text: 'O' })]; // 初始化单选胶囊按钮的配置项 @State singleSelectCapsuleOptions: SegmentButtonOptions | undefined = undefined; // 监听父亲血型选择变化 @State @Watch('capsuleSelectedIndexesChanged') fatherBloodTypeIndex: number[] = [0]; // 监听母亲血型选择变化 @State @Watch('capsuleSelectedIndexesChanged') motherBloodTypeIndex: number[] = [0]; // 根据血型获取其可能的基因组合 getGenes(bloodType: string): string[] { console.info(`bloodType:${bloodType}`); switch (bloodType) { case 'A': return ['A', 'O']; // A型血可能的基因组合 case 'B': return ['B', 'O']; // B型血可能的基因组合 case 'AB': return ['A', 'B']; // AB型血可能的基因组合 case 'O': return ['O']; // O型血可能的基因组合 default: throw new Error('Invalid blood type'); // 非法血型抛出错误 } } // 组合父母的基因以获得子代可能的基因组合 combineGenes(fatherGenes: string[], motherGenes: string[]): string[] { const possibleGenes: string[] = []; // 用于存储可能的基因组合 for (const fatherGene of fatherGenes) { for (const motherGene of motherGenes) { const combinedGene = [fatherGene, motherGene].sort().join(''); // 将父母的基因组合并排序后加入数组 if (!possibleGenes.includes(combinedGene)) { possibleGenes.push(combinedGene); // 如果组合尚未存在,则加入数组 } } } return possibleGenes; // 返回所有可能的基因组合 } // 根据基因组合确定血型 getBloodTypesFromGenes(genes: string[]): string[] { const bloodTypes: string[] = []; // 用于存储可能的血型 for (const gene of genes) { if (gene === 'AA' || gene === 'AO' || gene === 'OA') { bloodTypes.push('A'); // 基因组合为AA、AO或OA时,血型为A } else if (gene === 'BB' || gene === 'BO' || gene === 'OB') { bloodTypes.push('B'); // 基因组合为BB、BO或OB时,血型为B } else if (gene === 'AB' || gene === 'BA') { bloodTypes.push('AB'); // 基因组合为AB或BA时,血型为AB } else if (gene === 'OO') { bloodTypes.push('O'); // 基因组合为OO时,血型为O } } // 去除重复的血型 return bloodTypes.filter((value, index, self) => self.indexOf(value) === index); } // 计算孩子可能的血型及不可能的血型 calculatePossibleBloodTypes(father: string, mother: string) { const fatherGenes = this.getGenes(father); // 获取父亲的基因组合 const motherGenes = this.getGenes(mother); // 获取母亲的基因组合 const possibleGenes = this.combineGenes(fatherGenes, motherGenes); // 组合父母的基因 const possibleBloodTypes = this.getBloodTypesFromGenes(possibleGenes); // 从基因组合中获取可能的血型 const allBloodTypes: string[] = ['A', 'B', 'AB', 'O']; // 所有可能的血型列表 const impossibleBloodTypes = allBloodTypes.filter(bt => !possibleBloodTypes.includes(bt)); // 计算不可能的血型 console.log(this.possibleBloodTypesText = `孩子可能血型:${possibleBloodTypes.join('、')}`); // 显示可能的血型 console.log(this.impossibleBloodTypesText = `孩子不可能血型:${impossibleBloodTypes.join('、')}`); // 显示不可能的血型 } // 当胶囊按钮的选择发生变化时调用此函数 capsuleSelectedIndexesChanged() { let father: string = this.bloodTypeList[this.fatherBloodTypeIndex[0]]['text']; // 获取父亲选择的血型 let mother: string = this.bloodTypeList[this.motherBloodTypeIndex[0]]['text']; // 获取母亲选择的血型 this.calculatePossibleBloodTypes(father, mother); // 计算并更新血型信息 } // 在组件即将出现时调用此函数 aboutToAppear(): void { this.singleSelectCapsuleOptions = SegmentButtonOptions.capsule({ buttons: this.bloodTypeList as SegmentButtonItemTuple, // 设置胶囊按钮的选项 multiply: false, // 单选模式 fontColor: Color.White, // 字体颜色为白色 selectedFontColor: Color.White, // 选中时字体颜色为白色 selectedBackgroundColor: this.themeColor, // 选中背景色为主题色 backgroundColor: this.lineColor, // 背景色为边框颜色 backgroundBlurStyle: BlurStyle.BACKGROUND_THICK // 背景模糊效果 }); this.capsuleSelectedIndexesChanged(); // 初始化时调用选择变化处理函数 } // 构建用户界面 build() { Column() { // 页面标题 Text('血型遗传计算') .fontColor(this.textColor) // 文本颜色 .fontSize(18) // 字体大小 .width('100%') // 宽度为100% .height(50) // 高度为50 .textAlign(TextAlign.Center) // 文本居中对齐 .backgroundColor(Color.White) // 背景色为白色 .shadow({ // 添加阴影效果 radius: 2, // 阴影半径 color: this.lineColor, // 阴影颜色 offsetX: 0, // 水平偏移量 offsetY: 5 // 垂直偏移量 }); // 工具介绍部分 Column() { Text('工具介绍').fontSize(20).fontWeight(600).fontColor(this.textColor); Text('血型是以A、B、O三种遗传因子的组合而决定的,根据父母的血型,就可以判断出以后出生的孩子的血型。') .fontSize(18).fontColor(this.textColor).margin({ top: `${this.basePadding / 2}lpx` }); } .alignItems(HorizontalAlign.Start) .width('650lpx') .padding(`${this.basePadding}lpx`) .margin({ top: `${this.basePadding}lpx` }) .borderRadius(10) .backgroundColor(Color.White) .shadow({ radius: 10, color: this.lineColor, offsetX: 0, offsetY: 0 }); // 父亲血型选择部分 Column() { Row() { Text('父亲血型').fontColor(this.textColor).fontSize(18); SegmentButton({ options: this.singleSelectCapsuleOptions, // 胶囊按钮的配置项 selectedIndexes: this.fatherBloodTypeIndex // 当前选中的索引 }).width('400lpx'); }.height(45).justifyContent(FlexAlign.SpaceBetween).width('100%'); // 母亲血型选择部分 Row() { Text('母亲血型').fontColor(this.textColor).fontSize(18); SegmentButton({ options: this.singleSelectCapsuleOptions, // 胶囊按钮的配置项 selectedIndexes: this.motherBloodTypeIndex // 当前选中的索引 }).width('400lpx'); }.height(45).justifyContent(FlexAlign.SpaceBetween).width('100%'); } .alignItems(HorizontalAlign.Start) .width('650lpx') .padding(`${this.basePadding}lpx`) .margin({ top: `${this.basePadding}lpx` }) .borderRadius(10) .backgroundColor(Color.White) .shadow({ radius: 10, color: this.lineColor, offsetX: 0, offsetY: 0 }); // 显示计算结果 Column() { Row() { Text(this.possibleBloodTypesText).fontColor(this.textColor).fontSize(18); }.height(45).justifyContent(FlexAlign.SpaceBetween).width('100%'); Row() { Text(this.impossibleBloodTypesText).fontColor(this.textColor).fontSize(18); }.height(45).justifyContent(FlexAlign.SpaceBetween).width('100%'); } .alignItems(HorizontalAlign.Start) .width('650lpx') .padding(`${this.basePadding}lpx`) .margin({ top: `${this.basePadding}lpx` }) .borderRadius(10) .backgroundColor(Color.White) .shadow({ radius: 10, color: this.lineColor, offsetX: 0, offsetY: 0 }); } .height('100%') .width('100%') .backgroundColor("#f4f8fb"); // 页面背景色 } } 转载自https://www.cnblogs.com/zhongcx/p/18561407
总条数:446 到第
上滑加载中