• [技术干货] 使用DevEcoStudio 开发、编译鸿蒙 NEXT_APP 以及使用中文插件
    使用DevEcoStudio 开发、编译鸿蒙 NEXT_APP 以及使用中文插件 #鸿蒙开发工具 #DevEco Studio1. 概述DevEco Studio 是华为提供的一站式集成开发环境(IDE),专为鸿蒙操作系统(HarmonyOS Next)应用和服务开发设计 DevEco Studio,掌握基本操作和开发流程。2. 安装与配置访问华为开发者联盟官网 下载 DevEco Studio 安装包运行安装程序,按照向导完成安装启动 DevEco Studio,首次启动会自动下载并安装 SDK 组件3. 创建新项目3.1 启动向导启动 DevEco Studio,选择 Start a new HarmonyOS project选择应用模板(如 Empty Ability、Native C++ 等)3.2 项目结构创建完成后,DevEco Studio 会生成标准的鸿蒙应用项目结构:myapplication/ ├── AppScope/ │ ├── resources/ │ └── app.json5 # 版本号等内容 ├── entry/ # 主模块 │ ├── src/ │ │ ├── main/ │ │ │ ├── ets/ # ArkTS 代码 │ │ │ ├── resources/ # 资源文件 │ │ │ └── module.json5 │ │ ├── test/ # 测试代码 │ │ ├── mock/ │ │ ├── ohosTest/ │ │ ├── build-profile.json5 │ │ ├── hvigorfile.ts │ │ ├── obfuscation-rules.txt │ │ └── oh-package.json5 │ └── build/ # 编译后测试包 ├── hvgor/ │ └── hvigor-config.json5 ├── build/ # 编译后正式包 └── build-profile.json5 # 项目构建配置文件4 代码方式布局使用 ArkTS 声明式语法:@Entry @Component export struct Index { @State message: string = 'Hello World' build() { Column() { Text(this.message) .fontSize(50) .fontWeight(FontWeight.Bold) Button('Click Me') .onClick(() => { this.message = 'Hello HarmonyOS!' }) } .width('100%') } } 5. 应用调试5.1 模拟器调试点击工具栏中的 Tools > Device Manager 图标在弹出窗口中点击 New Emulator选择设备类型和系统版本,点击 Next 完成创建点击工具栏中的运行按钮,选择模拟器启动应用5.2 真机调试用 USB 线将鸿蒙设备连接到电脑在设备上多次点击版本号开启 开发者选项 和 USB 调试 功能在 DevEco Studio 中选择设备并点击运行按钮,选择已连接的设备如设备未识别,检查 USB 驱动是否正确安装6. 代码编辑与辅助功能6.1 代码补全DevEco Studio 支持智能代码补全,输入代码时会自动提示可能的选项:输入组件名称时提示可用组件输入属性名时提示可用属性支持方法参数提示和类型检查6.2 代码导航Ctrl + 左键点击:跳转到变量、方法或类的定义处Shift + Shift:快速打开文件Alt + 左/右箭头:在编辑历史中导航7. 构建与打包7.1 构建应用点击 Build > Build Hap(s)/App(s) 构建 APP 包构建完成后,APK 文件位于 build/outputs/default 目录下7.2 签名配置点击 File > Project Structure > Project > Signing Configs选择已有的签名密钥配置签名信息并生成签名8. 中文插件切换中文版不是选择语言,而是需要下载插件点击File > Settings > Plugins搜索Chinese(Simplified)下载并应用重启 DevEcoStudio9. 常见问题与解决方法9.1 模拟器无法启动尝试删除并重新创建模拟器降低模拟器系统版本检查内存9.2 代码报错 “arkts-no-props-by-index”错误原因:ArkTS 不支持通过索引访问对象属性解决方法:使用点号表示法或 Map 结构替代
  • [技术干货] 鸿蒙Next实现瀑布流布局
    鸿蒙Next实现瀑布流布局 #鸿蒙影音娱乐类应用 #拍摄美化 #HarmonyOS一、环境准备与项目创建在开始实现瀑布流布局前,需确保已安装好 DevEco Studio,且已配置好鸿蒙开发环境。打开 DevEco Studio,新建一个鸿蒙应用项目,选择合适的模板(如 Empty Feature Ability),设置项目名称、包名等信息,完成项目创建。二、布局设计思路鸿蒙 Next 的瀑布流布局可以通过自定义组件结合 Column、Row 等容器组件实现。其核心思路是将数据分成若干列,每列独立滚动展示,且根据数据项高度动态调整布局,以达到类似瀑布自然流动的效果。三、基础实现创建一个自定义组件 MasonryLayout,接收图片数据数组作为参数,并根据列数将数据分配到不同列中展示:@Component export struct MasonryLayout { @Prop data: string[]; @State cols: number[] = Array.from<number>({ length: 2 }).fill(0); build() { Row({}) { ForEach(this.cols, (_col: number, cIndex) => { Column({ }) { ForEach(this.data, (item: string, i) => { if(i % this.cols.length === cIndex) { Image(item).width(`${100 / this.cols.length}%`); } }) } }) }.alignItems(VerticalAlign.Top) } } 四、引用 MasonryLayout 瀑布流组件build() { MasonryLayout({ data: ["img1.png", "img2.png", "img3.png", "img4.png", "img5.png"], }); } 五、优化与扩展1. 响应式布局通过 MediaQuery 组件根据屏幕宽度动态调整瀑布流的列数,以适配不同设备:在 UIAbility 的 onWindowStageCreate 生命周期回调中,通过窗口对象获取启动时的应用窗口宽度并注册回调函数监听窗口尺寸变化。将窗口尺寸的长度单位由 px 换算为 vp 后,即可基于前文中介绍的规则得到当前断点值,此时可以使用状态变量记录当前的断点值方便后续使用MainAbility.tsimport { window, display } from "@kit.ArkUI"; import { UIAbility } from "@kit.AbilityKit"; export default class MainAbility extends UIAbility { private windowObj?: window.Window; private col: number = 2; //... // 根据当前窗口尺寸更新断点 private updateBreakpoint(windowWidth: number): void { // 将长度的单位由px换算为vp let windowWidthVp = windowWidth / display.getDefaultDisplaySync().densityPixels; let col: number = this.col; if (windowWidthVp < 320) { // "xs"; col = 1; } else if (windowWidthVp < 600) { // "sm"; col = 2; } else if (windowWidthVp < 840) { // "md"; col = 3; } else { // "lg"; col = 4; } if (this.col !== col) { this.col = col; } } onWindowStageCreate(windowStage: window.WindowStage): void { windowStage.getMainWindow().then((windowObj) => { this.windowObj = windowObj; // 获取应用启动时的窗口尺寸 this.updateBreakpoint(windowObj.getWindowProperties().windowRect.width); // 注册回调函数,监听窗口尺寸变化 windowObj.on("windowSizeChange", (windowSize) => { this.updateBreakpoint(windowSize.width); }); }); // ... } //... } MasonryLayout.etsinterface IBpMapCol { xs: number; sm: number; md: number; lg: number; } const bpMapCol = new Map<string, number>(); bpMapCol.set('xs', 1) bpMapCol.set('sm', 2) bpMapCol.set('md', 3) bpMapCol.set('lg', 4) @Component export struct MasonryLayout { @StorageProp('currentBreakpoint') curBp: keyof IBpMapCol = 'sm'; @Prop data: string[]; @State cols: number[] = Array.from<number>({ length: bpMapCol.get(this.curBp) || 2 }).fill(0); build() { Row({}) { ForEach(this.cols, (_col: number, cIndex) => { Column({ }) { ForEach(this.data, (item: string, i) => { if(i % this.cols.length === cIndex) { Image(item).width(`${100 / this.cols.length}%`); Text(this.curBp) } }) } }) }.alignItems(VerticalAlign.Top) } } 注:鸿蒙 next 中无法使用索引访问对象属性,如 const obj = { a: 1 } 无法使用 obj[a],这种情况可以用 Map2. 动态加载数据为了实现类似真实瀑布流不断加载新数据的效果,可以结合鸿蒙的 LazyForEach 组件,在滚动到列表底部时触发数据加载逻辑六、网络权限// config.json { "module": { "reqPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "需要网络权限来加载图片" } ] } } 七、常见问题与解决方案1. 图片加载后布局跳动解决方案:使用预估高度占位,图片加载完成后更新高度2. 大数据量性能问题解决方案:实现虚拟列表,只渲染可视区域内的元素3. 滚动卡顿解决方案:使用防抖/节流处理滚动事件避免在滚动回调中执行复杂计算使用鸿蒙的 Canvas 组件替代部分布局组件4. 不同设备适配问题解决方案:使用响应式布局动态调整列数基于设备类型设置不同的默认列数八、最佳实践总结优先使用固定高度:如果业务场景允许,尽量使用固定高度或宽高比,减少动态测量开销合理实现懒加载:对于非首屏内容或图片资源,一定要实现懒加载渐进式增强体验:先确保基础功能可用,再添加动画和交互效果测试与优化:在不同设备上测试性能表现,针对卡顿问题进行专项优化遵循鸿蒙设计规范:保持与鸿蒙系统一致的视觉风格和交互体验
  • [技术干货] 鸿蒙NEXT上传图片功能PhotoViewPicker核心功能解析
    鸿蒙NEXT上传图片功能PhotoViewPicker核心功能解析 #ArkTS#鸿蒙Next#HarmonyOS_SDK应用服务#HarmonyOS 语言PhotoViewPicker 是鸿蒙系统中用于媒体资源选择的核心组件,通过它可以便捷地实现图片、视频等媒体文件的选择功能。下面从基本用法、参数配置到高级应用进行全面解析:一、PhotoViewPicker 基础用法PhotoViewPicker 的使用流程主要分为三步:创建实例配置参数启动选择器获取结果以下是最基本的使用示例:import photoAccessHelper from "@ohos.photoAccess.photoAccessHelper"; async function pickImage() { try { // 1. 创建选择器实例 const picker = photoAccessHelper.createPhotoViewPicker(); // 2. 配置选择参数(此处使用默认配置) const options = { MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, // 只选择图片 maxSelectNumber: 1, // 最多选择张数 isSearchSupported: true, // 显示搜索 isPhotoTakingSupported: true, // 支持拍照 recommendationOptions: true, // 智能推荐 preselectedUris: true, // 预览文件 isPreviewForSingleSelectionSupported: true, // 单选是否支持预览 }; // 3. 启动选择器并获取结果 const selectedAssets = await picker.select(options); if (selectedAssets.photoUris.length) { // 处理选择的资源 console.info("选择的资源:", selectedAssets.photoUris); } } catch (error) { console.error("选择图片失败:", error); } } 二、关键参数详解PhotoViewPicker 的参数配置非常灵活,可以根据需求定制选择器的行为:const options = { MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, // 只选择图片 maxSelectNumber: 1, // 最多选择张数 isSearchSupported: true, // 显示搜索 isPhotoTakingSupported: true, // 支持拍照 recommendationOptions: true, // 智能推荐 preselectedUris: true, // 预览文件 isPreviewForSingleSelectionSupported: true, // 单选是否支持预览 }; 三、MIMEType 参数深度解析MIMEType 参数是 PhotoViewPicker 中非常强大的一个配置项,它允许你精确控制选择器中显示的文件类型:// 示例1:只允许选择JPEG和PNG图片 IMAGE_TYPE = 'image/*', // 示例2:只允许选择视频文件 VIDEO_TYPE = 'video/*', // 示例3: MOVING_PHOTO_IMAGE_TYPE = 'image/movingPhoto' // 示例4:允许所有 IMAGE_VIDEO_TYPE = '*/*', 四、处理选择结果选择器返回的结果是一个 PhotoSelectResult 对象,包含以下关键属性:class PhotoSelectResult { /** * The uris for the selected files. * * @type { Array<string> } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @since 10 */ /** * The uris for the selected files. * * @type { Array<string> } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @atomicservice * @since 11 */ /** * The uris for the selected files. * * @type { Array<string> } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @crossplatform * @atomicservice * @since 12 */ photoUris: Array<string>; /** * Original option. * * @type { boolean } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @since 10 */ /** * Original option. * * @type { boolean } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @atomicservice * @since 11 */ /** * Original option. * * @type { boolean } * @syscap SystemCapability.FileManagement.PhotoAccessHelper.Core * @crossplatform * @atomicservice * @since 12 */ isOriginalPhoto: boolean; } 获取到资源后,通常需要进行以下处理:使用文件 URI 读取文件内容进行必要的格式转换(如压缩图片)上传到服务器或保存到本地// 这里以oss上传为例 // uri为选择图片路径 import { http } from '@kit.NetworkKit'; import fs from '@ohos.file.fs' import { request } from '@kit.BasicServicesKit'; export async function upload (uri: string): Promise<string> { // 此处从服务器获取 const data = { policy: 'qweqwe', signature: 'qweqwe', ossAccessKeyId: 'qweqwe', host: 'https://???/api', }; const name = Date.now() + '.' + uri.split('.').pop(); const key = `${new Date().getFullYear()}/${name}` const context = getContext(); // 通过getContext获取沙箱地址 const destPath = `${context.cacheDir}/${name}`; const file = fs.openSync(uri); // 将文件复制一份到沙箱缓存地址,这一步尤为重要,只有沙箱地址的文件可以进行上传操作 fs.copyFileSync(file.fd, destPath); const result = await request.uploadFile(context,{ // files字段是上传的文件组成的列表,类型为formData files: [{ filename: 'file', name: 'file', uri: `internal://cache/${name}`, type: 'image/jpeg' }], // data字段是携带的参数,建议把类型设置为formData data: [ { name: 'name', value: `${name}`, }, { name: 'policy', value: `${data.policy}` }, { name: 'OSSAccessKeyId', value: Object.values(data)[2] }, { name: 'signature', value: `${data.signature}` }, { name: 'key', value: key }, ], method: http.RequestMethod.POST, header: { Accept: '*/*', // 设置header确保参数类型为FormData "Content-Type": "multipart/form-data" }, // 请求地址 url: data.host }); return new Promise((res, rej) => { result.on('progress', (u, t) => { console.log('进度', u / t); }); result.on('complete', e => { console.log('ok', JSON.stringify(e)); res(`${data.host + key}`); }); result.on('fail', e => { console.log('错误', JSON.stringify(e)); rej(e); }) }); } 五、注意事项与常见问题权限要求 需要在 config.json 中声明文件访问权限:{ "requestPermissions": [ { "name": "ohos.permission.READ_MEDIA", "reason": "需要访问媒体文件" } ] } 文件处理注意系统媒体库中的文件 URI 通常是临时的,建议复制到应用私有目录再使用const name = Date.now() + ""; const key = `${new Date().toLocaleDateString()}/${name}`; const context = getContext(); const destPath = `${context.cacheDir}/${name}`; const file = fileIo.openSync(uri); fileIo.copyFileSync(file.fd, destPath); console.log(destPath); 大文件处理时建议进行异步操作,避免 UI 卡顿request.uploadFile文件上传时候一直报错 401兼容性注意:不同鸿蒙版本的 API 可能存在差异,建议在开发前查阅对应版本的官方文档部分参数(如 title、initialDirectory)可能只在特定版本中支持通过合理配置 PhotoViewPicker 的各项参数,开发者可以轻松实现符合需求的媒体选择功能,为应用增色不少。
  • [开发技术领域专区] 贡献 OpenHarmony 库关键配置
    贡献 OpenHarmony 库关键配置 #自研框架#ArkUI-X#三方框架#OpenHarmony#HarmonyOS创建第三方库打开 DevEco Studio 创建一个项目,然后创建一个库模块,选择 static library。详细步骤可参考:开发静态共享包。在项目内会生成 library 文件夹对所创建的库模块进行开发,需要完善库模块中生成的 oh-package.json5 文件,有关三方库名称的规则,请参阅三方库名称指南章节,其他配置项的设置请参考 oh-package.json5 配置说明。目录分析 library // HAR根目录 ├─libs // 存放用户自定义引用的Native库,一般为.so文件f └─src │ └─main │ ├─cpp │ │ ├─types // 定义Native API对外暴露的接口 │ │ │ └─liblibrary │ │ │ ├─index.d.ts │ │ │ └─oh-package.json5 │ │ ├─CMakeLists.txt // CMake配置文件 │ │ └─napi_init.cpp // C++源码文件 │ └─ets // ArkTS源码目录 │ │ └─components │ │ └─MainPage.ets │ ├─resources // 资源目录,用于存放资源文件,如图片、多媒体、字符串等 │ └─module.json5 // 模块配置文件,包含当前HAR的配置信息 ├─build-profile.json5 // Hvigor编译构建所需的配置文件,包含编译选项 ├─hvigorfile.ts // Hvigor构建脚本文件,包含构建当前模块的插件、自定义任务等 ├─Index.ets // HAR的入口文件,一般作为出口定义HAR对外提供的函数、组件等 └─oh-package.json5 // HAR的描述文件,定义HAR的基本信息、依赖项等 library/build-profile.json5 建议开启代码混淆{ "apiType": "stageMode", "buildOption": {}, "buildOptionSet": [ { "name": "release", "arkOptions": { // 混淆相关参数 "obfuscation": { "ruleOptions": { // true表示进行混淆,false表示不进行混淆。5.0.3.600及以上版本默认为false "enable": true, // 混淆规则文件 "files": ["./obfuscation-rules.txt"] }, // consumerFiles中指定的混淆配置文件会在构建依赖这个library的工程或library时被应用 "consumerFiles": ["./consumer-rules.txt"] } } } ], "targets": [ { "name": "default" } ] } build-profile.json5 设置 useNormalizedOHMUrl{ "app": { "products": [ { "buildOption": { "strictMode": { "useNormalizedOHMUrl": false } } } ] } } 在库模块的根目录中,创建一个 README.md(不区分大小写) 文件,描述您三方库的代码以及如何使用它,文件不能为空。README.md 模板# feilongui FeilongUI 是一款功能丰富、高效易用的 UI 库,旨在帮助开发者快速构建美观、交互流畅的用户界面。以下是该库的详细使用指南。 ## 安装命令 README.md 中未包含安装命令会导致审核失败 `ohpm install feilongui` ## 模块介绍 README.md 中缺少简要的三方库使用说明会导致审核失败没有文件会报错ohpm ERROR: HttpCode 400 The OHPM package must contain a non-empty readme.md file. ohpm ERROR: Publish failed, detail: The "Publish" request to url "https://ohpm.openharmony.cn/ohpm/feilongui" has failed在库模块的根目录中,创建一个 CHANGELOG.md 文件,描述您三方库的代码不同版本的变更内容,文件不能为空。没有文件会报错ohpm ERROR: HttpCode 400 The OHPM package must contain a non-empty changelog.md file. ohpm ERROR: Publish failed, detail: The "Publish" request to url "https://ohpm.openharmony.cn/ohpm/feilongui" has failedChangelog.md模板(CHANGELOG.md中未包含当前版本,也就是oh-package.json5中version值的版本。 CHANGELOG.md由清晰的版本号和该版本的修改内容组成,修改内容不可为空,不然过不了审核)# Changelog ## 1.0.0 \*\* Unreleased Added - 新增 [按钮],用于 [点击]。 Changed - 优化 [模块 / 功能名称] 的性能,提升 [具体指标,如响应速度、加载时间等]。 Deprecated - 标记 [某接口 / 方法 / 功能] 为废弃,计划在 [具体版本] 中移除。​ Removed - 移除不再维护的 [某功能 / 模块]。​ Fixed - 修复 [问题描述] 导致的 [具体错误,如程序崩溃、数据错误等]。​ Security - 修复 [名称] 提升系统安全性。​在库模块的根目录中,创建一个 LICENSE(不区分大小写) 文件,指定该软件包可以在什么样的许可下被使用、修改和共享,文件不能为空。许可证模板(LICENSE文件中许可证条款内容和oh-package.json5文件中许可证名称不一致会审核失败) 许可证获取地址Copyright [此处填写年份,如2025] [此处填写在library/oh-package.json5中的name,否则审核失败] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.没有 LICENSE 文件会报错ohpm ERROR: HttpCode 400 The OHPM package must contain a non-empty license file. ohpm ERROR: Publish failed, detail: The "Publish" request to url "https://ohpm.openharmony.cn/ohpm/feilongui" has failed在进行 publish 发布前,请先确保在 OpenHarmony 三方库中心仓上已经创建了帐号,且利用工具 ssh-keygen 生成公、私钥文件ssh-keygen -m PEM -t RSA -b 4096 -f ~/.ssh_ohpm/mykey ohpm config set key_path ~/.ssh_ohpm/mykey不签名发布时会报错ohpm ERROR: Publish failed, detail: The "key_path" is empty - configure "key_path" in the .ohpmrc file.不设置密码发布时会报错ohpm ERROR: Private key without passphrase is not supported. ohpm ERROR: You must config a encrypted private key using a non-empty passphrase. ohpm ERROR: Publish failed, detail: Not supported private key. 不上传公钥(~/.ssh_ohpm/mykey.pub)会报错ohpm ERROR: HttpCode 400 The publicKey verify failed! ohpm ERROR: Publish failed, detail: The "Login" request to url "https://ohpm.openharmony.cn/ohpm/login" has failed设置 publish_id不设置 publish_id 会报错ohpm ERROR: Publish failed, detail: The "publish_id" is empty - configure "publish_id" in .ohpmrc file.publish_id 设置错误会报错ohpm ERROR: HttpCode 400 The publishId is invalid! ohpm ERROR: Publish failed, detail: The "Login" request to url "https://ohpm.openharmony.cn/ohpm/login" has failed发布后再发布不更新版本号会报错ohpm ERROR: HttpCode 400 The OHPM package version already exists. ohpm ERROR: Publish failed, detail: The "Publish" request to url "https://ohpm.openharmony.cn/ohpm/feilongui" has failed利用 DevEco Studio 对开发后的库模块打成 HAR 包。( 详情请见:构建 HAR)在工具中构建 library 库时需要选中 library 文件夹内的文件构建后所在目录为library/build/default/outputs/default/library-signed.har后续发布需要使用这个路径
  • [技术干货] DevEcoStudio 中使用模拟器时如何过滤日志
    DevEcoStudio 中使用模拟器时如何过滤日志 #鸿蒙核心技术#鸿蒙开发者工具##DevEcoStudio在 Hilog > Settings > Filter 设置Log message: A03d00/JSAPP当你看到不断更新的日志时,你会不会崩溃因为 No-filters 模式下模拟器会输出系统所有日志信息,这个模式在开发中并不使用,可用自定义模式找到模拟器日志,选择自定义在 Filter name 中输入custom (可以忽略这一步)Log level 中选择Info在 Log message 输入框中输入A03d00/JSAPP(打印内容)操作后在控制台查看就行了其他日志内容类型启动相关日志:记录模拟器启动过程中的各种信息,如加载系统镜像、初始化硬件设备、配置网络等操作的结果和状态。如果模拟器启动失败,这里会包含导致失败的具体原因,例如端口冲突、虚拟机管理程序未启用或配置错误、权限不足等。系统运行日志:包含模拟器系统运行时的各种事件和状态信息,如进程的启动和停止、系统服务的运行情况、内存和 CPU 的使用统计等。这些信息有助于开发者了解模拟器的整体运行状况,判断是否存在资源瓶颈或系统异常。应用相关日志:当在模拟器中运行应用程序时,会记录应用的启动、暂停、恢复、销毁等生命周期事件,以及应用中发生的各种错误、警告和调试信息。这对于开发者调试应用在模拟器上的运行问题非常重要,可以帮助定位应用中的代码错误、资源泄漏等问题。硬件模拟日志:模拟器需要模拟设备的硬件功能,如屏幕、摄像头、传感器等。相关日志会记录硬件模拟的操作和状态,例如屏幕分辨率的设置、摄像头的调用情况、传感器数据的模拟等。如果应用依赖于特定的硬件功能,这些日志可以帮助确定硬件模拟是否正常工作,以及应用与模拟硬件的交互是否正确。日志级别DEBUG:调试级别日志,通常包含详细的调试信息,用于开发者在开发过程中深入了解程序的执行流程和变量状态等。在正式发布版本中默认不被打印,只有在调试版本或打开调试开关的情况下才会打印。INFO:信息级别日志,用于记录程序运行中的重要信息,如系统启动、模块加载、关键操作的执行等,有助于开发者了解程序的整体运行情况。WARN:警告级别日志,提示可能存在的问题或潜在的风险,如资源使用接近上限、不推荐的操作被执行等,但并不一定表示程序出现了错误。ERROR:错误级别日志,用于记录程序中发生的错误情况,如代码异常、文件读取失败、网络连接中断等,这些错误可能会导致程序的部分功能无法正常运行。FATAL:严重错误级别日志,通常表示程序出现了严重的错误,导致整个系统或关键功能无法继续运行,如系统崩溃、内存耗尽等。
  • [互动交流] CodeArts IDE 鸿蒙版本相关问题咨询
    CodeArts IDE 鸿蒙版本功能是否完整?鸿蒙系统终端是否有命令教程?
  • [专题汇总] 总结的时间到了,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 教程对你有所帮助,期待您的点赞、评论、收藏。
总条数:462 到第
上滑加载中