鸿蒙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暴露组件供外部使用。
typescript 复制代码
// 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 去掉无关的代码,在页面中引入我们的组件。
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
    一旦状态数据变化,监视的回调就会调用,这里我有必要加一个示例。

less 复制代码
@State @Watch('onCountChange') count: number = 0

/**
 * 一旦count变化,此回调函数就会自动调用
 * @param count  被监视的状态属性名
 */
onCountChange (count) {
  // 可以在此做特定处理
}
四、完善逻辑

好了回到我们的主题,前面我们的示例中,只是子组件自己调用弹窗了,我们要实现以下几个功能。

  • 父组件调用子组件方法唤醒子组件弹窗。
  • 父组件传参控制选择地址的层级数量。
  • 子组件选好数据之后回调方法传给父组件。

修改 /ets/components/cascade/index.ets,实现父组件传参给子组件,子组件回调方法传值给父组件。然后还修改了弹窗的样式以及位置,详细请看下面代码。

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 文件实现功能主要是父元素调用子组件方法,以及子组件的回调方法调用

typescript 复制代码
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 文件中存放我们的所有城市信息,因为内容过多,我就只举例展示部分,我将完整的城市信息,放在了本文末尾,有需要的小伙伴可以自己下载下来尝试一下。
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 添加类型
typescript 复制代码
export  interface RegionType{
  code?:string;
  pcode?:string;
  name?:string
}

export  type levelNumber = 1 | 2 | 3 | 4;
  • 修改 CustomAddress.ets 完成我们主要的核心业务代码,内容比较多,该添加注释的地方我都添加了,具体功能代码请看下面内容。
scss 复制代码
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 文件
typescript 复制代码
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上有需要的小伙伴可以自己拉下来去试一试。

相关推荐
ekskef_sef1 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6411 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻2 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云2 小时前
npm淘宝镜像
前端·npm·node.js
dz88i82 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr2 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook
程序员_三木2 小时前
Three.js入门-Raycaster鼠标拾取详解与应用
开发语言·javascript·计算机外设·webgl·three.js
顾平安3 小时前
Promise/A+ 规范 - 中文版本
前端
聚名网3 小时前
域名和服务器是什么?域名和服务器是什么关系?
服务器·前端
桃园码工3 小时前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染