#HarmonyOS NEXT 体验官# 手把手教你实现省市区镇-四级地址选择弹窗组件 原创 精华
前言
hello 大家好,我是无言,因为地址级联
选择功能其实还是非常常见的,而且官方有TextPicker文本选择组件也可以实现地址级联选择,但是我发现超过3级之后,文字就太多了,会很难看,不好操作等相关问题。所以有必要自己来实现一个好看的省市区镇-四级地址
级联选择组件。
目的
通过本篇文章小伙伴们能学到什么?我简单的总结了一下大概有以下几点。
- 了解到鸿蒙Next 自定义弹窗 的核心用法。
- 了解到 实现
级联选择
的实现思路和过程,不仅限于鸿蒙,也适用于其他框架和场景。 - 了解到
鸿蒙Next
中如何封装自己的自定义组件。 - 了解到
鸿蒙Next
中组件之间是如何通信的,以及如何实现自己想要的功能。
效果提前看一看
实现过程
一、准备工作
- 安装好最新DevEco Studio 开发工具,创建一个新的空项目。
- 新增目录结构
ets/components/cascade/
,在下面添加文件addressObj.ts
用于存放地址Obj对象
,index.ets
用来初始化弹窗容器,CustomAddress.ets
用来存放具体的级联选择业务代码,Cascade.d.ts
TS 声明文件。
二、实现自定义弹窗
- 将官网自定义弹窗的示例3复制过来,放入到
ets/components/cascade/index.ets
中后稍微修改一下,修改的地方我都添加注释了。 主要是 去掉@Entry
页面的入口组件装饰,修改组件命名并用export
暴露组件供外部使用。
// xxx.ets
@CustomDialog
struct CustomDialogExample {
controller?: CustomDialogController
cancel: () => void = () => {
}
confirm: () => void = () => {
}
build() {
Column() {
Text('这是自定义弹窗')
.fontSize(30)
.height(100)
Button('点我关闭弹窗')
.onClick(() => {
if (this.controller != undefined) {
this.controller.close()
}
})
.margin(20)
}
}
}
// @Entry 去掉入口页面标志
@Component
export struct CustomDialogCascade { //修改命名 注意前面加了 export 需要暴露组件
dialogController: CustomDialogController | null = new CustomDialogController({
builder: CustomDialogExample({
cancel: ()=> { this.onCancel() },
confirm: ()=> { this.onAccept() }
}),
cancel: this.existApp,
autoCancel: true,
onWillDismiss:(dismissDialogAction: DismissDialogAction)=> {
console.info("reason=" + JSON.stringify(dismissDialogAction.reason))
console.log("dialog onWillDismiss")
if (dismissDialogAction.reason == DismissReason.PRESS_BACK) {
dismissDialogAction.dismiss()
}
if (dismissDialogAction.reason == DismissReason.TOUCH_OUTSIDE) {
dismissDialogAction.dismiss()
}
},
alignment: DialogAlignment.Center,
offset: { dx: 0, dy: -20 },
customStyle: false,
cornerRadius: 20,
width: 300,
height: 200,
borderWidth: 1,
borderStyle: BorderStyle.Dashed,//使用borderStyle属性,需要和borderWidth属性一起使用
borderColor: Color.Blue,//使用borderColor属性,需要和borderWidth属性一起使用
backgroundColor: Color.White,
shadow: ({ radius: 20, color: Color.Grey, offsetX: 50, offsetY: 0}),
})
// 在自定义组件即将析构销毁时将dialogController置空
aboutToDisappear() {
this.dialogController = null // 将dialogController置空
}
onCancel() {
console.info('Callback when the first button is clicked')
}
onAccept() {
console.info('Callback when the second button is clicked')
}
existApp() {
console.info('Click the callback in the blank area')
}
build() {
Column() {
Button('click me')
.onClick(() => {
if (this.dialogController != null) {
this.dialogController.open()
}
}).backgroundColor(0x317aff)
}.width('100%').margin({ top: 5 })
}
}
- 修改
ets/pages/index.ets
去掉无关的代码,在页面中引入我们的组件。
import {CustomDialogCascade} from "../components/cascade/index"
@Entry
@Component
struct Index {
build() {
RelativeContainer() {
CustomDialogCascade()
}
.height('100%')
.width('100%')
}
}
预览一下看看效果。
三、实现父子组件的通信
在讲后续功能前,这里有必要讲一下鸿蒙开发组件状态。
-
@State用于装饰当前组件的状态变量而且必须初始化,@State装饰的变量在发生变化时,会驱动当前组件的视图刷新。
-
@Prop用于装饰子组件的状态变量而且不允许本地初始化,@Prop装饰的变量会同步父组件的状态,但只能单向同步。
-
@Link用于装饰子组件的状态变量而且不允许本地初始化,@Prop变量同样会同步父组件状态,但是能够双向同步。
-
@Provide和@Consume用于跨层级传递状态信息,其中@Provide用于装饰祖先组件的状态变量,@Consume用于装饰后代组件的状态变量。@Provide装饰变量必须本地初始化,而@Consume装饰的变量不允许本地初始化,
而且他们能够双向同步。 -
@Props与@Link声明接收的属性,必须是@State的属性,而不能是@State属性对象中嵌套的属性解决办法将嵌套对象的类型用class定义, 并使用@Observed来装饰,子组件中定义的嵌套对象的属性, 使用@ObjectLink来装饰。
-
@Watch用来监视状态数据的变化,包括:@State、@Prop、@Link、@Provide、@Consume
一旦状态数据变化,监视的回调就会调用,这里我有必要加一个示例。
@State @Watch('onCountChange') count: number = 0
/**
* 一旦count变化,此回调函数就会自动调用
* @param count 被监视的状态属性名
*/
onCountChange (count) {
// 可以在此做特定处理
}
四、完善逻辑
好了回到我们的主题,前面我们的示例中,只是子组件自己调用弹窗了,我们要实现以下几个功能。
- 父组件调用子组件方法唤醒子组件弹窗。
- 父组件传参控制选择地址的层级数量。
- 子组件选好数据之后回调方法传给父组件。
修改 /ets/components/cascade/index.ets
,实现父组件传参给子组件,子组件回调方法传值给父组件。然后还修改了弹窗的样式以及位置,详细请看下面代码。
// xxx.ets
@CustomDialog
struct CustomDialogExample {
controller?: CustomDialogController
@Prop level: number;
cancel: () => void = () => {
}
confirm: (data:string) => void = () => {
}
build() {
Column() {
Text('这是自定义弹窗')
.fontSize(30)
.height(100)
Text('层级'+this.level)
Button('点我关闭弹窗')
.onClick(() => {
if (this.controller != undefined) {
this.controller.close()
}
this.confirm('aaa') //回传信息
})
.margin(20)
}
}
}
// @Entry 去掉入口页面标志
@Component
export struct CustomDialogCascade { //修改命名 注意前面加了 export 需要暴露组件
@Link CustomDialogController: CustomDialogController | null ;
@Prop level: number;
cancel?: () => void
confirm?: (data:string) => void = () => {
}
aboutToAppear(): void {
this.CustomDialogController= new CustomDialogController({
builder: CustomDialogExample({
cancel: this.cancel,
confirm: this.confirm,
level:this.level,
}),
autoCancel: true,
onWillDismiss:(dismissDialogAction: DismissDialogAction)=> {
if (dismissDialogAction.reason == DismissReason.PRESS_BACK) {
dismissDialogAction.dismiss()
}
if (dismissDialogAction.reason == DismissReason.TOUCH_OUTSIDE) {
dismissDialogAction.dismiss()
}
},
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: 0},
customStyle: false,
cornerRadius: 0,
width: '100%',
backgroundColor: Color.White,
})
}
aboutToDisappear() {
this.CustomDialogController = null // 将dialogController置空
}
build() { //因为用的 自定义弹窗功能,所以这下面可以为空
}
}
修改/ets/pages/index.ets 文件实现功能主要是父元素调用子组件方法,以及子组件的回调方法调用
import {CustomDialogCascade} from "../components/cascade/index"
@Entry
@Component
struct Index {
@State CustomDialogController :CustomDialogController|null = null;
build() {
Column() {
Button('打开弹窗')
.onClick(()=>{
this.CustomDialogController?.open()
})
CustomDialogCascade(
{
level:3,//层级 最大4 最小1
CustomDialogController:this.CustomDialogController, //弹窗实体类 主要控制弹窗显示隐藏等
confirm:(data)=>{
console.log('data',(data))
}
},
)
}
.height('100%')
.width('100%')
}
}
运行效果如下,点击 点我关闭弹窗
按钮可以发现 子组件的回调信息 aaa
在父组件中成功打印。
五、完善地址级联逻辑
- 因为对象取值性能最好,速度也快,所以我这里采用的是对象信息结构,在
addressObj.ts
文件中存放我们的所有城市信息,因为内容过多,我就只举例展示部分,我将完整的城市信息,放在了本文末尾,有需要的小伙伴可以自己下载下来尝试一下。
export const regionDict = {
"86": {
"110000": "北京市",
"120000": "天津市",
"130000": "河北省",
...
},
"110000": {
"110100": "北京市"
},
"110100": {
"110101": "东城区",
"110102": "西城区",
"110105": "朝阳区",
"110106": "丰台区",
"110107": "石景山区",
"110108": "海淀区",
"110109": "门头沟区",
"110111": "房山区",
"110112": "通州区",
"110113": "顺义区",
"110114": "昌平区",
"110115": "大兴区",
"110116": "怀柔区",
"110117": "平谷区",
"110118": "密云区",
"110119": "延庆区"
},
"110101": {
"110101001": "东华门街道",
"110101002": "景山街道"
},
...
};
- 声明文件
Cascade.d.ts
添加类型
export interface RegionType{
code?:string;
pcode?:string;
name?:string
}
export type levelNumber = 1 | 2 | 3 | 4;
- 修改
CustomAddress.ets
完成我们主要的核心业务代码,内容比较多,该添加注释的地方我都添加了,具体功能代码请看下面内容。
import { regionDict } from "./addressObj"
import {RegionType,levelNumber} from './Cascade'
@CustomDialog
export struct CustomAddress {
controller?: CustomDialogController
@State region:RegionType[]=[]; //存放选中的结果
@State data: RegionType[] = [];// 提供选中的列表
@State step:number = 0;//存放步数
@State active:number = 0;//当前高亮步骤
level:levelNumber=4; //层级 默认 为 4级 可以选镇街道一级
cancel: () => void = () => {
console.log('关闭')
}
confirm: (region:RegionType[]) => void = () => {
}
// 页面加载执行
aboutToAppear(): void {
this.loadRegionData('86')
}
/**
* 根据父元素code生成列表数据
* @param pcode
*/
loadRegionData(pcode = '86') {
this.data.length=0
Object.keys(regionDict).forEach((code)=>{
if(code==pcode){
Object.keys(regionDict[code]).forEach((key)=>{
this.data.push({
name:regionDict[code][key],
code:key,
pcode:pcode
})
})
}
})
if(this.data.length == 0) {
this.controller?.close() //关闭弹窗
}
}
// 上面步骤选中
onStepClickSelect(index:number,item:RegionType){
this.active=index;
this.loadRegionData(item.pcode)
}
// 数据选中
onRowClickSelect(item:RegionType){
if(this.active==3 || this.active>=(this.level-1)){ //如果是到了最后一步选择街道 则直接结束
this.region.push(item)
this.confirm(this.region)
this.controller?.close()//关闭弹窗
return
}
if(this.active==this.step){
this.step++;
this.active++;
}else{
this.region= this.region.slice(0,this.active) //数组截取
this.active++; //从选中的地方重新开始
this.step=this.active //步骤也是一样重新开始
}
this.region.push(item)
this.loadRegionData(item.code)
}
// 获取名称
getLableName(){
let name =`请选择`
switch (this.step){
case 0:
name=`请选择省份/地区`
break;
case 1:
name=`请选择城市`
break;
case 2:
name=`请选择区县`
break;
case 3:
name=`请选择街道`
break;
}
return name
}
build() {
Column() {
// 存储已选择信息
Column(){
ForEach(this.region, (item: RegionType,index:number) => {
Flex({alignItems:ItemAlign.Center}){
Row(){
Text()
.backgroundColor(this.active>=index?'#396ec1':'#ff737070')
.width(6)
.height(6)
.borderRadius(10)
// 顶部线条
if (index>0){
Text()
.width(1)
.height(13)
.position({left:2,top:0})
.backgroundColor(this.active>index?'#396ec1':'#ff737070')
}
// 底部线条
if(this.step>=index){
Text()
.width(1)
.height(13)
.position({left:2,top:'50%'})
.backgroundColor(this.active>index?'#396ec1':'#ff737070')
}
}.height(25)
.width(20)
.align(Alignment.Center)
Row(){
Text(item.name).fontSize(14).fontColor('#333333')
}
.flexGrow(1)
.height(25)
.border({
width: { bottom:1 },
color: 0xf5f5f5,
style: {
left:null,
right: null,
top: null,
bottom: BorderStyle.Solid
}
})
.onClick(()=>{
this.onStepClickSelect(index,item)
})
}.width('100%')
.padding({left:10})
})
// 提示信息
Flex({alignItems:ItemAlign.Center}){
Row(){
Text()
.backgroundColor(this.active==this.step?'#396ec1':'#ff737070')
.width(6)
.height(6)
.borderRadius(10)
// 顶部线条
if(this.step){
Text()
.width(1)
.height(13)
.position({left:2,top:0})
.backgroundColor(this.active==this.step?'#396ec1':'#ff737070')
}
}.height(25)
.width(20)
.align(Alignment.Center)
Row(){
Text(this.getLableName()).fontSize(14).fontColor(this.active==this.step?'#396ec1':'#333')
}
.flexGrow(1)
.height(25)
}.width('100%')
.padding({left:10})
}.padding({top:10,bottom:10})
// 分割线
Column(){
}.height(10)
.backgroundColor(0xf5f5f5)
.width('100%')
// 下方列表
Column(){
List({ space: 5, initialIndex: 0 }) {
ForEach(this.data, (item: RegionType) => {
ListItem() {
Text('' + item.name)
.width('100%')
.fontSize(14)
.fontColor('#333333')
.textAlign(TextAlign.Start)
}
.padding({left:10})
.height(25)
.onClick(()=>{
this.onRowClickSelect(item)
})
})
}
.listDirection(Axis.Vertical) // 排列方向
.scrollBar(BarState.Off)
.friction(0.6)
.contentStartOffset(10) //列表滚动到起始位置时,列表内容与列表显示区域边界保留指定距离
.contentEndOffset(10) //列表内容与列表显示区域边界保留指定距离
.divider({ strokeWidth:1, color: 0xf5f5f5, startMargin: 5, endMargin: 5 }) // 每行之间的分界线
.edgeEffect(EdgeEffect.Spring) // 边缘效果设置为Spring
.width('100%')
.height('100%')
}.height(200)
}
}
}
- 修改
/ets/components/cascade/index.ets
文件
import {CustomAddress }from "./CustomAddress"
import {RegionType,levelNumber} from './Cascade'
export {RegionType }from './Cascade' //重新暴露声明文件类型
// @Entry 去掉入口页面标志
@Component
export struct CustomDialogCascade { //修改命名 注意前面加了 export 需要暴露组件
@Link CustomDialogController: CustomDialogController | null ;
@Prop level: levelNumber;
cancel?: () => void
confirm?: (data:RegionType[]) => void = () => {
}
aboutToAppear(): void {
this.CustomDialogController= new CustomDialogController({
builder: CustomAddress({
cancel: this.cancel,
confirm: this.confirm,
level:this.level,
}),
autoCancel: true,
onWillDismiss:(dismissDialogAction: DismissDialogAction)=> {
if (dismissDialogAction.reason == DismissReason.PRESS_BACK) {
dismissDialogAction.dismiss()
}
if (dismissDialogAction.reason == DismissReason.TOUCH_OUTSIDE) {
dismissDialogAction.dismiss()
}
},
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: 0},
customStyle: false,
cornerRadius: 0,
width: '100%',
backgroundColor: Color.White,
})
}
aboutToDisappear() {
this.CustomDialogController = null // 将dialogController置空
}
build() {
}
}
重新运行一下,当街道选好之后,即可发现弹窗自动关闭,而且选好地址信息成功拿到。
总结
本文详细介绍了关于在华为鸿蒙系统 去实现一个自定义弹窗的详细教程,主要是提供一些思路,了解组件之间通信的技巧,以及如何实现一个地址级联选中的详细过程。
希望这篇文章能帮到你,最后我把完整代码放到了gitee上有需要的小伙伴可以自己拉下来去试一试。
已star,感谢分享
嘿嘿,共建鸿蒙社区