个人中心的用户卡片点击后跳转到编辑资料页面。这个页面的任务是让用户修改昵称、头像、性别、生日、手机号等个人信息。看起来是个简单的表单页,但实现起来有几个绕不开的问题:昵称怎么编辑?弹键盘会不会遮挡内容?头像怎么选?性别切换用什么交互?

ProfileEdit用一个@State editing变量统一管理所有弹窗状态,通过条件渲染控制显示哪个弹层。这种"一个状态变量控制多个弹窗"的模式比每个弹窗单独管理状态更简洁,但也更容易出bug。
完整效果
在这里插入图片描述
在这里插入图片描述

整体布局:Stack + 条件渲染弹窗

Stack({ alignContent: Alignment.TopStart }) {
  Column() {
    // Header
    // Scroll() { 多个卡片区域 }
  }

  // 内联编辑弹窗(昵称/生日/手机号)
  if (this.editing === 'nickname' || this.editing === 'birthday' || this.editing === 'phone') {
    Column() { ... }  // 半透明背景 + 居中白色卡片
  }

  // 头像选择弹窗
  if (this.editing === 'avatar') {
    Column() { ... }  // 半透明背景 + 居中白色卡片
  }
}

在这里插入图片描述

Stack把主内容和弹窗叠在一起。弹窗通过if (this.editing === 'xxx')条件渲染——editing为空时不渲染任何弹窗,editing为特定值时渲染对应的弹层。

弹窗用position({ x: 0, y: 0 })固定在Stack的左上角,配合width('100%').height('100%')铺满整个屏幕。半透明黑色背景rgba(0,0,0,0.4)覆盖在主内容上,视觉上形成"蒙层"效果。

@State 状态变量的设计

@State nickname: string = '用户'
@State avatar: string = 'U'
@State gender: string = '男'
@State birthday: string = '2000-01-01'
@State phone: string = '138****8888'
@State editing: string = ''
@State avatarColors: string[] = [...]
@State avatarChars: string[] = [...]

九个@State变量,分为三类:

数据变量: nickname、avatar、gender、birthday、phone——存储用户的个人信息。每个变量有默认值,页面加载时显示默认值。

控制变量: editing——控制当前显示哪个弹窗。空字符串表示不显示弹窗,'nickname’表示显示昵称编辑弹窗,以此类推。

配置变量: avatarColors、avatarChars——头像选择器的颜色和字符配置。这两个数组不会被修改,但因为ArkUI的@State不支持只读数组(const数组不能被@State装饰),所以用@State声明。

editing 的状态机

editing变量是一个简单的状态机:

'' → 'nickname' → ''(取消/确定)
'' → 'birthday' → ''(取消/确定)
'' → 'phone' → ''(取消/确定)
'' → 'avatar' → ''(选择头像后)

每次只能处于一个状态(包括空状态)。切换状态时直接赋值:this.editing = 'nickname'。关闭弹窗时赋值为空:this.editing = ''

这个设计的好处是:不需要为每个弹窗单独维护一个boolean变量(isNicknameVisible、isBirthdayVisible等),一个editing变量就够了。坏处是:如果以后需要同时显示多个弹窗(比如头像选择和昵称编辑同时弹出),editing变量就撑不住了——需要改成数组或Set。

卡片区域的结构

头像区域

Column() {
  Row() {
    Text('头像').fontSize(15).fontColor(T1)
    Blank()
    Stack() {
      Column() {}.width(60).height(60).borderRadius(30)
        .linearGradient({ angle: 135, colors: [[A, 0], ['#FF9F43', 1]] })
      Text(this.avatar).fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
    }
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD']).margin({ left: 10 })
  }.width('100%').padding(16).onClick(() => { this.editing = 'avatar' })
}

在这里插入图片描述

头像区域的布局:左边"头像"文字,右边Stack叠加渐变圆形+字母。点击整个Row触发this.editing = 'avatar',弹出头像选择器。

渐变头像和ProfilePage的实现一样——60×60圆形+135°红橙渐变+白色字母。但这里的字母是动态的(this.avatar),用户选择新头像后会更新。

信息区域

Column({ space: 0 }) {
  this.EditRow('昵称', this.nickname, () => { this.editing = 'nickname' })
  this.Div()
  Row() {
    Text('性别').fontSize(15).fontColor(T1)
    Blank()
    Text(this.gender).fontSize(14).fontColor(T2)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD']).margin({ left: 8 })
  }.width('100%').padding(16)
  .onClick(() => { this.gender = this.gender === '男' ? '女' : '男' })
  this.Div()
  this.EditRow('生日', this.birthday, () => { this.editing = 'birthday' })
  this.Div()
  Row() {
    Text('手机号').fontSize(15).fontColor(T1)
    Blank()
    Text(this.phone).fontSize(14).fontColor(T2)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD']).margin({ left: 8 })
  }.width('100%').padding(16).onClick(() => { this.editing = 'phone' })
}

在这里插入图片描述

信息区域包含四个字段,但交互方式不同:

  • 昵称、生日、手机号:点击后弹出内联编辑弹窗,用户在弹窗里输入新值
  • 性别:点击后直接在原地切换(男↔女),不弹窗

性别的切换逻辑是this.gender = this.gender === '男' ? '女' : '男'——三元表达式直接翻转。这种"点击切换"比弹窗选择更高效——性别只有两个选项,不需要打开弹窗再选。

地址区域

Column({ space: 0 }) {
  Row() {
    Text('收货地址').fontSize(15).fontColor(T1).layoutWeight(1)
    Blank()
    Text('管理 ›').fontSize(13).fontColor(T3)
  }.width('100%').padding(16)
  .onClick(() => { router.pushUrl({ url: 'pages/MessageDetail', params: {
    'title': '收货地址', 'sub': '管理您的收货地址,添加新地址或编辑已有地址',
    'time': '', 'icon': '📍', 'color': '#FF9F43'
  } as Record<string,Object> }) })
}

在这里插入图片描述

收货地址单独一个卡片,点击跳转到MessageDetail。这里没有用EditRow,因为地址不是"编辑一个值",而是"管理一个列表"。跳转到MessageDetail作为占位,未来可以替换成真正的地址管理页面。

安全区域

Column({ space: 0 }) {
  this.MenuItem('账号安全', '安全中心', () => { router.pushUrl({ url: 'pages/MessageDetail', params: {
    'title': '账号安全', 'sub': '修改密码·绑定手机·实名认证·账号注销',
    'time': '', 'icon': '🔒', 'color': '#FF4757'
  } as Record<string,Object> }) })
  this.Div()
  this.MenuItem('隐私设置', '管理', () => { router.pushUrl({ url: 'pages/MessageDetail', params: {
    'title': '隐私设置', 'sub': '个人信息收集清单·第三方信息共享清单·隐私政策',
    'time': '', 'icon': '🔐', 'color': '#8A5DFA'
  } as Record<string,Object> }) })
}

安全区域用MenuItem Builder,和ProfilePage的Menu Builder类似——左边标题,右边副标题+箭头。副标题用灰色小字,视觉层级比主标题低。

退出登录

Row() {
  Text('退出登录').fontSize(15).fontWeight(FontWeight.Bold).fontColor(A)
}.width('100%').height(50).backgroundColor(CW).borderRadius(14)
.justifyContent(FlexAlign.Center).margin({ left: 16, right: 16, bottom: 30 })
.onClick(() => { router.back() })

退出登录按钮:红色加粗文字,白色背景,居中对齐。目前点击只是router.back()返回上一页,没有真正清除登录状态。实际项目中需要清除Token、清空用户数据、跳转到登录页。

内联编辑弹窗

弹窗结构

if (this.editing === 'nickname' || this.editing === 'birthday' || this.editing === 'phone') {
  Column() {
    Column() {
      Text('编辑' + (this.editing === 'nickname' ? '昵称' : ...))
        .fontSize(18).fontWeight(FontWeight.Bold).fontColor(T1).margin({ bottom: 16 })
      TextInput({ placeholder: '请输入', text: this.editing === 'nickname' ? this.nickname : ... })
        .type(this.editing === 'phone' ? InputType.Number : InputType.Normal)
        .maxLength(this.editing === 'phone' ? 11 : 50)
        .fontSize(15).backgroundColor('#F5F5F7').borderRadius(10).padding(14).width('100%')
        .onChange((v: string) => {
          if (this.editing === 'nickname') { this.nickname = v }
          else if (this.editing === 'birthday') { this.birthday = v }
          else { this.phone = v }
        })
      Row({ space: 12 }) {
        Row() { Text('取消').fontSize(14).fontColor(T2) }
          .layoutWeight(1).height(44).borderRadius(14).backgroundColor('#F5F5F7')
          .justifyContent(FlexAlign.Center).onClick(() => { this.editing = '' })
        Row() { Text('确定').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Color.White) }
          .layoutWeight(1).height(44).borderRadius(14).backgroundColor(A)
          .justifyContent(FlexAlign.Center).onClick(() => { this.editing = '' })
      }.width('100%').margin({ top: 16 })
    }.width('85%').padding(20).backgroundColor(CW).borderRadius(18)
  }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.4)')
  .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  .position({ x: 0, y: 0 })
  .onClick(() => { this.editing = '' })
}

弹窗由三层组成:

  1. 外层Column:铺满屏幕,半透明黑色背景,居中对齐
  2. 内层Column:85%宽度,白色背景,圆角18px,包含标题+输入框+按钮
  3. 蒙层点击:整个外层Column绑定onClick,点击蒙层关闭弹窗

TextInput 的动态配置

TextInput根据editing的值动态调整配置:

.type(this.editing === 'phone' ? InputType.Number : InputType.Normal)
.maxLength(this.editing === 'phone' ? 11 : 50)
  • 手机号InputType.Number只允许输入数字,maxLength限制11位
  • 昵称/生日InputType.Normal允许输入任意字符,maxLength限制50位

生日字段用Normal而不是Date类型,因为鸿蒙的DatePicker组件比较复杂,用TextInput让用户手动输入"2000-01-01"格式更简单。代价是用户可能输入非法日期格式,但原型阶段可以接受。

onChange 的实时同步

.onChange((v: string) => {
  if (this.editing === 'nickname') { this.nickname = v }
  else if (this.editing === 'birthday') { this.birthday = v }
  else { this.phone = v }
})

TextInput的onChange事件实时同步输入内容到对应的@State变量。用户每输入一个字符,@State就更新一次,弹窗里的TextInput显示的内容也会同步更新。

这里有个细节:取消按钮和确定按钮的逻辑一样——都是this.editing = ''。区别在于:取消时用户已经通过onChange修改了@State变量,但因为弹窗关闭后主页面会重新渲染,用户会看到"修改后的值"。如果要实现"取消不保存",需要在打开弹窗时备份原始值,取消时恢复备份。这里简化处理了——取消等于保存。

蒙层点击关闭

.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.4)')
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.position({ x: 0, y: 0 })
.onClick(() => { this.editing = '' })

外层Column铺满屏幕,点击蒙层区域(白色卡片以外的部分)也会关闭弹窗。这是因为onClick绑在外层Column上,点击蒙层和点击卡片都会触发。但如果点击卡片内的TextInput或按钮,事件会被内层元素捕获,不会冒泡到外层——因为ArkUI的事件冒泡机制和Web不同,子元素的onClick会阻止事件继续传播。

头像选择弹窗

弹窗结构

if (this.editing === 'avatar') {
  Column() {
    Column() {
      Text('选择头像').fontSize(18).fontWeight(FontWeight.Bold).fontColor(T1).margin({ bottom: 16 })
      Grid() {
        ForEach(this.avatarChars, (ch: string, idx: number) => {
          GridItem() {
            Stack() {
              Column() {}.width(60).height(60).borderRadius(30)
                .linearGradient({ angle: 135, colors: [[this.avatarColors[idx % this.avatarColors.length], 0], [this.avatarColors[(idx+1) % this.avatarColors.length], 1]] })
              Text(ch).fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White)
            }.onClick(() => { this.avatar = ch; this.editing = '' })
          }
        })
      }.columnsTemplate('1fr 1fr 1fr 1fr').rowsGap(12).columnsGap(12).width('100%')
      Row() {
        Text('取消').fontSize(14).fontColor(T2)
      }.width('100%').height(40).borderRadius(12).backgroundColor('#F5F5F7')
      .justifyContent(FlexAlign.Center).margin({ top: 14 })
      .onClick(() => { this.editing = '' })
    }.width('85%').padding(20).backgroundColor(CW).borderRadius(18)
  }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.4)')
  .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  .position({ x: 0, y: 0 })
}

头像选择弹窗的结构和内联编辑弹窗类似:半透明背景+居中白色卡片。但内容不同——标题下方是一个4列的Grid,展示所有可选头像。

头像的渐变色生成

.linearGradient({
  angle: 135,
  colors: [[this.avatarColors[idx % this.avatarColors.length], 0],
           [this.avatarColors[(idx+1) % this.avatarColors.length], 1]]
})

每个头像的渐变色由两个颜色组成:avatarColors[idx]avatarColors[(idx+1) % length]。用取模运算确保索引不越界——最后一个头像的颜色是avatarColors[7]avatarColors[0](循环回第一个颜色)。

8种颜色循环生成11个头像的渐变色,每个头像的颜色组合都不同。这种"循环配色"比"固定配色"更灵活——不需要为每个头像单独定义颜色,只需要一个颜色数组。

点击选择

.onClick(() => { this.avatar = ch; this.editing = '' })

点击某个头像后,做两件事:把this.avatar设为选中的字符,然后关闭弹窗(this.editing = '')。主页面的头像区域会立即更新——因为this.avatar是@State变量,修改后会触发重新渲染。

@Builder 的复用

EditRow

@Builder EditRow(label: string, value: string, action: () => void) {
  Row() {
    Text(label).fontSize(15).fontColor(T1)
    Blank()
    Text(value).fontSize(14).fontColor(T2)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD']).margin({ left: 8 })
  }.width('100%').padding(16).onClick(action)
}

通用的"标签+值+箭头"行,用于昵称和生日。手机号没有用EditRow,因为手机号的点击逻辑和昵称/生日不同——手机号需要限制输入类型为数字,而EditRow的action参数无法传递额外的配置信息。

MenuItem

@Builder MenuItem(label: string, sub: string, action: () => void) {
  Row() {
    Text(label).fontSize(15).fontColor(T1).layoutWeight(1)
    Text(sub + ' ›').fontSize(13).fontColor(T3)
  }.width('100%').padding(16).onClick(action)
}

设置风格的行:左边标题,右边灰色副标题+箭头。用于账号安全和隐私设置。和EditRow的区别是:EditRow显示当前值(比如昵称"用户"),MenuItem显示副标题(比如"安全中心")。

Div

@Builder Div() {
  Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 16 })
}

分割线,和ProfilePage的Div一样。左边距16px,和卡片的margin({ left: 16 })对齐。注意这里的margin是16px,而ProfilePage的Div是52px——因为ProfilePage的菜单行有padding({ left: 18 }),分割线需要跳过图标区域。ProfileEdit的卡片没有左侧图标,所以分割线从16px开始就够了。

踩坑记录

取消按钮没有真正"取消"。 弹窗的取消按钮和确定按钮逻辑一样——都是this.editing = ''。但onChange已经在用户输入时实时修改了@State变量,所以取消后主页面显示的是"修改后的值"。如果要实现真正的取消,需要在打开弹窗时保存原始值:

@State backupNickname: string = ''

// 打开弹窗时备份
this.backupNickname = this.nickname
this.editing = 'nickname'

// 取消时恢复
this.nickname = this.backupNickname
this.editing = ''

弹窗的事件冒泡。 蒙层绑定onClick关闭弹窗,但弹窗内的TextInput和按钮也需要onClick。如果点击TextInput时事件冒泡到蒙层,弹窗会立即关闭。ArkUI中,子元素的onClick会阻止事件冒泡——但这个行为没有在官方文档中明确说明。如果遇到"点击输入框弹窗就关了"的问题,需要在内层Column上加onClick((e) => { e.stopPropagation() })阻止冒泡。

头像选择器的数组越界。 avatarColors[idx % avatarColors.length]用取模运算防止越界。但avatarChars有11个元素,avatarColors只有8个——第9、10、11个头像的颜色会循环到前三个。如果以后增加avatarChars的数量,需要确保avatarColors足够多,或者接受循环配色的设计。

性别切换没有动画。 点击性别行直接翻转值,没有过渡动画。如果要做更优雅的体验,可以用animateTo包一下:animateTo({ duration: 200 }, () => { this.gender = ... })。但性别切换的频率很低(用户可能永远不会改),动画的收益不大。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐