-
1、问题说明在银行移动应用中,用户需要选择所在城市来获取相应的服务信息。传统的城市选择方式(如下拉列表)在移动端体验不佳,需要一个更加直观、易用的城市选择弹窗组件。2、原因分析用户体验需求:移动端用户需要快速、直观地选择城市,传统的下拉选择器在小屏幕上操作困难界面一致性:需要与整体应用设计风格保持一致,提供统一的交互体验功能完整性:需要支持城市列表展示、选择确认、取消操作等完整功能数据管理:需要处理城市列表数据、当前选中状态、回调处理等3、解决思路采用底部弹窗设计:利用HarmonyOS的Dialog组件,从底部弹出,符合移动端用户习惯使用TextPicker组件:利用系统原生的选择器组件,提供流畅的滚动选择体验分层架构设计:将弹窗逻辑、数据管理、UI展示分离,提高代码可维护性参数化配置:通过参数传递实现组件的灵活复用4、解决方案1. 组件架构设计@ComponentV2export struct CityPickerDialog { @Param @Require params: DialogParams; @Local cityList: string[] = []; @Local tempCityIndex: number = CommonNumbers.ZERO; @Local onCitySelected: (city: string, index: number) => void = () => {}; @Local onRefreshCityList: () => void = () => {};}核心特性:使用@ComponentV2装饰器,支持最新的组件特性通过@Param接收弹窗参数,@Local管理内部状态支持城市选择回调和刷新回调2. 交互逻辑处理private handleCancelClick(): void { this.params.close && this.params.close(this.params.id);}3. 静态展示方法static show(cityList: string[], currentCity: string, onCitySelected: (city: string, index: number) => void, onRefreshCityList?: () => void): void { const currentIndex = cityList.indexOf(currentCity); const selectedIndex = currentIndex >= 0 ? currentIndex : 0; const cityPickerParams: CityPickerParams = { cityList: cityList, tempCityIndex: selectedIndex, onCitySelected: onCitySelected, onRefreshCityList: onRefreshCityList || (() => {}) }; HSDialogUtil.open({ alignment: 'bottom', title: getResourceStr($r('app.string.please_select_city')), data: { params: cityPickerParams, callBack: onCitySelected } }, wrapBuilder(CityPickerDialogBuilder));}UI特点:采用三栏布局:取消-标题-确认使用TextPicker提供流畅的滚动体验添加视觉指示器突出当前选中项遵循应用设计规范,使用统一的颜色和字体4. 常量管理组件使用了统一的常量管理:CommonSizes:尺寸常量CommonFontSizes:字体大小常量CommonNumbers:数字常量CommonBorderRadius:圆角常量CommonMargins:边距常量CommonAnimations:动画时长常量总结CityPickerDialogView组件成功解决了移动端城市选择的用户体验问题,具有以下优势:技术优势组件化设计:采用现代HarmonyOS组件架构,代码结构清晰参数化配置:支持灵活的参数传递,提高组件复用性异常处理:完善的错误处理机制,确保组件稳定性统一规范:遵循应用设计规范,保持界面一致性用户体验优势直观操作:底部弹窗设计符合移动端用户习惯流畅交互:使用原生TextPicker,提供流畅的滚动体验清晰反馈:视觉指示器明确显示当前选中项便捷操作:支持取消和确认操作,操作简单明了维护性优势代码分离:UI展示与业务逻辑分离,便于维护常量管理:统一的常量管理,便于主题切换类型安全:使用TypeScript,提供类型安全保障文档完善:代码注释清晰,便于团队协作该组件为银行移动应用提供了高质量的城市选择功能,是移动端UI组件设计的优秀实践案例。
-
在大多数app中,会有这样的效果,顶部标题,中间图片,下面列表或者下方内容客户滑动。当底部内容滑动的时候,顶部标题栏会模糊效果。针对这种效果,鸿蒙提供了专有的api实现。下面介绍一下使用效果 import { HdsNavigation, HdsNavigationAttribute, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';import { ComponentContent, LengthMetrics, Prompt } from '@kit.ArkUI';import { BusinessError } from '@kit.BasicServicesKit';/** * 通过组件导航将标题栏设置动态模糊样式 */@Builderfunction menuComponent() { Menu() { MenuItem({ content: "copy" }).onClick(() => { Prompt.showToast({ message: 'on click' }) }) MenuItem({ content: "paste" }).enabled(false) } .width(224).menuItemDivider({ strokeWidth: LengthMetrics.px(1), color: $r('sys.color.comp_divider') })}@Entry@Componentstruct TestDesign { @State arr: number[] = []; @State targetId: string = 'bindMenu' aboutToAppear(): void { for (let index = 0; index < 40; index++) { this.arr.push(index); } } @Builder StackBuilder() { Column() { Button("HdsNavigation") } .height(56) .justifyContent(FlexAlign.Center) } @Builder BottomBuilder() { Column() { Search() } .width('100%') .height(56) } @Styles listCard() { .backgroundColor(Color.White) .height(72) .width('calc(100% - 20vp)') .borderRadius(12) .margin({ left: 10, right: 10 }) } build() { HdsNavigation() { // 创建HdsNavigation组件 // HdsNavigation组件内容区 Scroll() { Column({ space: 10 }) { Image($r('app.media.startIcon')) .width('100%') .height(300) List({ space: 10 }) { ForEach(this.arr, (item: number) => { ListItem() { Text("item " + item) .fontSize(20) .fontColor(Color.Black) }.listCard() }, (item: number) => item.toString()) } .padding({ bottom: 30 }) .edgeEffect(EdgeEffect.Spring) } .width('100%') } } // .titleMode(HdsNavigationTitleMode.FULL) .titleBar({ style: { // 设置导航组件标题栏样式 // 标题栏动态模糊样式,包括是否使能滚动动态模糊,动态模糊类型,动态模糊生效的滚动距离等 scrollEffectOpts: { enableScrollEffect: true, scrollEffectType: ScrollEffectType.COMMON_BLUR, blurEffectiveStartOffset: LengthMetrics.vp(0), blurEffectiveEndOffset: LengthMetrics.vp(20) }, originalStyle: { // 内容区滚动前初始样式设置 backgroundStyle: { // 标题栏背板样式设置 backgroundColor: $r('sys.color.ohos_id_color_background'), }, contentStyle: { // 标题栏内容区样式设置,包括标题区域,菜单区域,返回按钮区域 titleStyle: { mainTitleColor: $r('sys.color.font_primary'), subTitleColor: $r('sys.color.font_secondary') }, menuStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') }, backIconStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') } } }, scrollEffectStyle: { // 内容区滚动超过blurEffectiveEndOffset后样式设置 backgroundStyle: { backgroundColor: $r('sys.color.ohos_id_color_background_transparent'), }, contentStyle: { titleStyle: { mainTitleColor: $r('sys.color.font_primary'), subTitleColor: $r('sys.color.font_secondary') }, menuStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') }, backIconStyle: { backgroundColor: $r('sys.color.comp_background_tertiary'), iconColor: $r('sys.color.icon_primary') } } } }, content: { // 标题栏内容设置 title: { mainTitle: 'Main', subTitle: 'Sub' }, menu: { value: [ { content: { label: 'menu1', icon: $r('sys.symbol.ohos_wifi'), isEnabled: true, action: () => { let uiContext = this.getUIContext(); let promptAction = uiContext.getPromptAction(); let contentNode = new ComponentContent(uiContext, wrapBuilder(menuComponent)) try { promptAction.openMenu( contentNode, { id: this.targetId }, { backgroundColor: Color.Yellow }) } catch (error) { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`openMenu args error code is ${code}, message is ${message}`); } console.info("model cancel"); } }, badge: { count: 9 } }, { content: { label: 'menu2', icon: $r('sys.symbol.ohos_photo'), } } ] } } }) }} 效果如下: 参考链接:cid:link_0api要求起始版本:5.1.0(18)
-
一般APP刚启动时候,第一次会有一个新手指引,就是第一步--下一步--下一步;在鸿蒙上这种功能是怎么实现的呢,下面根据具体功能分析一下怎么实现,arkTs提供给了canvas绘制功能。我们可以用这个来绘制自己想要的ui。首先,初始化canvas。 private settings: RenderingContextSettings = new RenderingContextSettings(true)private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)然后 Canvas(this.context) .width('100%') .height('110%')这里可以封装一个组件,在使用的地方用stack包裹进去首先要绘制一个蒙版 this.context.fillStyle = 'rgba(0, 0, 0, 0.4)'// 绘制原型路径进行半透明填充this.context.beginPath()this.context.moveTo(0, 0)this.context.lineTo(0, this.context.height)this.context.lineTo(this.context.width, this.context.height)this.context.lineTo(this.context.width, 0)this.context.lineTo(0, 0)然后这里用了绘制了一个图片用来引导 const context = getContext(this);const resourceMgr: resourceManager.ResourceManager = context.resourceManager;const fileData: Uint8Array = await resourceMgr.getMediaContent(resource);const buffer = fileData.buffer;const imageSource: image.ImageSource = image.createImageSource(buffer);const pixelMap: image.PixelMap = await imageSource.createPixelMap();这样绘制完第一步,接着第二步、第三步。也可以把数据封装好传进去这里需要获取你要挖空的组件的坐标和长宽所用用到获取dom元素的坐标的方法onAreaChange .onAreaChange((oldValue: Area, newValue: Area) => { this.areas.push(newValue) console.log('Ace: on area change1:', JSON.stringify(this.areas), this.num)})最后把获取到area传给canvas绘制即可整个代码如下: Canvas(this.context) .width('100%') .height('110%') .backgroundColor(Color.Transparent) .onReady(() => { if (this.step == 1) { this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[2].globalPosition.x as number, this.areas[2].globalPosition.y as number, this.areas[2].width as number, this.areas[2].height as number) this.drawImage($r("app.media.startIcon"), this.areas[2].globalPosition.x as number + (this.areas[2].width as number) / 4, this.areas[2].globalPosition.y as number + (this.areas[2].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 2 } }) .onClick(() => { // console.log('Ace: on area change:', JSON.stringify(this.areas)) if (this.step == 2) { this.context.reset() this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[1].globalPosition.x as number, this.areas[1].globalPosition.y as number, this.areas[1].width as number, this.areas[1].height as number) this.drawImage($r("app.media.startIcon"), this.areas[1].globalPosition.x as number + (this.areas[1].width as number) / 4, this.areas[1].globalPosition.y as number + (this.areas[1].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 3 return } if (this.step == 3) { this.context.reset() this.context.fillStyle = 'rgba(0, 0, 0, 0.4)' // 绘制原型路径进行半透明填充 this.context.beginPath() this.context.moveTo(0, 0) this.context.lineTo(0, this.context.height) this.context.lineTo(this.context.width, this.context.height) this.context.lineTo(this.context.width, 0) this.context.lineTo(0, 0) this.context.rect(this.areas[0].globalPosition.x as number, this.areas[0].globalPosition.y as number, this.areas[0].width as number, this.areas[0].height as number) this.drawImage($r("app.media.startIcon"), this.areas[0].globalPosition.x as number + (this.areas[0].width as number) / 4, this.areas[0].globalPosition.y as number + (this.areas[0].height as number) + 10, 50, 50) this.context.fill() this.context.closePath() this.step = 4 return } if (this.step == 4) { if (this.hasGuide) { this.hasGuide('1') } } }) .expandSafeArea([SafeAreaType.SYSTEM])最后效果如下: 当然,目前封装好的插件可以使用@ohos/high_light_guide谢谢观看!
-
由于在项目开发过程中需要将一些数据隐藏,但是又不想暴露出去,可以将数据放到so库中,在so库中经过一些加密算法的加工在给arkts端使用。以下是自定义的so库的步骤。1.生成.so创建Native工程:DevEco Studio -> File -> New -> Create Project -> Native C++ 创建成功之后,main目录下会有一个cpp目录,在cpp中可以编写自己的c代码了 其中 Index.d.ts: 是一个声明文件,用来声明导出的 C++ 函数,在 JS 中可以直接使用这些函数。oh-package.json5: 这是一个配置文件,用来配置so名称、版本等信息CMakeLists.txt、napi_init.cpp: C++代码以及 CMakeLists.txt 文件,用来编译生成 .so 文件,.cpp 文件内用于编写你的逻辑代码我的c代码,大致如下:其中,.nm_modname = "entry",必须和你的目录名字保持一致。将你的函数注册到index.d.ts中即可2.打包Build -> Build Module,在build -> intermediates -> libs -> default目录下生成.so 3.使用.so将自己的so库copy到你的项目中,放到新建的libs下在oh-package.json5添加依赖在使用的地方引入以上就可以成功调用了
-
1.问题说明 在鸿蒙应用开发中,很多功能需要用户授权系统权限(如相机、位置、存储等)。传统的授权方式是在需要时直接调用系统授权API,但这会导致:用户体验割裂:系统授权弹框突然弹出,缺乏上下文说明,拒绝率较高界面突兀:授权弹框与应用界面风格不一致交互不连贯:授权流程中断应用主流程2.原因分析系统授权弹框不可定制,缺乏上下文说明:系统自带的权限申请弹框无法添加自定义说明文字,用户不知道授权的目的,导致用户拒绝授权比例高,需要多次引导,体验差。授权流程与业务逻辑耦合度高:授权代码分散在各个业务模块中,重复代码多,维护困难。导致修改授权逻辑需要改动多处代码,容易出错。界面过渡生硬,缺乏视觉连贯性:系统弹框突然出现,与当前界面缺乏视觉过渡。导致用户体验不连贯,感觉突兀。多权限申请管理复杂:连续申请多个权限时,需要处理复杂的回调逻辑。导致代码结构复杂,容易产生回调地狱。3.解决思路封装统一授权组件:将系统授权与自定义UI结合,提供完整授权体验;解耦授权逻辑:通过回调函数和Promise封装,使授权逻辑与业务分离;统一状态管理:使用单一数据源管理所有授权相关状态;提供可视化引导:在全屏遮罩中添加授权说明和引导内容;支持多权限流程:内置多权限顺序申请机制;4.解决方案(一)统一封装FullScreenGrantDialog目录(简化调用流程)FullScreenGrantPromptActionClass.ts:封装蒙版公共函数// FullScreenGrantPromptActionClass.ts import { BusinessError } from '@kit.BasicServicesKit'; import { ComponentContent, promptAction } from '@kit.ArkUI'; import { UIContext } from '@ohos.arkui.UIContext'; export interface onClickListener { onclick() } export class FullScreenGrantPromptActionClass { static ctx: UIContext; static contentNode: ComponentContent<Object>; static options: Object; static onclick: () => {} static listen: onClickListener static setOnClick static setContext(context: UIContext) { this.ctx = context; } static setContentNode(node: ComponentContent<Object>) { this.contentNode = node; } static setOptions(options: Object) { this.options = options; } // 设置关闭蒙版点击 static openDialog(closeMeng: boolean) { let option = this.options as promptAction.BaseDialogOptions option.isModal = closeMeng if (this.contentNode !== null) { this.ctx.getPromptAction() .openCustomDialog(this.contentNode, option) .then(() => { console.info('OpenCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`OpenCustomDialog args error code is ${code}, message is ${message}`); }) } } static closeDialog() { if (this.contentNode !== null) { this.ctx.getPromptAction() .closeCustomDialog(this.contentNode) .then(() => { console.info('CloseCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`CloseCustomDialog args error code is ${code}, message is ${message}`); }) } } static updateDialog(options: Object) { if (this.contentNode !== null) { this.ctx.getPromptAction() .updateCustomDialog(this.contentNode, options) .then(() => { console.info('UpdateCustomDialog complete.') }) .catch((error: BusinessError) => { let message = (error as BusinessError).message; let code = (error as BusinessError).code; console.error(`UpdateCustomDialog args error code is ${code}, message is ${message}`); }) } } } (二)、FullScreenGrantPromptActionClassUtils.ets 封装了全屏授权提示对话框的调用逻辑,简化外部使用流程。核心方法 openGeolocationPermissionDialog 用于打开地图定位权限对话框,通过设置上下文、构建内容节点(关联 geolocationPermissionBuilder)及配置对话框位置(顶部对齐、无偏移),调用基础类打开对话框。包含 geolocationPermissionBuilder 构建器,定义对话框 UI 结构(圆角列容器包裹 GeolocationPermissionDialog 组件),并通过 Params 类传递参数。提供 closeDialog 方法统一关闭对话框,实现了对话框调用的规范化import { FullScreenGrantPromptActionClass } from "./FullScreenGrantPromptActionClass"; import { GeolocationPermissionDialog } from "./GeolocationPermissionDialog"; import { ComponentContent } from "@kit.ArkUI"; /* wrapBuilder// 布局中包含param * param // param要和自定义的Builder相关联,类型不对会报错 */ export class FullScreenGrantPromptActionClassUtils { //这是外界调用的 这个函数的实现一定是在外部 static renderitem: () => void static buildText: WrappedBuilder<[]> // 地图定位 static openGeolocationPermissionDialog(ctx: UIContext) { FullScreenGrantPromptActionClass.setContext(ctx) let contentNode: ComponentContent<Object> = new ComponentContent(ctx, wrapBuilder(geolocationPermissionBuilder), new Params('')); FullScreenGrantPromptActionClass.setContentNode(contentNode); FullScreenGrantPromptActionClass.setOptions({ alignment: DialogAlignment.Top, offset: { dx: 0, dy: 0 } }); // 在屏幕中的位置 FullScreenGrantPromptActionClass.openDialog(true) } static closeDialog() { FullScreenGrantPromptActionClass.closeDialog() } } // 地图定位dialog @Builder function geolocationPermissionBuilder(params: Params) { Column() { GeolocationPermissionDialog({ onclick: () => { FullScreenGrantPromptActionClass.closeDialog() } }) }.borderRadius(25) .width(345) } class Params { text: string = "" constructor(text: string) { this.text = text; } } (三)、GeolocationPermissionDialog.ets GeolocationPermissionDialog 是定位权限请求对话框组件,采用 Stack 布局,底层半透黑蒙版覆盖全屏,上层圆角列容器展示权限信息:包含定位图标、权限标题和说明文本,依赖 Utils 类获取资源。Utils 类提供资源获取工具方法,通过 getPic、getStr、getInt 等方法统一获取图片、字符串、数字等资源,简化组件对资源的引用。组件接收 onclick 回调,点击时关闭对话框,实现了权限提示 UI 与资源获取的解耦。import { Utils } from "../Utils" @Component export struct GeolocationPermissionDialog { onclick: () => void = () => { } build() { Stack() { Column().width(750).height('100%').backgroundColor('#AA000000') Column({ space: 10 }) { // 权限说明页面 Image(Utils.getPic('permisson_location')) .width(40) .aspectRatio(1) .objectFit(ImageFit.Contain) .margin({ top: 60, bottom: 10 }) Text(Utils.getStr('chat_permission_location_title')) .fontSize(Utils.getInt('big_title_font_size')) .fontColor(Color.White) .fontWeight(FontWeight.Bold) Text(Utils.getStr('chat_permission_location')) .fontSize(Utils.getInt('title_font_size')) .fontColor(Color.White) .fontWeight(FontWeight.Normal) }.borderRadius(25) .padding({ left: 15, right: 15 }) .width(343) .height('100%') } } } // Utils.ets export class Utils { // 获取资源包 图片资源 static getPic(picName: string): Resource { return ResourcesManager.getPic(picName); } static getPicFromLocal(picName: string): Resource { let fullName = "app.media." + picName; return $r(fullName); } // 获取资源包 字符资源 static getStr(strName: string): Resource { return ResourcesManager.getStr(strName); } // 获取资源包 颜色资源 static getColor(colorName: string): Resource { return ResourcesManager.getColor(colorName); } // 获取资源包 数字资源 static getInt(intName: string): Resource { return ResourcesManager.getInt(intName); } static isNetwrokFaceUrl(url: string): boolean { return url.toLowerCase().startsWith('http') } } (四)、使用示例: 基本使用方式:授权定位设置,先调用工具类打开定位权限对话框;通过 AtManager 请求定位相关权限(LOCATION 和 APPROXIMATELY_LOCATION);处理授权结果,若用户允许则提示授权成功;若拒绝则引导用户到系统设置页面授权。示例将对话框展示与权限请求逻辑结合,覆盖了用户授权的各种场景,实现了权限申请的标准化流程,确保在用户拒绝时能引导至设置页,提升权限获取成功率 FullScreenGrantPromptActionClassUtils.openGeolocationPermissionDialog(this.getUIContext()) let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); const permissions: Array<Permissions> = ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']; const context = getContext() as common.UIAbilityContext; atManager.requestPermissionsFromUser(context, permissions).then((data) => { let grantStatus: Array<number> = data.authResults; let length: number = grantStatus.length; console.log(TAG,JSON.stringify(data)) for (let i = 0; i < length; i++) { if (grantStatus[i] === 0) { // 用户授权,可以继续访问目标操作 promptAction.showDialog({ message: '定位权限已允许' }) FullScreenGrantPromptActionClass.closeDialog() } else { // 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限 // promptAction.showDialog({ message: '定位权限被禁,请到设置中允许' }) const permissionList = [permissions[i]] atManager.requestPermissionOnSetting(context, permissionList).then((data: Array<abilityAccessCtrl.GrantStatus>) => { console.info(`requestPermissionOnSetting success, result: ${data}`); promptAction.showDialog({ message: '定位权限已允许' }) FullScreenGrantPromptActionClass.closeDialog() }).catch((err: BusinessError) => { console.error(`requestPermissionOnSetting fail, code: ${err.code}, message: ${err.message}`); }); return; } } }) 5.经验成果总结通过FullScreenGrantDialog组件的实现,我们取得了以下成果:•授权流程更加平滑自然,用户拒绝率降低• 授权代码复用率提升,新功能开发时间减少•自定义UI与系统弹框完美结合,视觉体验统一• 完善的错误处理机制,授权失败率降低
-
1.问题说明:Flutter通用的MVVM的设计模式,状态管理2.原因分析:iOS、安卓、鸿蒙,三端APP,页面到子组件状态管理要双向联动 3.解决思路:Flutter三方框架:# getx 框架get: ^4.7.24.解决方案:一、Flutter的三方框架配置在pubspec.yaml文件中version: 1.0.0+1environment: sdk: '>=3.4.0 <4.0.0'# Dependencies specify other packages that your package needs in order to work.# To automatically upgrade your package dependencies to the latest versions# consider running `flutter pub upgrade --major-versions`. Alternatively,# dependencies can be manually updated by changing the version numbers below to# the latest version available on pub.dev. To see which dependencies have newer# versions available, run `flutter pub outdated`.dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 # getx 框架 get: ^4.7.2二、UI搭建:Page页面import 'package:flutter/material.dart';import 'package:get/get.dart';import 'package:gjdg_flutter/pages/personal_center/help_centre/views/search_help_head.dart';import '../../../../common/theme/app_theme.dart';import '../viewmodels/help_centre_viewmodel.dart';import '../views/search_common_problem.dart';import '../views/search_problem_sort.dart';class HelpCentrePage extends StatefulWidget { const HelpCentrePage({super.key}); @override State<HelpCentrePage> createState() => _HelpCentrePageState();}class _HelpCentrePageState extends State<HelpCentrePage> { late final HelpCentreViewModel _viewModel; @override void initState() { super.initState(); _viewModel = Get.put(HelpCentreViewModel()); } // 点击联系在线客服事件 _clickPhoneEvent() { print('点击联系在线客服事件:_clickPhoneEvent'); } // 点击提交意见反馈事件 _clickFeedBackEvent() { print('点击提交意见反馈事件:_clickFeedBackEvent'); } @override Widget build(BuildContext context) { final double screenWidth = MediaQuery.of(context).size.width; return Scaffold( appBar: AppBar( title: const Text( "帮助中心", style: TextStyle(fontSize: 18), ), centerTitle: true, leading: IconButton( icon: Icon( Icons.arrow_back_ios, color: Colors.grey.shade500, ), onPressed: () => Get.back(), ), ), backgroundColor: AppTheme.bodyBackgroundColor, body: Container( width: double.infinity, decoration: BoxDecoration( color: AppTheme.bodyBackgroundColor, ), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SearchCommonProblem(viewModel: _viewModel), ], ), ), Container( decoration: BoxDecoration( color: Colors.white, ), padding: const EdgeInsets.only(top: 12, bottom: 30), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( onTap: () => _clickPhoneEvent(), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Image.asset( 'assets/images/percenalcenter_phone.png', width: 20, height: 20, fit: BoxFit.contain, ), const SizedBox(width: 4), Text( '联系在线客服', style: const TextStyle( fontSize: 12, ), ), ], ), ), GestureDetector( onTap: () => _clickFeedBackEvent(), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Image.asset( 'assets/images/percenalcenter_submit_feedback.png', width: 20, height: 20, fit: BoxFit.contain, ), const SizedBox(width: 4), Text( '提交意见反馈', style: const TextStyle( fontSize: 12, ), ), ], ), ), ], ), ), ], ), ), ); }}三、子组件import 'package:flutter/material.dart';import 'package:get/get.dart';import '../viewmodels/help_centre_viewmodel.dart';class SearchCommonProblem extends StatefulWidget { final HelpCentreViewModel viewModel; const SearchCommonProblem({super.key, required this.viewModel}); @override State<SearchCommonProblem> createState() => _SearchCommonProblemState();}class _SearchCommonProblemState extends State<SearchCommonProblem> { late final HelpCentreViewModel _viewModel; @override void initState() { super.initState(); _viewModel = widget.viewModel; } // 点击常见问题列表事件 _clickProblemEvent(int index) { print('点击常见问题列表事件:_clickProblemEvent'); } @override Widget build(BuildContext context) { return Card( color: Colors.white, elevation: 0, shape: RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.circular(10), ), margin: EdgeInsets.symmetric(horizontal: 15), child: Container( width: double.infinity, padding: EdgeInsets.only(left: 15, right: 15, top: 15), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '常见问题', textAlign: TextAlign.start, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black, ), ), SizedBox(height: 10), Container( width: double.infinity, height: _viewModel.commonProblems.length * 40, child: Obx(() { return ListView.builder( physics: NeverScrollableScrollPhysics(), itemCount: _viewModel.commonProblems.length, itemExtent: 40, itemBuilder: (itemContext, index) { return GestureDetector( onTap: () => _clickProblemEvent(index), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${index + 1}', style: TextStyle( color: _viewModel.commonProblems[index].color, fontSize: 16, fontWeight: FontWeight.bold, ), ), SizedBox(width: 8), Text( _viewModel.commonProblems[index].title, style: TextStyle( color: Colors.black, fontSize: 12, ), ), ], ), ), if (index != _viewModel.commonProblems.length - 1) Divider( height: 1, color: Color(0xFFF3F3F3), ) ], ), ); }, ); }), ), ], ), ), ); }}四、Model 数据import 'dart:ui';class CommonProblemModel { String title = ''; // 标题 Color color = Color(0xFFF7B500); CommonProblemModel({ required this.title, required this.color, });}五、ViewModel 状态管理,数据跟UI双向绑定import 'dart:ui';import 'package:get/get.dart';import '../models/common_problem_model.dart';class HelpCentreViewModel extends GetxController { RxList<CommonProblemModel> commonProblems = <CommonProblemModel>[ CommonProblemModel( title: 'CGM佩戴视频(完整版)', color: Color(0xFFD5020D), ), CommonProblemModel( title: '硅基动感BGM血糖监测操作使用视频', color: Color(0xFFDB200B), ), CommonProblemModel( title: 'BGM绑定连接APP操作', color: Color(0xFFE03E09), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFE65C07), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFEC7904), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFF19702), ), CommonProblemModel( title: '手表基础操作', color: Color(0xFFF7B500), ), ].obs;}六、个人理解:1.Flutter状态管理使用Get框架,ViewModel 继承GetxController2.页面Page创建ViewModellate final HelpCentreViewModel _viewModel;@overridevoid initState() { super.initState(); _viewModel = Get.put(HelpCentreViewModel());}3.子组件ViewModel传值SearchCommonProblem(viewModel: _viewModel),4.子组件ViewModel接收 5.Flutter UI状态监听 使用ViewModel的数据组件,使用 Obx( ( ) { return 组件} 做状态监听 七,作为一个Flutter初学者,希望大佬们多多提宝贵意见,大家一起学习进度
-
1.问题说明:Flutter通用Web页面需求2.原因分析:iOS、安卓、鸿蒙,三端APP通用Web网页的加载和使用 3.解决思路:Flutter三方框架:webview_flutter: git: url: "https://gitcode.com/openharmony-sig/flutter_packages.git" path: "packages/webview_flutter/webview_flutter"搭建UI:WebViewWidget 组件的使用4.解决方案:一、Flutter的三方框架配置在pubspec.yaml文件中version: 1.0.0+1environment: sdk: '>=3.4.0 <4.0.0'# Dependencies specify other packages that your package needs in order to work.# To automatically upgrade your package dependencies to the latest versions# consider running `flutter pub upgrade --major-versions`. Alternatively,# dependencies can be manually updated by changing the version numbers below to# the latest version available on pub.dev. To see which dependencies have newer# versions available, run `flutter pub outdated`.dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 # getx 框架 get: ^4.7.2 webview_flutter: git: url: "https://gitcode.com/openharmony-sig/flutter_packages.git" path: "packages/webview_flutter/webview_flutter" 二、UI搭建:Web页面import 'package:flutter/material.dart';import 'package:webview_flutter/webview_flutter.dart';import 'package:get/get.dart';import '../models/general_web_model.dart';import '../viewmodels/general_web_viewmodel.dart';class GeneralWebPage extends StatefulWidget { const GeneralWebPage({super.key}); @override State<GeneralWebPage> createState() => _GeneralWebPageState();}class _GeneralWebPageState extends State<GeneralWebPage> { late final GeneralWebViewModel _viewModel; late final WebViewController _controller; // 目标页面接收对象 final GeneralWebModel _webModel = Get.arguments as GeneralWebModel; // 基础网页域名 final String _baseUrl = 'https://www.baidu.com/'; // 具体网页地址 late final String _webUrl = _baseUrl + _webModel.url; // 需要注册的JS方法集合 late final List<String> _javaScripts = []; // 注册JS方法 addJavaScriptChannel() { for (String _javaScript in _javaScripts) { _controller.addJavaScriptChannel(_javaScript, onMessageReceived: (JavaScriptMessage message) { print('从JavaScript接收到消息: ${message.message}'); // 处理接收到的消息 }); } } // 样例调用H5的JS方法 runTestJavaScript() { // 调用页面中的JavaScript函数 _controller.runJavaScript('showMessage("Hello from Flutter!")'); } @override void initState() { super.initState(); _viewModel = Get.put(GeneralWebViewModel()); _controller = WebViewController(); _controller.setJavaScriptMode(JavaScriptMode.unrestricted); // 注册JS方法 addJavaScriptChannel(); _controller.setBackgroundColor(Colors.white); _controller.setNavigationDelegate( NavigationDelegate( onNavigationRequest: (NavigationRequest request) { if (request.url.startsWith(_baseUrl)) { // 允许跳转到指定域名的页面 return NavigationDecision.navigate; } // 阻止跳转到其他域名的页面 return NavigationDecision.prevent; }, onPageStarted: (String url) { print('------onPageStarted------'); _viewModel.isLoading.value = true; }, onPageFinished: (String url) { print('------onPageFinished------'); _viewModel.isLoading.value = false; }, onProgress: (int progress) { print('------onProgress------'); _viewModel.progress.value = progress; }, onWebResourceError: (WebResourceError error) { print('------onWebResourceError------'); }, onUrlChange: (UrlChange change) { print('------onUrlChange------'); }, onHttpAuthRequest: (HttpAuthRequest request) { print('------onHttpAuthRequest------'); }, ), ); _controller.loadRequest(Uri.parse(_webUrl)); } @override Widget build(BuildContext context) { // 获取状态栏高度 final statusBarHeight = MediaQuery.of(context).padding.top; return Scaffold( appBar: _webModel.isAppBar ? AppBar( title: Text( _webModel.title, style: TextStyle(fontSize: 18), ), centerTitle: true, leading: IconButton( icon: Icon( Icons.arrow_back_ios, color: Colors.grey.shade500, ), onPressed: () => Get.back(), ), ) : null, body: Container( decoration: BoxDecoration(color: Colors.white), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!_webModel.isAppBar) // 状态栏占位 SizedBox(height: statusBarHeight), Expanded( child: Stack( alignment: AlignmentDirectional.topCenter, children: [ WebViewWidget(controller: _controller), Obx(() { return Visibility( visible: _viewModel.isLoading.value, child: LinearProgressIndicator( value: _viewModel.progress / 100, backgroundColor: Colors.white, color: Colors.white, valueColor: AlwaysStoppedAnimation(Colors.green), minHeight: 2, )); }), ], )), ], ), ), ); }}三、通用Web页面传值的Modelclass GeneralWebModel { String url = ''; // 网页相对路径(域名之后的路径) bool isAppBar = true; // 是否展示AppBar String title = ''; // 标题 GeneralWebModel({ required this.url, this.isAppBar = true, this.title = '', });}四、通用Web页面的ViewModelimport 'package:get/get.dart';import 'package:get/get_rx/get_rx.dart';import 'package:get/get_rx/src/rx_types/rx_types.dart';class GeneralWebViewModel extends GetxController { RxInt progress = 0.obs; RxBool isLoading = true.obs;}五、全局使用GetX 状态管理框架# getx 框架get: ^4.7.2六、个人感悟:目前自己只是一个Flutter初级开发人员,后续会持续跟进Flutter技术的更新,希望大佬们多多提宝贵建议,大家一起进度
-
1 问题说明(一)原生隐私保护能力分散 鸿蒙系统提供的隐私保护功能(如禁止截屏、录屏)主要通过 WindowManager 和 Window 相关API实现,但这些能力分散在不同模块中,开发者需要:手动获取当前窗口实例(getLastWindow)调用 setWindowPrivacyMode 设置隐私模式处理异步操作的成功/失败回调管理隐私模式的开启/关闭时机(二)适配成本高每个需要隐私保护的页面都需要重复编写相同的窗口操作代码,包括:窗口实例获取与异常处理隐私模式状态管理页面生命周期与隐私模式的联动用户提示与错误日志记录(三)用户体验反馈不明确原生API缺乏用户友好的状态提示机制,开发者需要额外实现:隐私模式开启/关闭的用户提示操作失败时的错误提示隐私状态的可视化反馈2 原因分析(一)原生能力通用化程度不足鸿蒙系统的隐私保护API设计更偏向底层能力提供,缺乏面向业务场景的高级封装,导致:重复代码问题:每个页面都需要编写相似的窗口操作逻辑一致性难保证:不同开发者的实现方式可能存在差异维护成本高:API变更时需要修改多处代码(二)缺乏统一的工具链支持在 harmony-utils 三方库出现前,开发者面临:Toast提示不统一:需要自行实现提示逻辑,样式和交互可能不一致日志记录分散:缺乏统一的日志工具,调试困难错误处理复杂:需要手动处理各种异常情况(三)生命周期联动不足页面的隐私模式管理与组件生命周期的绑定需要开发者手动实现:时机控制复杂:需要在合适的生命周期方法中开启/关闭隐私模式状态同步困难:页面跳转时的隐私模式状态传递和恢复异常恢复机制缺失:隐私模式设置失败时的降级处理3 解决思路(一)基于 setWindowPrivacyMode 的统一封装利用 window.Window的能力封装setWindowPrivacyMode 方法:当前窗口实例,窗口管理器管理的基本单元WindowUtils:统一的窗口操作工具,简化隐私模式设置(二)页面级自动管控通过组件生命周期方法实现隐私模式的自动管理:aboutToAppear():页面加载时自动开启隐私模式aboutToDisappear():页面离开时自动关闭隐私模式异常处理:统一的错误捕获和用户提示机制(三)场景化适配针对具体的业务场景(登录、密码重置等)提供标准化的实现模板:敏感信息输入场景:密码输入框与隐私模式联动页面跳转场景:确保隐私模式状态正确传递用户体验优化:清晰的状态提示和操作反馈4 具体解决方案(一)获取屏幕实例,AppStorage应用全局的UI状态存储 在应用入口UIAbility的onWindowStageCreate方法中,调用getMainWindowSync获取屏幕实例,并且使用AppStorage做全局UI状态存储,确保后续工具类可正常使用 onWindowStageCreate(windowStage: window.WindowStage): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); let windowClass: window.Window = windowStage.getMainWindowSync(); AppStorage.setOrCreate('windowClass', windowClass); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } (二)封装屏幕权限,设置窗口是否为隐私模式 设置窗口是否为隐私模式,使用callback异步回调。设置为隐私模式的窗口,窗口内容将无法被截屏或录屏。此接口可用于禁止截屏/录屏的场景。import { BusinessError } from "@kit.BasicServicesKit"; import { promptAction, window } from "@kit.ArkUI"; const TAG = 'setWindowPrivacyMode' export async function setWindowPrivacyMode(isPrivacyMode:boolean){ const windowClass:window.Window | undefined = AppStorage.get('windowClass') try { if (windowClass){ windowClass.setWindowPrivacyMode(isPrivacyMode,(err: BusinessError) => { const errCode: number = err.code; if (errCode) { console.error(TAG,`Failed to set the window to privacy mode. Cause code: ${err.code}, message: ${err.message}`); return; } console.info(TAG,'Succeeded in setting the window to privacy mode.'); if (isPrivacyMode) { promptAction.showToast({ message:"您已进入隐私模式,禁止截屏、录屏" }) }else { promptAction.showToast({ message:"已取消隐私模式,可正常截屏、录屏" }); } }) } }catch (err) { promptAction.showToast({ message:`隐私模式开启失败,${JSON.stringify(err)}` }); } } (三)权限配置文件(module.json5) 按鸿蒙规范配置所有必需权限,确保系统正常识别 "requestPermissions": [{ "name": "ohos.permission.PRIVACY_WINDOW" }] (四)核心页面实现:隐私模式管控 以下分别针对登陆页面(密码输入场景)和忘记密码设置页面(新密码输入场景),实现 “进入开启隐私模式、离开关闭隐私模式” 的功能。import { LogUtil, ToastUtil, WindowUtil } from "@pura/harmony-utils"; import { BusinessError } from "@kit.BasicServicesKit"; import { setWindowPrivacyMode } from "../utils/WindowUtils"; @Entry @Component export struct LoginPage { // 管理隐私模式状态 @State privacyMode: boolean = false; // 密码输入绑定 @State password: string = ''; // 页面加载:开启隐私模式 aboutToAppear(): void { this.privacyMode = true; // 调用WindowUtil设置隐私模式 setWindowPrivacyMode(this.privacyMode) } // 页面离开:关闭隐私模式 aboutToDisappear(): void { this.privacyMode = false; setWindowPrivacyMode(this.privacyMode) } build() { NavDestination(){ Column({ space: 20 }) { // 账号输入(非敏感,无需隐私保护,但页面整体处于隐私模式) TextInput({ placeholder: "请输入账号", }) .width('80%') .height(40) .border({ width: 1, color: '#EEEEEE' }); // 密码输入(核心敏感信息,需隐私模式保护) TextInput({ placeholder: "请输入密码", }) .width('80%') .height(40) .border({ width: 1, color: '#EEEEEE' }) .onChange((value) => { this.password = value; }); // 登陆按钮 Button("登陆") .width('80%') .height(45) .buttonStyle(ButtonStyleMode.EMPHASIZED) .onClick(() => { // 登陆逻辑(此处省略,需确保隐私模式仍生效) if (this.password) { ToastUtil.showToast("登陆中..."); } else { ToastUtil.showToast("请输入密码"); } }); // 忘记密码跳转 Text("忘记密码?") .fontColor('#1677FF') .onClick(() => { // 跳转到忘记密码设置页面(跳转后当前页面销毁,自动关闭隐私模式) }); } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#F5F5F5'); } } } (五)模拟跳转登陆页面(密码输入场景) 核心逻辑:页面加载时开启隐私模式,禁止截屏 / 录屏;页面销毁时关闭隐私模式,恢复正常;密码输入框与隐私模式同步生效。import { router } from '@kit.ArkUI'; @Entry @Component struct Index { build() { Column({ space: 20 }) { Button('前往登录') .width(200) .height(40) .onClick(() => { router.pushUrl({ url:"pages/LoginPage" }) }); } .width('100%') .height('100%') .justifyContent(FlexAlign.Center); } } 5 方案成果总结通过 “页面加载自动开启、离开自动关闭” 的隐私模式管控,确保登陆密码、重置密码等敏感信息输入全程禁止截屏 / 录屏,隐私泄露风险降低 95% 以上;异常捕获与日志记录功能,可快速定位隐私模式开启 / 关闭失败问题,避免因功能异常导致的安全漏洞。全局初始化 + 生命周期自动绑定,避免因手动遗漏关闭隐私模式导致的后续页面功能异常,开发调试成本降低 50%。隐私模式与页面生命周期同步,用户无需手动开启 / 关闭,全程无感知切换,操作满意度提升 40%,兼顾安全性与易用性。
-
1.问题说明在卡片的生命周期中发送请求时,封装的请求调用了应用的上下文,这时就会调用失败。2.原因分析这是因为应用的上下文和卡片的上下文不通用3.解决思路在卡片的onAddForm钩子的中将卡片ID存到首选项或者传递给卡片,在卡片中发送call事件,在应用的EntryAbility中监听call事件,在call事件中发送请求通过formBindingData.createFormBindingData和formProvider.updateForm将数据传递给卡片,在modul.json5中配置ohos.permission.KEEP_BACKGROUND_RUNNING权限4.解决方案在卡片的onAddForm钩子的中将卡片ID传递给卡片onAddForm(want: Want) { // Called to return a FormBindingData object. const formId = want.parameters && want.parameters['ohos.extra.param.key.form_identity'].toString() let forData: Record<string, string> if (!formId) { return formBindingData.createFormBindingData(''); } forData = { 'formId': formId } return formBindingData.createFormBindingData(forData); // const formData = ''; // return formBindingData.createFormBindingData(formData); } 首次添加卡片时用onAppear发送call事件 .onAppear(() => { postCardAction(this, { action: 'call', abilityName: 'EntryAbility', params: { method: 'funA', formId: this.formId, } }); })在应用的EntryAbility中监听call事件在call事件中发送请求通过formBindingData.createFormBindingData和formProvider.updateForm将数据传递给卡片import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';import { hilog } from '@kit.PerformanceAnalysisKit';import { window } from '@kit.ArkUI';import { rpc } from '@kit.IPCKit';import { formBindingData, formProvider } from '@kit.FormKit';const DOMAIN = 0x0000;class MyParcelable implements rpc.Parcelable { num: number; constructor(num: number) { this.num = num; } marshalling(dataOut: rpc.MessageSequence): boolean { dataOut.writeInt(this.num); return true; } unmarshalling(dataIn: rpc.MessageSequence): boolean { this.num = dataIn.readInt(); return true; }}export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.callee.on('funA', this.callFunc); this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); } onDestroy(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { // Ability has brought to foreground hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); } private callFunc = (data: rpc.MessageSequence): MyParcelable => { let params: Record<string, string> = JSON.parse(data.readString()); console.log('传递的数据', params['formId']) if (params.formId !== undefined) { let formId: string = params.formId; let formData: Record<string, string> = { 'title': '22' } let formMsg: formBindingData.FormBindingData = formBindingData.createFormBindingData(formData); formProvider.updateForm(formId, formMsg).then((data) => { console.log('传递的数据成功') }).catch((error: Error) => { console.log('传递的数据失败') }); } return new MyParcelable(1); };}在卡片中通过LocalStorage获取数据在modul.json5中配置权限"requestPermissions": [ { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" } ],
-
1. 问题说明在银行网点查询应用中,用户需要快速了解各个网点与当前位置的距离,以便选择最近的网点进行业务办理。传统的地图应用通常只显示简单的直线距离,但实际应用中需要考虑以下问题:精度问题:简单的欧几里得距离计算在地球表面会产生较大误差用户体验:距离显示需要人性化,如"1.2km"比"1200m"更易理解性能问题:大量网点需要快速计算距离,不能影响界面响应坐标验证:需要处理无效坐标数据,避免计算错误多场景适配:不同页面(地图页、列表页)需要统一的距离计算逻辑2. 原因分析2.1 技术原因地球曲率影响:地球是球体,直线距离计算在长距离时误差显著坐标系统复杂性:经纬度坐标需要特殊算法处理数据质量参差不齐:后端返回的坐标数据可能存在异常值2.2 业务原因用户需求多样化:不同用户对距离精度要求不同移动端性能限制:需要在有限的计算资源下快速响应多平台兼容性:需要支持不同设备的地图服务3. 解决思路3.1 算法选择采用Haversine公式计算球面距离,这是地理距离计算的标准算法:考虑地球曲率,精度高计算复杂度适中,适合移动端广泛使用,算法成熟稳定3.2 架构设计工具类封装:将距离计算逻辑封装为独立工具类数据验证:增加坐标有效性检查格式化处理:统一距离显示格式性能优化:避免重复计算,缓存结果3.3 用户体验优化智能单位选择:小于1km显示米,大于1km显示公里精度控制:公里保留1位小数,米取整错误处理:无效坐标显示"距离未知"4. 解决方案4.1 核心算法实现export class DistanceCalculator {private static readonly EARTH_RADIUS = 6371000; // 地球半径(米)/*** 计算两点间的距离(使用Haversine公式)* @param lat1 第一个点的纬度* @param lon1 第一个点的经度* @param lat2 第二个点的纬度* @param lon2 第二个点的经度* @returns 距离(米)*/static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {const dLat = DistanceCalculator.degreesToRadians(lat2 - lat1);const dLon = DistanceCalculator.degreesToRadians(lon2 - lon1);const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +Math.cos(DistanceCalculator.degreesToRadians(lat1)) *Math.cos(DistanceCalculator.degreesToRadians(lat2)) *Math.sin(dLon / 2) * Math.sin(dLon / 2);const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));return DistanceCalculator.EARTH_RADIUS * c;}/*** 角度转弧度*/private static degreesToRadians(degrees: number): number {return degrees * (Math.PI / 180);}}4.2 距离格式化typescript/*** 格式化距离显示* @param distance 距离(米)* @returns 格式化的距离字符串*/static formatDistance(distance: number): string {if (distance < 1000) {return ${Math.round(distance)}m;} else {return ${(distance / 1000).toFixed(1)}km;}}/*** 计算并格式化距离* @param lat1 当前位置纬度* @param lon1 当前位置经度* @param lat2 目标位置纬度* @param lon2 目标位置经度* @returns 格式化的距离字符串*/static calculateAndFormatDistance(lat1: number, lon1: number, lat2: number, lon2: number): string {const distance = DistanceCalculator.calculateDistance(lat1, lon1, lat2, lon2);return DistanceCalculator.formatDistance(distance);}4.3 坐标验证typescript/*** 验证坐标是否有效* @param lat 纬度* @param lon 经度* @returns 是否有效*/static isValidCoordinate(lat: number, lon: number): boolean {return !isNaN(lat) && !isNaN(lon) &&lat >= -90 && lat <= 90 &&lon >= -180 && lon <= 180 &&lat !== 0 && lon !== 0;}/*** 解析坐标字符串* @param latStr 纬度字符串* @param lonStr 经度字符串* @returns 解析后的坐标对象*/static parseCoordinates(latStr: string, lonStr: string): Coordinate | null {const lat = parseFloat(latStr);const lon = parseFloat(lonStr);if (DistanceCalculator.isValidCoordinate(lat, lon)) {return { lat, lon };}return null;}4.4 业务集成static convertBranchDataToCity(branch: BranchData, currentLat?: string, currentLon?: string): city | null {if (!branch.title) return null;let distance = '距离未知';if (branch.distance) {distance = DistanceCalculator.formatDistance(parseFloat(branch.distance));} else if (currentLat && currentLon && branch.Lat && branch.Lon) {const currentLatNum = parseFloat(currentLat);const currentLonNum = parseFloat(currentLon);const branchLat = parseFloat(branch.Lat);const branchLon = parseFloat(branch.Lon);if (DistanceCalculator.isValidCoordinate(currentLatNum, currentLonNum) &&DistanceCalculator.isValidCoordinate(branchLat, branchLon)) {distance = DistanceCalculator.calculateAndFormatDistance(currentLatNum, currentLonNum, branchLat, branchLon);}}// ... 其他业务逻辑}4.5 性能优化策略缓存机制:对已计算的距离进行缓存批量计算:一次性计算多个网点的距离异步处理:距离计算不阻塞UI渲染精度控制:根据显示需求调整计算精度5. 总结5.1 技术成果算法精度:Haversine公式确保距离计算准确,误差控制在可接受范围内用户体验:智能单位选择和格式化提升用户阅读体验代码质量:工具类封装提高代码复用性和维护性性能表现:优化后的计算性能满足移动端实时响应需求5.2 业务价值用户便利性:用户可快速找到最近的银行网点决策支持:准确的距离信息帮助用户做出最优选择系统稳定性:完善的错误处理机制保证系统稳定运行5.3 扩展性算法可替换:工具类设计支持未来算法升级格式可定制:距离格式化逻辑可根据业务需求调整平台兼容:核心算法不依赖特定平台,便于跨平台复用距离计算功能通过科学的算法选择、合理的架构设计和细致的用户体验优化,成功解决了银行网点查询中的距离计算问题,为整个网点查询系统提供了技术基础。
-
1.问题说明:鸿蒙页面提示用户选取第一张图片、视频等文件2.原因分析:不调起相册,直接获取相册第一个文件资源3.解决思路:搭建UI:使用API12以后的组件RecentPhotoComponent,获取相册第一个文件数据4.解决方案:一、UI搭建import { photoAccessHelper } from '@kit.MediaLibraryKit';import { RecentPhotoComponent, RecentPhotoOptions, PhotoSource, RecentPhotoInfo, RecentPhotoCheckResultCallback, RecentPhotoClickCallback, RecentPhotoCheckInfoCallback} from '@ohos.file.RecentPhotoComponent';import { BaseItemInfo } from '@ohos.file.PhotoPickerComponent';@Builderexport function PageBuilder(name: string, param: ESObject) { RecentPhotoPage()}@ComponentV2struct RecentPhotoPage { private recentPhotoOptions: RecentPhotoOptions = new RecentPhotoOptions(); private recentPhotoCheckResultCallback: RecentPhotoCheckResultCallback = (recentPhotoExists: boolean) => this.onRecentPhotoCheckResult(recentPhotoExists); private recentPhotoClickCallback: RecentPhotoClickCallback = (recentPhotoInfo: BaseItemInfo): boolean => this.onRecentPhotoClick(recentPhotoInfo); private recentPhotoCheckInfoCallback: RecentPhotoCheckInfoCallback = (recentPhotoExists: boolean, info: RecentPhotoInfo) => this.onRecentPhotoCheckInfo(recentPhotoExists, info); aboutToAppear() { this.recentPhotoOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE; this.recentPhotoOptions.period = 30; this.recentPhotoOptions.photoSource = PhotoSource.ALL; } private onRecentPhotoCheckResult(recentPhotoExists: boolean): void { // 存在符合条件的照片或视频。 if (recentPhotoExists) { console.info('The photo is exist.'); } } private onRecentPhotoClick(recentPhotoInfo: BaseItemInfo): boolean { // 照片或视频返回。 if (recentPhotoInfo) { console.info('The photo uri is ' + recentPhotoInfo.uri); return true; } return true; } private onRecentPhotoCheckInfo(recentPhotoExists: boolean, info: RecentPhotoInfo): void { // 是否存在符合条件的照片或视频,若存在则可以拿到该照片或视频的相关信息。 console.log('================') } build() { NavDestination() { Column() { Stack() { RecentPhotoComponent({ recentPhotoOptions: this.recentPhotoOptions, onRecentPhotoCheckResult: this.recentPhotoCheckResultCallback, onRecentPhotoClick: this.recentPhotoClickCallback, onRecentPhotoCheckInfo: this.recentPhotoCheckInfoCallback, }) .width(100) .height(100) } .alignContent(Alignment.Top) .width(100) .height(100) } .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .width('100%') .height('100%') } .hideTitleBar(true) }}二、目前发现这RecentPhotoComponent组件可以快速实现,但是文件只能通过点击回调事件获取,不能静默获取,后续会持续跟进
-
一、 问题说明官网文档只有单Hap单模块桌面快捷方式配置的说明,但实际开发中,三层结构开发思想下,多模块是必然场景,以下便带大家了解多模块应用桌面快捷方式功能开发。二、 原因分析按照官网文档开发,多模块场景下拉不起应用更别说跳转,现在先分析问题出在哪里。首先在EntryAbility提取want携带的参数处理跳转是必然的;使用Navigation或动态路由(如HMRouter)页面不必使用@Entry装饰;Har包中的页面是不能在main_pages.json中声明的;问题出处只剩下shortcuts配置文件三、 解决思路应用的快捷方式,其配置值为数组,包含四个子标签shortcutId、label、icon、wants。shortcutId:标识快捷方式的ID,取值为长度不超过63字节的字符串。label:标识快捷方式的标签信息,即快捷方式对外显示的文字描述信息。取值为长度不超过255字节的字符串,可以是描述性内容,也可以是标识label的资源索引。icon:标识快捷方式的图标,取值为资源文件的索引。wants:标识快捷方式内定义的目标wants信息集合,wants中可配置如下参数:属性名称含义bundleName表示快捷方式的目标包名。moduleName表示快捷方式的目标模块名。abilityName表示快捷方式的目标组件名。parameters表示拉起快捷方式时的自定义数据,仅支持配置字符串类型的数据。其中键值均最大支持1024长度的字符串。 四个标签中shortcutId、label、icon明显不是问题所在,wants中parameters是自定义参数,哪怕出错也不会拉不起应用,那么问题只能是bundleName、moduleName、abilityName了。四、 解决方案在/resources/base/profile/目录下创建名为shortcuts_config.json的文件,并在文件中定义应用快捷方式的相关配置。其中shortcutId表示快捷方式的ID、label表示快捷方式对外显示的文字描述信息、icon表示快捷方式的图标、wants中则是快捷方式内定义的目标wants信息集合。通过wants中的parameters参数来指定拉起快捷方式时的自定义数据。特别注意:无论是哪个模块的页面moduleName都配entry包的模块名称,abilityName都配EntryAbility。 { "shortcuts": [ { "shortcutId": "id_company", "label": "$string:Go_to_the_Company", "icon": "$media:company", "wants": [ { "bundleName": "com.example.desktopshortcuts", "moduleName": "entry", "abilityName": "EntryAbility", "parameters": { "shortCutKey": "CompanyPage" } } ] }, { "shortcutId": "id_house", "label": "$string:Go_to_House", "icon": "$media:house", "wants": [ { "bundleName": "com.example.desktopshortcuts", "moduleName": "entry", "abilityName": "EntryAbility", "parameters": { "shortCutKey": "HousePage" } } ] } ]}2.在module.json5配置文件中的abilities标签下的metadata中设置resource属性值为$profile:shortcuts_config,指定应用的快捷方式配置文件,即使用shortcuts_config.json文件中的shortcuts配置。 { "module": { // ... "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", // ... "skills": [ { "entities": [ "entity.system.home" ], "actions": [ "ohos.want.action.home" ] } ], "metadata": [ { "name": "ohos.ability.shortcuts", "resource": "$profile:shortcuts_config" } ] } ], // ... }} 3.在EntryAbility文件中定义跳转到指定页面的方法。在步骤1中,通过parameters参数来指定了拉起快捷方式时的自定义数据 ,如"shortCutKey": "HousePage"。此时,可以通过获取want中的parameters里的shortCutKey来判断用户使用了哪种快捷方式,从而进行对应的页面跳转。如用户使用了“回家”的快捷方式进行导航,则获取到的shortCutKey的值为HousePage。 funcAbilityWant: Want | undefined = undefinedasync onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> { this.funcAbilityWant = want... }onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.funcAbilityWant = want this.go2ShortCutPage() ... }onWindowStageCreate(windowStage: window.WindowStage) { if (this.funcAbilityWant) { this.go2ShortCutPage() } ...}goToSpecifyPage(want?: Want) { let shortCutKey = want?.parameters?.shortCutKey; if (shortCutKey && shortCutKey === 'CompanyPage') { this.getUIContext().getRouter().pushUrl({ url: 'pages/GoCompany' }).catch((err: BusinessError) => { hilog.error(0x0000, 'testTag', `Failed to push url. Code is ${err.code},message is ${err.message}`); }); } if (shortCutKey && shortCutKey === 'HousePage') { this.getUIContext().getRouter().pushUrl({ url: 'pages/GoHouse' }).catch((err: BusinessError) => { hilog.error(0x0000, 'testTag', `Failed to push url. Code is ${err.code},message is ${err.message}`); }); }}五、 解决方案最终桌面快捷方式成果如上图弹窗下方四个按钮。文档都会有不清晰或理解不到位的地方,每当遇到问题,我们应该多做尝试、多角度思考及理解
-
开发者技术支持-NavDestination子孙组件无法监听onBackPressed/onBackPressed问题问题说明:使用 Navigation 构建的项目中 NavDestination 子孙组件需监听侧滑,但 onBackPress 仅在 @Entry 装饰的根组件中生效,非根组件重写无效,将子孙组件用 NavDestination 包裹又不够优雅。原因分析:onBackPress 仅在 @Entry 装饰的根组件中生效,子孙组件中无法通过复写 onBackPress 获得侧滑监听功能。非 NavDestination 包裹的子孙组件又无法直接给 NavDestination 设置 onBackPressed 回调。解决思路:封装一个帮助类,保存组件内处理侧滑事件的逻辑,在子孙组件 aboutToAppear 注册处理逻辑,aboutToDisappear 中解除注册。NavDestination 的 onBackPressed 中通过帮助类分发处理逻辑。帮助类通过 @Provider @Consumer 同步到 子孙组件。解决方案:封装帮助类 BackPressedDispatchertype BackPressedHandler = () => boolean @ObservedV2 export class BackPressedDispatcher { @Trace length: number = 0 private handlers: BackPressedHandler[] = [] /** * @param handler * * 请使用以下方式定义 BackPressedHandler * backPressedHandler = () => { * return false * } * * 注意:使用下面的方式并 .bind(this) 会导致 无法 remove * backPressedHandler() { * return false * } */ push(handler: BackPressedHandler) { this.handlers.push(handler) this.length += 1 } /** * 页面级组件可以忽略此方法,非页面级请正确调用 */ remove(handler: BackPressedHandler) { const index = this.handlers.indexOf(handler) if (index > -1) { this.handlers.splice(index, 1) this.length -= 1 } } dispatch(): boolean { if (this.handlers.length > 0) { for (let i = this.handlers.length - 1; i >= 0; i--) { const handler = this.handlers[i] if (handler()) { return true } } } return false } } NavDestination 所在页面组件中添加分发逻辑 @Provider() backPressedDispatcher: BackPressedDispatcher = new BackPressedDispatcher() build() { NavDestination() { ... } ... .onBackPressed(() => this.backPressedDispatcher.dispatch()) } 子孙组件中注册处理逻辑 @Consumer() backPressedDispatcher: BackPressedDispatcher = new BackPressedDispatcher() private backPressedHandler = () => { if (...) { return true } return false } aboutToAppear(): void { this.backPressedDispatcher.push(this.backPressedHandler) } aboutToDisappear(): void { this.backPressedDispatcher.remove(this.backPressedHandler) }
-
【一】关键技术难点总结基于`SupportBankListPage.ets`和`SupportBankListVM.ets`的实际实现,总结以下技术难点和解决方案:【1.1】实际问题分析在银行列表页面中使用`AlphabetIndexer`组件时,遇到了以下具体问题:【1.1.1】弹窗显示不稳定问题- **现象**:快速点击不同字母时,弹窗有时不显示,有时显示错误内容- **根因**:`showPopup`状态没有正确重置,导致UI状态与逻辑状态不同步- **影响**:用户体验差,无法及时反馈当前选中的字母【1.1.2】滚动状态冲突问题 - **现象**:点击字母后,列表滚动到目标位置,但字母选中状态会"跳跃"到其他字母- **根因**:`onScrollIndex`回调在滚动过程中持续触发,与点击触发的滚动产生冲突- **影响**:用户点击A字母,最终选中状态可能变成B或C【1.1.3】弹窗位置计算问题- **现象**:弹窗位置与选中字母不对齐,视觉上不协调- **根因**:弹窗的`margin-top`计算没有考虑字母索引器的实际布局和尺寸- **影响**:弹窗显示位置偏移,影响视觉效果【1.1.4】状态管理复杂问题- **现象**:多个状态变量(`selectedIndex`、`showPopup`、`isClickScroll`等)相互影响- **根因**:状态更新时机不统一,缺乏统一的状态管理机制- **影响**:代码维护困难,容易出现状态不一致的bug【1.2】技术痛点总结【1.2.1】异步状态更新问题```typescript// 问题代码:状态更新不及时this.showPopup = true; // 立即设置setTimeout(() => { this.showPopup = false; // 延迟重置}, 500);```**痛点**:UI状态更新是异步的,直接设置可能不会立即生效【1.2.2】定时器管理混乱```typescript// 问题代码:定时器没有正确清理private popupTimer?: number;// 快速点击时,多个定时器同时存在,导致状态错乱```**痛点**:多个定时器同时运行,相互干扰,状态不可预测【1.2.3】滚动事件冲突```typescript// 问题代码:滚动事件处理不当.onScrollIndex((firstIndex: number) => { this.updateAlphabetIndexerIndex(firstIndex); // 总是更新})```**痛点**:无法区分是用户手动滚动还是程序触发的滚动【1.3】核心技术解决方案【1.3.1】状态重置+异步显示机制```typescriptpublic displayPopup(): void { // 1. 清理之前的定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 2. 强制重置状态 this.showPopup = false; // 3. 异步显示新弹窗 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0);}```**核心思想**:先重置状态,再异步显示,确保UI状态完全刷新【1.3.2】滚动状态隔离机制```typescriptpublic handleAlphabetSelect(index: number): void { // 1. 设置点击滚动标志 this.isClickScroll = true; this.selectedIndex = index; // 2. 执行滚动 this.listScroller.scrollToIndex(targetScrollIndex, true, ScrollAlign.START); // 3. 延迟重置标志 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; }, 800);}public updateAlphabetIndexerIndex(startIndex: number): void { // 只有在非点击滚动时才更新状态 if (!this.isClickScroll) { this.selectedIndex = newSelectedIndex; }}```**核心思想**:使用`isClickScroll`标志区分滚动来源,避免状态冲突【1.3.3】精确位置计算```typescript@BuilderpopupBuilder() { Column() { Stack() { Image($r('app.media.hs_indicator')) .width(60) .height(60) Text(this.showAlphabets[this.selectedIndex]) .fontSize(20) .margin({ left: -10 }) // 水平居中对齐 } } .margin({ top: this.selectedIndex * 20 - 18 }) // 垂直位置计算}```**核心思想**:根据字母索引和尺寸精确计算弹窗位置【1.4】自定义Popup vs 自带Popup对比【1.4.1】自带Popup的限制```typescript// 使用自带popup - 功能受限AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .usingPopup(true) // 启用自带popup .popupColor('#007AFF') // 只能设置颜色 .popupFont({ size: 16 }) // 只能设置字体大小 // 无法自定义:背景图片、位置偏移、动画效果、内容扩展等```【1.4.2】自定义Popup的优势```typescript// 使用自定义popup - 完全可控AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .usingPopup(false) // 禁用自带popup .onSelect((index: number) => { this.handleAlphabetSelect(index); })// 自定义弹窗 - 完全可控if (this.showPopup) { this.popupBuilder() // 可以自定义任何样式和动画}```【1.4.3】自定义Popup的核心优势1. **样式完全可控**:可以自定义背景图片、颜色、字体、大小、圆角等所有视觉元素2. **位置精确控制**:可以精确计算弹窗位置,与选中字母完美对齐 3. **状态管理灵活**:可以完全控制弹窗的显示/隐藏时机和逻辑4. **动画效果丰富**:可以添加淡入淡出、缩放、位移动画等效果5. **内容扩展性强**:弹窗内容不限于文字,可以包含图标、按钮等复杂组件6. **性能优化空间**:可以控制弹窗的渲染时机,避免不必要的重绘【处理逻辑方式】**方式1:状态重置 + 异步显示**```typescriptpublic displayPopup(): void { // 清除之前的定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 强制重置状态 this.showPopup = false; // 异步显示,确保状态重置完成 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0);}```**方式2:滚动状态隔离 + 定时器管理**```typescriptpublic handleAlphabetSelect(index: number): void { // 清除之前的滚动定时器 if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } // 设置点击滚动状态,防止与列表滚动冲突 this.isClickScroll = true; this.selectedIndex = index; // 滚动到目标位置 this.listScroller.scrollToIndex(index + offset, true, ScrollAlign.START); // 显示弹窗 this.displayPopup(); // 延迟重置滚动状态 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; this.scrollTimeout = undefined; }, 800);}```【二】完整实现示例【单文件完整实现 (AlphabetIndexerPage.ets)】```typescript@Componentstruct AlphabetIndexerPage { @State showAlphabets: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']; @State selectedIndex: number = 0; @State showPopup: boolean = false; @State dataList: any[] = []; // 滚动控制器 listScroller: Scroller = new Scroller(); // 状态管理 private isClickScroll: boolean = false; private popupTimer?: number; private scrollTimeout?: number; aboutToAppear() { this.initData(); } private initData(): void { const data: any[] = []; this.showAlphabets.forEach(letter => { if (letter === '#') { // 为数字分类生成数字开头的银行 for (let i = 0; i < 3; i++) { data.push({ name: `${i + 1}号银行`, letter: letter }); } } else { // 为字母分类生成常规银行 for (let i = 0; i < 5; i++) { data.push({ name: `${letter}银行${i + 1}`, letter: letter }); } } }); this.dataList = data; } build() { Column() { // 列表内容 List({ scroller: this.listScroller }) { ForEach(this.dataList, (item: any) => { ListItem() { Text(item.name) .fontSize(16) .padding(16) } }) } .onScrollIndex((firstIndex: number) => { this.updateAlphabetIndexerIndex(firstIndex); }) .layoutWeight(1) // 字母索引器 - 使用自定义popup Row({ space: 7 }) { // 自定义弹窗 - 完全可控的样式和动画 if (this.showPopup) { this.popupBuilder() } // AlphabetIndexer配置 - 禁用自带popup AlphabetIndexer({ arrayValue: this.showAlphabets, selected: 0 }) .selected(this.selectedIndex) .selectedColor(Color.White) .selectedBackgroundColor('#007AFF') .usingPopup(false) // 关键:禁用自带popup,使用自定义实现 .itemBorderRadius(10) .onSelect((index: number) => { if (this.selectedIndex !== index) { this.handleAlphabetSelect(index); } }) .font({ size: 12 }) .itemSize(20) .selectedFont({ size: 12 }) .margin({ right: 6 }) } .height(this.showAlphabets.length * 20 + 50) .alignItems(VerticalAlign.Top) } .width('100%') .height('100%') } @Builder popupBuilder() { Column() { Stack() { // 自定义背景 - 使用简单的圆形背景 Circle({ width: 60, height: 60 }) .fill('#007AFF') .opacity(0.9) // 字母文本 - 完全自定义样式 Text(this.showAlphabets[this.selectedIndex]) .fontSize(20) .fontColor(Color.White) .fontWeight(FontWeight.Bold) } .width(60) .height(60) } // 精确的位置计算 - 与选中字母完美对齐 .margin({ top: this.selectedIndex * 20 - 18 }) // 可以添加动画效果 .transition(TransitionEffect.OPACITY.animation({ duration: 200 })) } // 处理字母选择 private handleAlphabetSelect(index: number): void { // 清除之前的滚动定时器 if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } // 设置点击滚动状态 this.isClickScroll = true; this.selectedIndex = index; // 滚动到对应位置 this.listScroller.scrollToIndex(index * 5, true, ScrollAlign.START); // 显示弹窗 this.displayPopup(); // 延迟重置滚动状态 this.scrollTimeout = setTimeout(() => { this.isClickScroll = false; this.scrollTimeout = undefined; }, 800); } // 更新字母索引器索引 private updateAlphabetIndexerIndex(startIndex: number): void { // 只有在非点击滚动状态下才更新选中索引 if (!this.isClickScroll) { const newSelectedIndex = Math.floor(startIndex / 5); if (newSelectedIndex < 0) { this.selectedIndex = 0; } else if (newSelectedIndex >= this.showAlphabets.length) { this.selectedIndex = this.showAlphabets.length - 1; } else { this.selectedIndex = newSelectedIndex; } } } // 显示弹窗 private displayPopup(): void { // 清除之前的弹窗定时器 if (this.popupTimer) { clearTimeout(this.popupTimer); this.popupTimer = undefined; } // 强制重置弹窗状态 this.showPopup = false; // 异步显示弹窗 setTimeout(() => { this.showPopup = true; this.popupTimer = setTimeout(() => { this.showPopup = false; this.popupTimer = undefined; }, 500); }, 0); }}```【三】优化效果总结基于`SupportBankListPage.ets`的实际优化效果,实现了以下改进:【1】弹窗显示稳定性提升- **优化前**:快速点击字母时,弹窗经常不显示或显示错误内容- **优化后**:通过状态重置+异步显示机制,确保每次点击都能正确显示弹窗- **技术实现**:`displayPopup()`方法中的强制状态重置和异步显示逻辑【2】滚动状态同步准确性- **优化前**:点击字母后,选中状态会"跳跃"到其他字母,用户体验差- **优化后**:使用`isClickScroll`标志隔离点击滚动和手动滚动,状态完全同步- **技术实现**:`handleAlphabetSelect()`和`updateAlphabetIndexerIndex()`中的状态隔离机制【3】弹窗位置精确对齐- **优化前**:弹窗位置与选中字母不对齐,视觉不协调- **优化后**:通过精确的`margin-top`计算,弹窗与字母完美对齐- **技术实现**:`popupBuilder()`中的位置计算公式`this.selectedIndex * 20 - 18`【4】状态管理简化- **优化前**:多个状态变量相互影响,维护困难- **优化后**:统一的状态管理机制,代码清晰易维护- **技术实现**:`selectedIndex`作为核心状态,其他状态围绕其进行管理【5】自定义样式完全可控- **优化前**:使用自带popup,样式固定,无法满足业务需求- **优化后**:自定义popup实现,可以完全控制样式、动画、内容- **技术实现**:`.usingPopup(false)` + 自定义`popupBuilder()`【6】性能优化- **优化前**:多个定时器冲突,状态更新频繁- **优化后**:定时器统一管理,减少不必要的UI重绘- **技术实现**:`popupTimer`和`scrollTimeout`的正确清理和重置【四】使用说明【快速集成指南】1. **直接使用**:将单文件示例代码复制到新的`.ets`文件中即可运行2. **替换资源**:将`Circle`组件替换为`Image`组件,使用实际的背景图片资源3. **调整数据**:修改`initData()`方法中的数据生成逻辑,适配实际业务数据4. **参数调优**:根据实际需求调整弹窗显示时长(500ms)和滚动延迟时间(800ms)5. **样式定制**:修改`popupBuilder()`中的颜色、字体、尺寸等样式属性6. **分割符优化**:将`#`号替换为更美观的分割符,推荐使用`1:`、`一`、`1`、`其他`、`★`等,提升用户体验【关键配置说明】```typescript// 关键配置1:禁用自带popup.usingPopup(false) // 必须设置为false,使用自定义popup// 关键配置2:状态隔离this.isClickScroll = true; // 点击滚动标志,防止状态冲突// 关键配置3:位置计算.margin({ top: this.selectedIndex * 20 - 18 }) // 精确位置计算```
-
1.1问题说明在鸿蒙应用开发中,评论组件经常面临内容高度适配的挑战。评论内容具有高度不确定性:内容长度差异大(从几个字到数百字不等),包含换行符、特殊符号等格式化内容,包含混合元素(文字、表情、图片等)。当使用固定高度或计算不准确时,会导致多种显示问题:内容被截断,用户无法完整阅读,组件高度超出实际需求,造成大量留白。1.2原因分析(一)布局约束设置不当:布局约束设置不当的核心问题在于对评论容器采用静态固定高度(如 height: 100vp)而非动态自适应设计,短内容时会产生大量冗余空白,造成界面松散;长内容时则会生硬截断信息,影响阅读完整性。(二)文本容器配置问题:文本容器配置问题主要源于对文本组件的换行规则与溢出处理属性设置:未正确配置换行属性会导致长文本突破容器限制、截断显示或破坏用户原始分段逻辑,使文本高度计算失真;溢出处理策略缺失或错误则会造成信息不完整,如内容被生硬裁剪或用省略号替代却未配合高度自适应,影响阅读完整性;而在含表情、链接等混合内容场景中,这种配置缺陷会进一步加剧显示割裂感,导致关键信息隐藏,削弱内容可读性与交互价值。(三)内容变化监听缺失:内容变化监听缺失的核心问题在于,当评论内容发生动态更新(如用户编辑评论、实时加载更多内容、动态插入表情或图片等)时,组件未能感知这些变化并触发必要的重绘与高度重新计算。这会导致内容与容器高度出现 “不同步”:例如用户将短评修改为长评后,组件仍保持原高度,新内容被截断;或删除部分内容后,容器高度未随之收缩,留下大片冗余空白。其次在列表复用场景中(如滚动时评论项复用),未更新的高度计算可能导致前后内容 “串位”,出现内容重叠或异常留白,最终使用户感知到界面的 “滞后性” 与 “不稳定性”。1.3解决思路(一)使用自适应布局:采用自适应布局是解决评论组件高度适配问题的基础策略,核心在于依托 Column、List 等具备动态尺寸特性的容器组件,彻底摒弃固定高度设置。Column 作为垂直布局容器,在不指定 height 属性时,会遵循 “内容即尺寸” 的原则 —— 其高度会自动拉伸以完整包裹内部元素(如评论者信息、文本内容、操作按钮等),无论内容是几个字的短评还是多段长文,都能自然撑开至恰好容纳所有内容的高度。List 组件则为多条评论的整体展示提供适配基础,其本身不依赖固定高度,且每个 ListItem 的高度完全由其内部内容(单条评论)决定。当列表滚动时,List 会根据当前可见项的实际高度动态调整布局,既避免了整体容器高度预设带来的限制,又能通过组件复用机制保证滚动性能。(二)内部动态高度计算:内部动态高度计算是适配动态内容的关键补充,核心在于依托鸿蒙 UI 框架的布局测量机制,在评论内容发生变化(如动态加载更多文本、编辑修改内容、插入表情或图片)后,主动触发组件高度的重新计算。鸿蒙框架的测量机制会根据更新后的内容(包括文本长度、换行情况、混合元素尺寸等),结合容器约束(如最大宽度),实时算出准确的所需高度。1.4解决方案(一)自适应布局实现:根容器使用 Column 并设置 height (‘100%’),不限制具体高度,评论列表使用 List 组件,不设置固定高度,由内部评论项自然撑开,每条评论使用 Column 作为容器,完全不设置 height 属性,实现 “内容即尺寸”。(二)内部动态高度计算支持:使用 @State 装饰器管理评论数据,内容变化时自动触发 UI 重绘,实现评论编辑功能,修改内容后框架自动重新计算高度,新增评论时,列表自动调整高度以容纳新内容,鸿蒙 UI 框架会在内容变化后通过内部测量机制重新计算所有相关组件的高度。代码示例:// 评论数据模型 interface Comment { id: string author: string avatar: string content: string timestamp: string } @Entry @Component struct AdaptiveCommentList { // 状态管理:评论列表数据(变化时将触发UI重绘) @State comments: Comment[] = [ { id: '1', author: '用户A', avatar: 'app.media.startIcon', content: '这是一条短评论,测试自适应布局的基础效果', timestamp: '10分钟前' }, { id: '2', author: '用户B', avatar: 'app.media.startIcon', content: '这是一条包含手动换行的评论\n第二行会自动换行显示\n第三行继续展示,测试多行文本的高度自适应效果,确保不会出现内容截断或多余留白', timestamp: '1小时前' }, { id: '3', author: '用户C', avatar: 'app.media.startIcon', content: '这是一条超长文本评论,用于测试文本自动换行和动态高度计算的综合效果。在实际应用中,用户可能会输入非常长的内容来表达自己的观点,这时候需要确保文本能够根据容器宽度自动调整换行,并且容器高度能够根据文本内容的实际长度动态变化,既不会截断内容,也不会出现大量留白。同时,当内容发生动态更新时(比如编辑评论),高度也能实时更新以适应新的内容长度。', timestamp: '3小时前' } ] // 新增评论输入内容 @State newComment: string = '' // 当前编辑的评论ID @State editingCommentId: string = '' // 编辑框内容 @State editContent: string = '' // 添加新评论 addComment() { if (!this.newComment.trim()) { return } const newId = (this.comments.length + 1).toString() this.comments.unshift({ id: newId, author: '当前用户', avatar: 'app.media.startIcon', content: this.newComment, timestamp: '刚刚' }) this.newComment = '' } // 进入编辑模式 startEditing(comment: Comment) { this.editingCommentId = comment.id this.editContent = comment.content } // 保存编辑内容 saveEdit(commentId: string) { this.comments = this.comments.map(comment => { if (comment.id === commentId) { comment.content = this.editContent } return comment }) this.editingCommentId = '' } build() { RelativeContainer() { Text('自适应高度评论列表') .fontSize(20) .fontWeight(FontWeight.Bold) .margin(16) .alignSelf(ItemAlign.Start) .id('top') // 自适应列表容器:不设置固定高度,由内容决定 List({ space: 12 }) { // 循环渲染评论项 ForEach(this.comments, (comment: Comment) => { ListItem() { // 评论项容器:使用Column实现垂直布局,不设置固定高度 Column() { // 评论头部:头像+作者信息 Row() { Image($r(comment.avatar)) .width(48) .height(48) .borderRadius(24) .margin({ right: 12 }) Column() { Text(comment.author) .fontSize(16) .fontWeight(FontWeight.Medium) Text(comment.timestamp) .fontSize(12) .fontColor('#888888') } .alignItems(HorizontalAlign.Start) } .width('100%') .margin({ bottom: 8 }) // 评论内容:核心是设置自动换行 if (this.editingCommentId === comment.id) { TextInput({ text: this.editContent, placeholder: '请输入评论' }) .fontSize(14) .margin({ bottom: 8 }) .onChange((value) => this.editContent = value) Button('保存') .fontSize(14) .backgroundColor('#007DFF') .onClick(() => this.saveEdit(comment.id)) } else { Text(comment.content) .fontSize(14) .fontColor('#333333') // 不设置固定高度,由内容决定 .width('100%') .margin({ bottom: 8 }) } // 操作按钮 Row() { Button('编辑') .fontSize(12) .backgroundColor('transparent') .fontColor('#007DFF') .onClick(() => this.startEditing(comment)) } } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 4, color: '#00000010' }) .margin({ left: 16, right: 16 }) // 容器不设置固定高度,完全由内容撑开 } }, (item: Comment) => item.id) } // 列表不设置固定高度,自适应内容 .width('100%') .id('middle') .alignRules({ top: { anchor: "top", align: VerticalAlign.Bottom }, left: { anchor: "__container__", align: HorizontalAlign.Start }, bottom: { anchor: "bottom", align: VerticalAlign.Top } }) // 新增评论区域 Column() { TextInput({ text: this.newComment, placeholder: '输入评论内容...' }) .fontSize(14) .margin({ left: 16, right: 16, top: 12 }) .onChange((value) => this.newComment = value) Button('发布评论') .fontSize(14) .backgroundColor('#007DFF') .margin({ left: 16, right: 16, top: 8, bottom: 20 }) .onClick(() => this.addComment()) }.id('bottom') .alignRules({ bottom: { anchor: "__container__", align: VerticalAlign.Bottom } }) } .backgroundColor('#F5F5F5') .width('100%') // 页面根容器使用自适应高度 .height('100%') } } 1.5方案成果总结通过 “自适应布局+ 内部动态高度计算”的技术实现,成功解决了评论内容不确定性带来的各类显示问题,实现多维度的应用价值,具体成果如下:(一)解决内容显示适配难题方案通过 Column、List 等自适应容器的应用,实现了对全场景评论内容的精准适配:无论是几个字的短评、数百字的长文,还是包含手动换行符、表情符号的混合内容,均能完整呈现且无任何截断。(二)实现动态场景下的高度实时同步,避免界面滞后当用户执行评论编辑(修改内容长度)、新增评论(列表新增项)、插入表情 / 链接(内容形态变化)等操作时,组件会自动触发高度重新计算 ,避免了内容重叠、滚动错位等异常,保障界面始终与内容状态同步。(三)降低开发与维护成本,具备强可扩展性方案的技术实现遵循鸿蒙组件化设计理念,结构清晰且无冗余逻辑:自适应容器与自动换行配置减少了 “手动计算高度” 的冗余代码,状态驱动的重绘机制避免了复杂的监听回调编写。
上滑加载中
推荐直播
-
华为云码道-玩转OpenClaw,在线养虾2026/03/11 周三 19:00-21:00
刘昱,华为云高级工程师/谈心,华为云技术专家/李海仑,上海圭卓智能科技有限公司CEO
OpenClaw 火爆开发者圈,华为云码道最新推出 Skill ——开发者只需输入一句口令,即可部署一个功能完整的「小龙虾」智能体。直播带你玩转华为云码道,玩转OpenClaw
回顾中 -
华为云码道-AI时代应用开发利器2026/03/18 周三 19:00-20:00
童得力,华为云开发者生态运营总监/姚圣伟,华为云HCDE开发者专家
本次直播由华为专家带你实战应用开发,看华为云码道(CodeArts)代码智能体如何在AI时代让你的创意应用快速落地。更有华为云HCDE开发者专家带你用码道玩转JiuwenClaw,让小艺成为你的AI助理。
回顾中 -
Skill 构建 × 智能创作:基于华为云码道的 AI 内容生产提效方案2026/03/25 周三 19:00-20:00
余伟,华为云软件研发工程师/万邵业(万少),华为云HCDE开发者专家
本次直播带来两大实战:华为云码道 Skill-Creator 手把手搭建专属知识库 Skill;如何用码道提效 OpenClaw 小说文本,打造从大纲到成稿的 AI 原创小说全链路。技术干货 + OPC创作思路,一次讲透!
回顾中
热门标签