文章目录

电子名片这种页面很容易写偏。很多示例会把重点放在渐变背景、圆角、头像和图标上,最后做出来像一张漂亮截图,但看不出业务结构。这个案例真正值得看的地方不在视觉,而在它把“我拿出去交换的名片”和“我收到后要管理的名片”放进了同一个页面里。

这两个场景看起来都叫名片,实际任务完全不同。我的名片更像身份资产,要完整、醒目、可分享;收到的名片更像关系记录,要方便浏览、能快速回忆当时交换的背景。代码如果没有先想清楚这一点,很容易写成两个互相复制的卡片区域,后面一改样式就两边都要补。

A hand-drawn doodle illustration on pure white pap

这里的处理方式比较稳:先定义统一的 BusinessCard 模型,再用 CardView(card, compact) 控制展示密度。compact 不是一个随手加的样式开关,它表达的是同一份数据在不同任务场景里的信息层级。

compactfalse 时,卡片承担“我的身份页”角色,电话、邮箱、网站、NFC 标记都可以完整展示;compacttrue 时,卡片收缩成列表摘要,只保留姓名、职位、公司这类快速识别信息。这个判断很像真实项目里的组件设计:复用不是把 UI 硬抽成一个函数,而是先找出稳定的数据协议,再把变化点变成明确参数。

页面状态也拆得比较干净。myCard 是当前用户自己的名片,属于页面的核心资产;receivedCards 是收到的名片列表,代表关系沉淀;activeTab 决定当前用户正在处理哪类任务;showNFC 只负责 NFC 弹层开关;selectedCard 虽然当前还没真正用起来,但明显是给详情或编辑链路预留的位置。

这几个状态的层级不一样。myCardreceivedCards 是内容状态,页面大部分区域都围着它们转;activeTab 是视图状态,只决定当前显示哪一面;showNFC 是瞬时交互状态,用完就收。把这些东西分清楚,页面维护起来会轻很多,因为你能判断每次修改到底会影响内容、结构,还是只影响一次弹窗交互。

A hand-drawn doodle illustration on pure white pap

@Builder CardView(card: BusinessCard, compact: boolean) 是这篇最值得反复看的地方。它不只是为了少写几行 ArkUI 代码,而是在定义“电子名片应该怎么展示”。头像、姓名、职位、公司是基础身份信息,两种模式都要保留;联系方式和网站只在完整态展示;高度、字号、间距会随着 compact 自动收缩;背景渐变和阴影又跟 themeColor 绑定,视觉也没有被写死。

这意味着 CardView() 更像一个展示协议。只要这个协议稳定,外层不管把它放在“我的名片”、收到列表,还是未来的详情页里,都不用重新解释一张名片长什么样。真实项目里很多组件复用失败,就是因为只抽了布局,没有抽出协议。

主题色切换也是一个很好的状态驱动示例。用户点某个颜色后,代码只改 this.myCard.themeColor = color,名片渐变、阴影和选中边框就跟着更新。这里没有额外维护一套“当前背景色”“当前阴影色”“当前选中样式”,视觉结果直接从业务状态投影出来。

这个习惯很重要。做 ArkUI 页面时,如果每个视觉变化都单独开状态,页面很快会变得乱。更好的方式是让状态表达业务含义,再让 UI 根据状态计算出视觉结果。这里的 themeColor 就是这种思路的缩小版,以后扩成深色模式、卡片皮肤、品牌主题,也能沿着这个方向走。

“我的名片”和“收到的名片”表面上只是两个 tab,但更准确地说,它们是两个任务上下文。前者关心展示自己、分享出去、快速交换;后者关心认识了谁、什么时候交换的、当时有什么合作备注。所以收到列表里没有继续塞满联系方式,而是把 exchangeDatenote 放到卡片下面,让它从展示对象变成关系记录。

这类取舍比组件写法更关键。页面不是信息越全越好,而是要看用户在当前场景里最需要什么。我的名片需要完整,因为它面向外部展示;收到的名片需要轻,因为它面向内部回看。代码里的 compact 参数,背后其实就是这层产品判断。

NFC 弹层的状态也很克制。主动作是 NFC碰一碰,点击后只把 showNFC 设为 true;遮罩和取消按钮再把它设回 false。这个布尔值虽然简单,但边界很清楚:主页面继续管理名片,弹层只管理一次交换动作,不把临时流程塞进主数据结构。

如果后面要把 NFC 换成二维码、蓝牙或者系统分享面板,这种组织方式也还能留住。因为它没有把交换过程和名片数据绑死,只是在页面上挂了一个局部交互层。

selectedCard: BusinessCard | null 是当前代码里一个挺真实的“未完成口子”。收到的名片列表里已经有“查看详情”的文案,但还没有把点击项写入 selectedCard。如果继续往正式项目做,我会从这里接下去:点击某张收到的名片,把它设为 selectedCard,再拉起详情面板,展示完整联系方式、交换记录和后续备注。

这样页面就不只是电子名片 demo 了,而会更接近一个轻量 CRM 入口。它已经有统一模型、有列表、有主题、有交换动作,只差把“收到之后怎么管理关系”这条线继续补完整。

所以这篇真正该学的不是某个 ArkUI 组件怎么写,而是这套组合:用 BusinessCard 收住数据,用 CardView() 固定展示协议,用 activeTab 分开任务场景,用 showNFC 承接局部动作,再让 themeColor 证明视觉可以由状态自然驱动。页面不复杂,但结构是能往真实业务里长的。

完整代码

下面保留案例完整代码,方便你直接对照学习、复制和重构。

A hand-drawn doodle illustration on pure white pap

// 电子名片交换页: 电子名片交换 - NFC模拟、名片设计、分享记录

interface BusinessCard {
  id: number
  name: string
  title: string
  company: string
  phone: string
  email: string
  website: string
  avatar: string
  themeColor: string
  exchangeDate: string
  note: string
  isMine: boolean
}

@Entry
@Component
struct DigitalBusinessCardExchange {
  @State myCard: BusinessCard = {
    id: 0,
    name: '李明',
    title: '高级产品经理',
    company: '星云科技有限公司',
    phone: '138-8888-8888',
    email: 'liming@xingyun.com',
    website: 'www.xingyun.com',
    avatar: '李',
    themeColor: '#007AFF',
    exchangeDate: '',
    note: '',
    isMine: true,
  }
  @State receivedCards: BusinessCard[] = [
    { id: 1, name: '王芳', title: '技术总监', company: '腾讯科技', phone: '135-1234-5678', email: 'wangfang@tencent.com', website: 'tencent.com', avatar: '王', themeColor: '#5856D6', exchangeDate: '2026-06-05', note: '产品合作意向', isMine: false },
    { id: 2, name: '陈志强', title: '市场总监', company: '阿里巴巴', phone: '139-8765-4321', email: 'chenzhiqiang@alibaba.com', website: 'alibaba.com', avatar: '陈', themeColor: '#FF9500', exchangeDate: '2026-06-04', note: '推广合作', isMine: false },
    { id: 3, name: '刘雪', title: '设计师', company: '小米科技', phone: '132-5555-6666', email: 'liuxue@xiaomi.com', website: 'mi.com', avatar: '刘', themeColor: '#FF3B30', exchangeDate: '2026-06-02', note: 'UI设计合作', isMine: false },
    { id: 4, name: '赵博', title: 'CEO', company: '未来科技', phone: '136-7777-8888', email: 'zhaobo@future.com', website: 'future.com', avatar: '赵', themeColor: '#34C759', exchangeDate: '2026-05-30', note: '融资对接', isMine: false },
  ]
  @State activeTab: number = 0
  @State selectedCard: BusinessCard | null = null
  @State showNFC: boolean = false

  @Builder
  CardView(card: BusinessCard, compact: boolean) {
    Column() {
      Row() {
        Text(card.avatar)
          .fontSize(compact ? 20 : 28).fontColor('#FFFFFF')
          .width(compact ? 44 : 56).height(compact ? 44 : 56)
          .backgroundColor('#FFFFFF30')
          .borderRadius(compact ? 22 : 28)
          .textAlign(TextAlign.Center)
          .lineHeight(compact ? 44 : 56)

        Column() {
          Text(card.name)
            .fontSize(compact ? 16 : 20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text(card.title)
            .fontSize(compact ? 11 : 13).fontColor('#FFFFFF90').margin({ top: 2 })
          Text(card.company)
            .fontSize(compact ? 11 : 12).fontColor('#FFFFFF80').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 12 })
        .layoutWeight(1)

        if (!compact) {
          Text('NFC').fontSize(12).fontColor('#FFFFFF80')
            .border({ width: 1, color: '#FFFFFF60' })
            .borderRadius(6).padding({ left: 6, right: 6, top: 3, bottom: 3 })
        }
      }
      .width('100%')

      if (!compact) {
        Divider().color('#FFFFFF30').margin({ top: 14, bottom: 14 })
        Row({ space: 20 }) {
          Column() {
            Text('📞').fontSize(14)
            Text(card.phone).fontSize(11).fontColor('#FFFFFFCC').margin({ top: 3 })
          }
          Column() {
            Text('📧').fontSize(14)
            Text(card.email).fontSize(10).fontColor('#FFFFFFCC').margin({ top: 3 })
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          Column() {
            Text('🌐').fontSize(14)
            Text(card.website).fontSize(10).fontColor('#FFFFFFCC').margin({ top: 3 })
          }
        }
        .width('100%')
      }
    }
    .width('100%')
    .padding(compact ? 14 : 20)
    .borderRadius(16)
    .linearGradient({
      angle: 135,
      colors: [[card.themeColor, 0], [`${card.themeColor}CC`, 1]]
    })
    .shadow({ radius: 10, color: `${card.themeColor}40`, offsetX: 0, offsetY: 4 })
  }

  build() {
    Column() {
      // 顶部
      Text('我的名片夹').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        .padding({ left: 20, right: 20, top: 20, bottom: 12 })
        .alignSelf(ItemAlign.Start)

      // Tab
      Row() {
        ForEach(['我的名片', '收到的名片'], (tab: string, idx: number) => {
          Text(tab)
            .fontSize(14)
            .fontColor(this.activeTab === idx ? '#007AFF' : '#888888')
            .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .border({ width: { bottom: this.activeTab === idx ? 2 : 0 }, color: { bottom: '#007AFF' } })
            .onClick(() => { this.activeTab = idx })
        })
      }
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 16 })

      if (this.activeTab === 0) {
        // 我的名片
        Scroll() {
          Column({ space: 16 }) {
            this.CardView(this.myCard, false)

            // 操作按钮
            Row({ space: 12 }) {
              Button('📤 分享名片')
                .layoutWeight(1).height(46).fontSize(14)
                .fontColor('#007AFF').backgroundColor('#EEF6FF').borderRadius(12)
              Button('📱 NFC碰一碰')
                .layoutWeight(1).height(46).fontSize(14)
                .fontColor('#FFFFFF').backgroundColor('#007AFF').borderRadius(12)
                .onClick(() => { this.showNFC = true })
            }

            // 名片颜色主题
            Column({ space: 10 }) {
              Text('选择主题').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').alignSelf(ItemAlign.Start)
              Row({ space: 12 }) {
                ForEach(['#007AFF', '#5856D6', '#FF3B30', '#34C759', '#FF9500', '#FF2D92'], (color: string) => {
                  Column()
                    .width(32).height(32)
                    .backgroundColor(color)
                    .borderRadius(16)
                    .border({ width: this.myCard.themeColor === color ? 3 : 0, color: '#333333' })
                    .onClick(() => {
                      this.myCard.themeColor = color
                    })
                })
              }
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)

            // 统计
            Row() {
              Column() {
                Text(String(this.receivedCards.length)).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#007AFF')
                Text('交换次数').fontSize(11).fontColor('#888888')
              }
              .layoutWeight(1)
              Column() {
                Text('32').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#34C759')
                Text('被查看').fontSize(11).fontColor('#888888')
              }
              .layoutWeight(1)
              Column() {
                Text('8').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FF9500')
                Text('本周新增').fontSize(11).fontColor('#888888')
              }
              .layoutWeight(1)
            }
            .padding(16)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
          }
          .padding({ left: 20, right: 20, bottom: 20 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      } else {
        // 收到的名片
        List({ space: 10 }) {
          ForEach(this.receivedCards, (card: BusinessCard) => {
            ListItem() {
              Column() {
                this.CardView(card, true)
                Row() {
                  Text(`📅 ${card.exchangeDate}`).fontSize(11).fontColor('#AAAAAA')
                  Blank()
                  if (card.note) {
                    Text(`💬 ${card.note}`).fontSize(11).fontColor('#888888').maxLines(1)
                  }
                  Text('查看详情 >').fontSize(11).fontColor('#007AFF').margin({ left: 10 })
                }
                .width('100%')
                .padding({ top: 8, left: 4, right: 4 })
              }
            }
          })
        }
        .layoutWeight(1)
        .padding({ left: 16, right: 16 })
        .scrollBar(BarState.Off)
      }

      // NFC弹窗
      if (this.showNFC) {
        Stack() {
          Column().width('100%').height('100%').backgroundColor('#00000060')
            .onClick(() => { this.showNFC = false })
          Column({ space: 16 }) {
            Text('📱 NFC 碰一碰').fontSize(18).fontWeight(FontWeight.Bold)
            Text('将两台设备背面靠近\n即可交换名片')
              .fontSize(14).fontColor('#666666').textAlign(TextAlign.Center)
            Text('📡')
              .fontSize(60)
              .animation({ duration: 800, iterations: -1, curve: Curve.EaseInOut })
            Button('取消').height(44).width(120)
              .fontColor('#007AFF').backgroundColor('#F5F5F5').borderRadius(22)
              .onClick(() => { this.showNFC = false })
          }
          .width('80%').padding(24).backgroundColor('#FFFFFF').borderRadius(20)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%').height('100%').position({ x: 0, y: 0 })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F2F2F7')
  }
}

最后总结

电子名片这页最值得学的,不是把卡片做得多像真名片,而是它把“我的展示页”和“我的联系人记录”拆成了两个任务场景,再用一个可切换密度的 CardView() 把它们收进同一套组件协议里。这样的结构后续无论扩成详情页、编辑页还是轻量 CRM,都还有很好的延展空间。

Logo

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

更多推荐