前言

如果你想练 HarmonyOS7 页面开发,我反而建议先从这种中等复杂度的页面下手,信息量够,但不至于把人劝退。

这个案例的主线是 社区邻里应用,后面的 公告、互助、二手市场 则把页面做得更像真实业务页。读代码时,建议把交互、状态和列表这三条线一起看。

它解决了什么事

NeighborhoodCommunityPage 对应的是 社区邻里应用 - 公告、互助、二手市场 这个业务场景。别把它只当成一个 UI 练习页,它真正有价值的地方在于:同一个页面里同时出现了状态切换、列表渲染、条件展示和用户反馈。

观察点 在这个案例里的表现
页面主题 社区邻里应用
主要文案 【重要】电梯维修停用通知, 业委会换届选举公告, 停车位租赁信息, 小区绿化改造施工通知, 求助:有没有五月份的驾照考试题库, 最近在准备科目二,想要找最新的模拟题练习
常用组件 Column, Divider, ForEach, Row, Scroll, Text
适合练习 状态驱动 UI、局部刷新、事件回调、页面结构拆分

Ink notes style comparison chart illustrating the

使用方法

把完整代码放进 ArkTS 页面文件后,就可以直接在预览器里运行。实际使用时,建议先手动点一遍页面里的主要交互,看状态有没有跟着变化;然后再去对照代码,理解每个 @State 字段到底控制了哪一块区域。

如果你想把这个案例改成自己的版本,我建议先做三件事:换掉模拟数据、调整筛选规则、再把重复结构抽成 Builder 或子组件。顺序别反,先改结构往往最容易把自己绕进去。

交互入口怎么拆

ArkUI 页面写顺手之后,你会发现很多问题其实都不是样式问题,而是状态没放对位置。这个案例里能直接看到哪些数据是输入、哪些是选择、哪些是列表源,读起来会比较顺。

状态字段 类型 说明
activeTab number 当前选中项,决定内容切换、高亮或过滤结果
announcements AnnouncementItem[] 记录当前展示状态或页面选择
helpItems HelpRequest[] 列表数据源,通常会交给 ForEach 或卡片区域渲染
secondHandItems SecondHandItem[] 列表数据源,通常会交给 ForEach 或卡片区域渲染

我自己看这类示例时,会先把 @State 和事件函数圈出来,再去看布局。这样不会被大段 ColumnRow 搞乱节奏。

关键代码说明

片段 1:toggleWanted(id: number) {

这段代码我建议单独看。它通常负责派生数据、交互动作或者局部渲染逻辑,把这部分抽出去之后,页面主结构会干净很多,后续要改规则也更省事。

Ink-style architectural diagram of the ArkUI state

  toggleWanted(id: number) {
    this.secondHandItems = this.secondHandItems.map((item) => { if (item.id === id) { item.isWanted = !item.isWanted } return item })
  }

必读小结

适合拿来练手的能力:页面拆分、状态联动、条件渲染、交互反馈。

推荐阅读顺序:先看前言 -> 再跑页面 -> 再看状态表 -> 最后啃完整代码
如果只想快速上手,优先改模拟数据和交互函数,收益最高
我会重点检查的几个地方
  • 点击或输入之后,界面有没有立即更新
  • 列表渲染是否依赖了稳定的数据结构
  • 筛选、统计、派生数据是不是单独放进函数里
  • 是否还有重复布局可以继续抽出去
  • 文案、颜色、间距是不是同一套风格

完整代码

// NeighborhoodCommunityPage - 社区邻里应用 - 公告、互助、二手市场

interface AnnouncementItem {
  id: number
  title: string
  content: string
  type: string
  time: string
  important: boolean
}

interface HelpRequest {
  id: number
  title: string
  desc: string
  author: string
  building: string
  time: string
  status: string
  category: string
  replies: number
}

interface SecondHandItem {
  id: number
  title: string
  price: number
  originalPrice: number
  condition: string
  author: string
  building: string
  time: string
  color: string
  isWanted: boolean
}

@Entry
@Component
struct NeighborhoodCommunityPage {
  @State activeTab: number = 0
  @State announcements: AnnouncementItem[] = [
    { id: 1, title: '【重要】电梯维修停用通知', content: '1号楼A栋电梯将于6月8日8:00-18:00进行年度维修,期间请使用1号楼B栋电梯,不便之处敬请谅解。', type: '维修', time: '1小时前', important: true },
    { id: 2, title: '业委会换届选举公告', content: '根据小区章程,业委会任期届满,现启动新一届换届选举工作,欢迎业主踊跃参与。', type: '通知', time: '昨天', important: false },
    { id: 3, title: '停车位租赁信息', content: '地下车库B区有5个月租车位出租,月租500元,有意者请联系物业前台。', type: '租赁', time: '2天前', important: false },
    { id: 4, title: '小区绿化改造施工通知', content: '小区中心花园将于本月15日开始绿化改造,为期10天,施工期间请注意避让。', type: '施工', time: '3天前', important: false },
  ]
  @State helpItems: HelpRequest[] = [
    { id: 1, title: '求助:有没有五月份的驾照考试题库', desc: '最近在准备科目二,想要找最新的模拟题练习', author: '李明', building: '3栋601', time: '30分钟前', status: 'open', category: '求助', replies: 5 },
    { id: 2, title: '求换:有没有人有充气泵可以借用?', desc: '自行车轮胎跑气了,需要充气泵借用一下', author: '王芳', building: '2栋301', time: '2小时前', status: 'resolved', category: '借用', replies: 3 },
    { id: 3, title: '拼单:一起拼买有机蔬菜', desc: '本周六上午去有机农场采购,有意愿一起拼单的邻居可以联系我', author: '张华', building: '1栋201', time: '昨天', status: 'open', category: '拼单', replies: 12 },
    { id: 4, title: '提醒:快递代收箱位置变更', desc: '快递代收箱已搬到南门旁边,之前放在北门的邻居注意一下', author: '赵强', building: '4栋501', time: '昨天', status: 'open', category: '提醒', replies: 0 },
  ]
  @State secondHandItems: SecondHandItem[] = [
    { id: 1, title: '9成新IKEA实木书桌', price: 350, originalPrice: 899, condition: '9成新', author: '陈家', building: '1栋802', time: '1小时前', color: '#74b9ff', isWanted: false },
    { id: 2, title: '婴儿推车 用了8个月', price: 200, originalPrice: 1500, condition: '8成新', author: '刘家', building: '2栋402', time: '3小时前', color: '#fd79a8', isWanted: true },
    { id: 3, title: '跑步机 欢迎试用', price: 800, originalPrice: 3000, condition: '7成新', author: '周先生', building: '3栋102', time: '昨天', color: '#00b894', isWanted: false },
    { id: 4, title: '全套厨具 搬家出售', price: 150, originalPrice: 400, condition: '6成新', author: '吴家', building: '5栋701', time: '2天前', color: '#a29bfe', isWanted: false },
    { id: 5, title: '儿童自行车 16寸', price: 120, originalPrice: 380, condition: '8成新', author: '孙家', building: '2栋301', time: '2天前', color: '#fdcb6e', isWanted: false },
  ]

  private tabs: string[] = ['公告', '互助', '二手市场']

  private typeColors: Record<string, string> = {
    '维修': '#ff4d4f',
    '通知': '#1890ff',
    '租赁': '#52c41a',
    '施工': '#fa8c16',
  }

  private categoryColors: Record<string, string> = {
    '求助': '#1890ff',
    '借用': '#52c41a',
    '拼单': '#722ed1',
    '提醒': '#fa8c16',
  }

  toggleWanted(id: number) {
    this.secondHandItems = this.secondHandItems.map((item) => { if (item.id === id) { item.isWanted = !item.isWanted } return item })
  }

  build() {
    Column({ space: 0 }) {
      // 顶部
      Row() {
        Column({ space: 2 }) {
          Text('幸福小区').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')
          Text('3栋 · 1001室').fontSize(12).fontColor('#aaaaaa')
        }
        .alignItems(HorizontalAlign.Start)
        Blank()
        Text('🔔').fontSize(24)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      .backgroundColor('#ffffff')

      // Tab
      Row() {
        ForEach(this.tabs, (tab: string, idx: number) => {
          Column() {
            Text(tab)
              .fontSize(15)
              .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.activeTab === idx ? '#1890ff' : '#888888')
              .padding({ top: 10, bottom: 8 })
            Divider().strokeWidth(2.5).color(this.activeTab === idx ? '#1890ff' : 'transparent')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.activeTab = idx })
        })
      }
      .backgroundColor('#ffffff')
      .border({ width: { bottom: 0.5 }, color: '#f0f0f0' })

      Scroll() {
        Column({ space: 12 }) {
          if (this.activeTab === 0) {
            // 公告列表
            ForEach(this.announcements, (item: AnnouncementItem) => {
              Column({ space: 8 }) {
                Row({ space: 8 }) {
                  Text(item.type)
                    .fontSize(11).fontColor('#ffffff')
                    .backgroundColor(this.typeColors[item.type] ?? '#aaaaaa')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(4)
                  if (item.important) {
                    Text('重要').fontSize(11).fontColor('#ff4d4f').fontWeight(FontWeight.Bold)
                  }
                  Blank()
                  Text(item.time).fontSize(12).fontColor('#aaaaaa')
                }
                .width('100%')

                Text(item.title)
                  .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1a1a1a').width('100%')
                Text(item.content)
                  .fontSize(13).fontColor('#666666').lineHeight(20).width('100%').maxLines(3)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .padding(16)
              .backgroundColor('#ffffff')
              .borderRadius(12)
              .border({ width: item.important ? 2 : 0, color: '#ff4d4f20' })
            })
          } else if (this.activeTab === 1) {
            // 互助列表
            ForEach(this.helpItems, (item: HelpRequest) => {
              Column({ space: 8 }) {
                Row({ space: 8 }) {
                  Text(item.category)
                    .fontSize(11).fontColor('#ffffff')
                    .backgroundColor(this.categoryColors[item.category] ?? '#aaaaaa')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(4)
                  Text(item.status === 'resolved' ? '✓ 已解决' : '待回应')
                    .fontSize(11)
                    .fontColor(item.status === 'resolved' ? '#52c41a' : '#fa8c16')
                    .fontWeight(FontWeight.Medium)
                  Blank()
                  Text(item.time).fontSize(12).fontColor('#aaaaaa')
                }
                .width('100%')

                Text(item.title)
                  .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1a1a1a').width('100%')
                Text(item.desc)
                  .fontSize(13).fontColor('#666666').width('100%').maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row() {
                  Text(`👤 ${item.author} · ${item.building}`)
                    .fontSize(12).fontColor('#999999')
                  Blank()
                  Text(`💬 ${item.replies}条回复`)
                    .fontSize(12).fontColor('#1890ff')
                }
                .width('100%')
              }
              .padding(16)
              .backgroundColor('#ffffff')
              .borderRadius(12)
            })
          } else {
            // 二手市场
            ForEach(this.secondHandItems, (item: SecondHandItem) => {
              Row({ space: 12 }) {
                Column()
                  .width(80).height(80)
                  .backgroundColor(item.color + '40')
                  .borderRadius(10)

                Column({ space: 6 }) {
                  Text(item.title)
                    .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')
                    .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row({ space: 6 }) {
                    Text(`¥${item.price}`)
                      .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#ff4d4f')
                    Text(`原¥${item.originalPrice}`)
                      .fontSize(12).fontColor('#cccccc')
                      .decoration({ type: TextDecorationType.LineThrough })
                  }
                  Row({ space: 6 }) {
                    Text(item.condition)
                      .fontSize(11).fontColor('#52c41a').backgroundColor('#f0fff4')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                    Text(`📍 ${item.building}`)
                      .fontSize(11).fontColor('#aaaaaa')
                  }
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)

                Column({ space: 8 }) {
                  Text(item.isWanted ? '❤️' : '🤍')
                    .fontSize(22)
                    .onClick(() => this.toggleWanted(item.id))
                  Text(item.time).fontSize(10).fontColor('#cccccc')
                }
                .alignItems(HorizontalAlign.Center)
              }
              .padding(16)
              .backgroundColor('#ffffff')
              .borderRadius(12)
            })
          }
          Column().height(24)
        }
        .padding({ left: 16, right: 16, top: 12 })
      }
      .layoutWeight(1)
      .backgroundColor('#f5f7fa')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f7fa')
  }
}

Technical ink-note illustration focusing on the 't

落地建议

这类案例最值得学的,其实不是某一个控件怎么写,而是页面组织方式。把状态放对、把交互函数收好、把布局压简单,后面你接正式项目时会轻松很多。

Logo

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

更多推荐