• [专题汇总] 总结的时间到了,3月份技术干货总结
    大家好哦 ,三月份的干货合集来了,这次包含又redis,MySQL,HarmonyOS,Linux,Python,GoLang,Nginx,spring等多方面内容,希望可以帮到大家1.Redis Key的数量上限及优化策略分享【转】https://bbs.huaweicloud.com/forum/thread-02127178647758557099-1-1.html2.MySQL多列IN查询的实现【转】https://bbs.huaweicloud.com/forum/thread-0282178647688443077-1-1.html3.MySQL新增字段后Java实体未更新的潜在问题与解决方案【转】https://bbs.huaweicloud.com/forum/thread-0211178647622373117-1-1.html4.浅谈mysql的sql_mode可能会限制你的查询【转】https://bbs.huaweicloud.com/forum/thread-02127178647532278098-1-1.html5.MySQL使用SHOW PROCESSLIST的实现【转】https://bbs.huaweicloud.com/forum/thread-0238178647442172080-1-1.html6.HarmonyOS Next音乐播放器技术栈详解【转】https://bbs.huaweicloud.com/forum/thread-0213178647328545104-1-1.html7.Linux上设置Ollama服务配置(常用环境变量)【转】https://bbs.huaweicloud.com/forum/thread-0274178647201397098-1-1.html8.GORM中Model和Table的区别及使用【转】https://bbs.huaweicloud.com/forum/thread-0238178647121910079-1-1.html9. Python 的 ultralytics 库功能及安装方法【转】https://bbs.huaweicloud.com/forum/thread-0211178647036676116-1-1.html10.Python如何在Word中查找并替换文本【转】https://bbs.huaweicloud.com/forum/thread-0213178646924252103-1-1.html?fid=56811.GoLand 中设置默认项目文件夹的实现【转】https://bbs.huaweicloud.com/forum/thread-0210178646835711094-1-1.html12.Python Geopy库地理编码和地理距离计算案例展示【转】https://bbs.huaweicloud.com/forum/thread-0282178646750927076-1-1.html13.Java RMI技术详解与案例分析https://bbs.huaweicloud.com/forum/thread-0274178534386630091-1-1.html14.Volatile不保证原子性及解决方案https://bbs.huaweicloud.com/forum/thread-0274178534309336090-1-1.html15.Redis数据结构—跳跃表 skiplist 实现源码分析https://bbs.huaweicloud.com/forum/thread-0282178533434493072-1-1.html16.Java Executors类的9种创建线程池的方法及应用场景分析https://bbs.huaweicloud.com/forum/thread-0210178533186291086-1-1.html17.Nginx性能调优5招35式不可不知的策略实战https://bbs.huaweicloud.com/forum/thread-0213178533127218096-1-1.html18.Tomcat的配置文件中有哪些关键的配置项,它们分别有什么作用?https://bbs.huaweicloud.com/forum/thread-0210178533048188084-1-1.html19.深度长文解析SpringWebFlux响应式框架15个核心组件源码    https://bbs.huaweicloud.com/forum/thread-0282178532893901071-1-1.html20.对比传统数据库,TiDB 强在哪?谈谈 TiDB 的适应场景和产品能力https://bbs.huaweicloud.com/forum/thread-02127178532544750088-1-1.html
  • [技术干货] HarmonyOS Next音乐播放器技术栈详解【转】
    技术栈详解1. HarmonyOS Next开发环境该项目基于HarmonyOS Next开发框架构建,使用DevEco Studio作为集成开发环境。HarmonyOS Next是华为自主研发的分布式操作系统,专为全场景智能设备提供统一的操作系统解决方案。相比传统移动应用开发,HarmonyOS具有分布式能力、一次开发多端部署等显著优势。2. ArkTS声明式开发范式项目代码采用ArkTS语言开发,这是一种基于TypeScript的声明式UI开发语言,专为HarmonyOS定制。主要特点包括:基于组件的UI构建:通过@Component装饰器定义可复用UI组件声明式编程:使用类似HTML的结构直接描述UI界面状态管理:使用@State等装饰器管理组件状态生命周期钩子:提供aboutToAppear、aboutToDisappear等生命周期方法3. 多媒体处理技术应用核心功能基于鸿蒙媒体管理框架实现,主要使用了:1import media from '@ohos.multimedia.media';这个框架提供了强大的音频处理能力:AVPlayer音频播放器:创建和管理音频播放实例状态管理机制:通过事件监听处理不同播放状态播放控制API:提供play()、pause()、stop()等方法进度控制:支持seek()方法实现播放位置跳转4. 组件化架构设计项目采用清晰的组件化设计思路,主要分为:入口组件:Index.ets作为应用入口页面功能组件:MusicPlayer.ets封装所有音乐播放相关逻辑和UI资源管理:通过resources目录统一管理应用资源这种架构设计使代码结构清晰,功能模块化,便于维护和扩展。核心功能实现剖析1. 音频播放器初始化与状态管理1234567891011121314151617181920initAudioPlayer() {  if (this.audioPlayer === null) {    this.audioPlayer = media.createAVPlayer();    // 设置音频源    this.audioPlayer.url = 'resource://raw/beautiful_now.mp3';         // 设置状态回调    this.audioPlayer.on('stateChange', (state) => {      // 状态监听处理逻辑    });         // 错误回调    this.audioPlayer.on('error', (err) => {      console.error(`播放器错误: ${err.code}, ${err.message}`);    });         // 准备播放器    this.audioPlayer.prepare();  }}这段代码展示了鸿蒙音频播放器的创建和初始化过程,通过事件驱动的方式监听播放器状态变化,实现播放流程控制。2. 播放控制实现应用提供了三种基本控制功能:开始播放:调用audioPlayer.play()方法并启动计时器暂停播放:调用audioPlayer.pause()方法并停止计时器停止播放:调用audioPlayer.stop()方法并重置播放状态这些控制方法配合状态监听,构成了完整的音频控制流程。3. 进度显示与交互123456789101112131415161718192021222324252627282930313233// 进度条和时间显示部分Row() {  Text(this.formatTime(this.currentTime))    .fontSize(14)    .width(50)     Slider({    value: this.currentTime,    min: 0,    max: this.duration,    step: 1,    style: SliderStyle.OutSet  })    .width('80%')    .trackThickness(4)    .showTips(true)    .onChange((value: number) => {      this.currentTime = value;    })    .onTouch((event) => {      if (event.type === TouchType.Down) {        this.sliderMoving = true;      } else if (event.type === TouchType.Up) {        this.sliderMoving = false;        this.setPosition(this.currentTime);      }    })     Text(this.formatTime(this.duration))    .fontSize(14)    .width(50)    .textAlign(TextAlign.End)}此部分代码实现了进度条和时间显示功能,特别值得注意的是:使用Slider组件提供直观的进度显示和控制通过onTouch事件实现拖动检测,确保用户体验流畅时间格式化显示,提升用户体验4. UI设计与用户体验应用界面设计简洁美观,主要包括:专辑封面区域:以蓝色背景块模拟专辑封面歌曲信息显示:包含歌曲名称和艺术家信息播放控制按钮:采用圆形设计,提供直观的播放/暂停/前进/后退功能进度控制区域:包含进度条和时间显示停止按钮:提供一键停止功能整体UI遵循了现代移动应用设计理念,布局合理,操作流畅。技术特点与优势1. 声明式编程范式与传统命令式编程相比,ArkTS声明式UI编程具有以下优势:代码更简洁:直接描述界面结构,减少样板代码易于理解:UI结构与实际渲染结果对应明确状态驱动:UI随状态自动更新,无需手动DOM操作2. 组件生命周期管理12345678910111213aboutToAppear() {  // 组件出现时初始化播放器  this.initAudioPlayer();} aboutToDisappear() {  // 组件消失时释放资源  this.stopTimer();  if (this.audioPlayer) {    this.audioPlayer.release();    this.audioPlayer = null;  }}这段代码展示了鸿蒙应用中组件生命周期管理的最佳实践,确保资源在适当时机被创建和释放,避免内存泄漏。3. 响应式状态管理通过@State装饰器,实现了组件状态的响应式管理:1234@State currentTime: number = 0;            @State duration: number = 180;             @State isPlaying: boolean = false;         @State sliderMoving: boolean = false;      状态变化会自动触发UI更新,简化了状态同步逻辑。4. 事件处理机制应用中大量使用事件处理机制,如:播放器状态变化监听按钮点击事件处理滑块拖动事件处理这些事件处理逻辑清晰,使得用户交互更加流畅可靠。开发经验汇总1. 资源管理应用通过路径resource://raw/beautiful_now.mp3访问音频资源,体现了鸿蒙系统的资源管理机制。2. 错误处理代码中包含完善的错误处理机制,尤其是对播放器错误的监听和处理,提高了应用的稳定性。3. 性能优化使用计时器定期更新进度,而非频繁查询组件生命周期中及时释放资源拖动状态管理,避免拖动时的频繁更新
  • [技术干货] 给Web开发者的HarmonyOS指南02-布局样式
    给Web开发者的HarmonyOS指南02-布局样式本系列教程适合鸿蒙 HarmonyOS 初学者,为那些熟悉用 HTML 与 CSS 语法的 Web 前端开发者准备的。本系列教程会将 HTML/CSS 代码片段替换为等价的 HarmonyOS/ArkUI 代码。开发环境准备DevEco Studio 5.0.3HarmonyOS Next API 15布局基础对比在Web开发中,我们使用CSS来控制元素的布局和样式。而在HarmonyOS的ArkUI中,我们使用声明式UI和链式API来实现相同的效果。本文将对比两种框架在布局方面的异同。盒子模型在Web开发中,CSS盒子模型包含内容(content)、内边距(padding)、边框(border)和外边距(margin)。在ArkUI中,这些概念依然存在,只是写法有所不同,容易上手。HTML/CSS代码:<div class="box"> 盒子模型 </div> <style> .box { box-sizing: border-box; /* 内容 */ width: 150px; height: 100px; /* 内边距 */ padding: 10px; /* 边框 */ border: 10px solid pink; /* 底部外边距 */ margin-bottom: 10px; } </style> ArkUI代码:Text('盒子模型') .width(150) .height(100) .padding(10) .border({ width: 10, style: BorderStyle.Solid, color: Color.Pink }) .margin({ bottom: 10 }) 背景色和文字颜色在Web开发中,我们使用 background-color 和 color 属性来设置背景色和文字颜色。在ArkUI中,我们使用 backgroundColor 和 fontColor 方法。HTML/CSS代码:<div class="box"> 背景色、文字色 </div> <style> .box { /* 背景色 */ background-color: #36d; /* 文字色 */ color: #fff; } </style> ArkUI代码:Text('背景色、文字色') .backgroundColor('#36d') .fontColor('#fff') 内容居中在Web开发中,我们使用 display: flex 配合 justify-content 和 align-items 实现内容居中。在ArkUI中,我们可以使用 Column 或 Row 组件配合 justifyContent 和 alignItems 属性。HTML/CSS代码:<div class="box"> 内容居中 </div> <style> .box { display: flex; justify-content: center; align-items: center; } </style> ArkUI代码:Column() { Text('内容居中') } .backgroundColor('#36D') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .width(150) .height(100) .padding(10) 圆角在Web开发中,我们使用border-radius属性来设置圆角。在ArkUI中,我们使用borderRadius方法。HTML/CSS代码:<div class="box"> 圆角 </div> <style> .box { border-radius: 10px; } </style> ArkUI代码:Text('圆角') .width(150) .height(100) .backgroundColor('#36D') .borderRadius(10) 阴影效果在Web开发中,我们使用box-shadow属性来设置阴影效果。在ArkUI中,我们使用shadow方法。HTML/CSS代码:<div class="box"> 阴影 </div> <style> .box { box-shadow: 0 6px 50px rgba(0, 0, 0, 0.5); } </style> ArkUI代码:Text('阴影') .width(150) .height(100) .backgroundColor('#F5F5F5') .shadow({ offsetX: 0, offsetY: 6, radius: 50, color: 'rgba(0, 0, 0, 0.5)', }) 布局容器和轴向基本容器在Web开发中,我们使用<div>作为通用容器。在ArkUI中,我们主要使用Column和Row组件,注意 alignItems 需区分轴向。HTML/CSS代码:<div class="column"> <!-- 垂直方向布局 --> </div> <div class="row"> <!-- 水平方向布局 --> </div> <style> .column { display: flex; flex-direction: column; align-items: center; } .row { display: flex; flex-direction: row; align-items: center; } </style> ArkUI代码:Column() { // 垂直方向布局,交叉轴水平居中 } .alignItems(HorizontalAlign.Center) Row() { // 水平方向布局,交叉轴垂直居中 } .alignItems(VerticalAlign.Center) 关键区别总结样式应用方式:HTML/CSS:使用选择器和属性声明样式ArkUI:使用链式API直接在组件上设置样式布局容器:HTML:使用 <div> 等标签,配合CSS实现布局ArkUI:使用专门的布局组件如 Column、Row 等组件,配合样式属性布局单位使用:HTML/CSS:使用 px、em、rem、百分比等单位ArkUI:使用 px、vp、lpx 、百分比等单位,使用数字单位 vp 可省略样式继承:HTML/CSS:通过CSS选择器实现样式继承ArkUI:没有样式继承学习建议理解链式API:熟悉ArkUI的链式API调用方式掌握常用样式方法的命名规则布局思维转变:从CSS盒模型思维转向组件化思维理解ArkUI的布局容器特性样式设置习惯:养成使用链式API设置样式的习惯注意样式方法的参数格式组件嵌套:合理使用组件嵌套实现复杂布局注意组件的父子关系总结作为Web开发者,迁移到 HarmonyOS 开发需要适应新的布局和样式设置方式。概念其实非常相似,通过理解这些差异,并掌握ArkUI的组件化开发方式,Web开发者可以快速上手HarmonyOS开发。希望这篇 HarmonyOS 教程对你有所帮助,期待您的 👍点赞、💬评论、🌟收藏 支持。
  • [技术干货] 给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开发,并创建出更加精美的应用界面!