《旅游住宿》三、通用 UI 组件

HarmonyOS 分层架构 — common 模块通用组件开发实战(@ComponentV2)

图标资源说明:HarmonyOS 6.1 中 sys.media.ohos_ic_public_*sys.symbol.* 系统图标资源已大量废弃或不可用。本项目实际采用 Unicode 文本字符 替代所有系统图标(如箭头 、爱心 、关闭 、空状态 等),图片资源采用渐变色 + 首字文字占位符。下文代码示例中保留原始 sys.media.* 写法以便理解 API 用法,实际运行请以项目源码为准。


效果

一、通用组件总览

common 模块作为整个项目的基础层,承载了所有 feature 模块共享的 UI 组件。每一个组件都遵循 纯展示、无业务逻辑、单向数据流 的设计原则,使用 HarmonyOS 6.1 的 @ComponentV2 装饰器体系构建。

组件名用途关键装饰器所在文件
SectionHeader区块标题栏(标题 + “查看更多”)@Param + @Eventcomponents/SectionHeader.ets
ScenicCard景点竖向卡片(封面 + 评分 + 门票)@Param + @Eventcomponents/ScenicCard.ets
FoodCard美食横向卡片(左图右文)@Param + @Eventcomponents/FoodCard.ets
HotelCard住宿横向卡片(左图右文 + 价格标签)@Param + @Eventcomponents/HotelCard.ets
LoadingView加载占位(LoadingProgress + 文字)@Paramcomponents/LoadingView.ets
EmptyView空状态占位(图标 + 提示文字)@Paramcomponents/EmptyView.ets
ImageView通用图片容器@Param × 5components/ImageView.ets

约定:所有颜色、字号、圆角等样式值统一从 ThemeConstants 静态常量读取,组件内部不硬编码任何魔法数字。


二、SectionHeader — 区块标题栏

2.1 完整代码

import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct SectionHeader {
  @Param title: string = '';
  @Param moreText: string = '';
  @Event onMoreClick: () => void = () => {};

  build() {
    Row() {
      Text(this.title)
        .fontSize(ThemeConstants.FONT_SIZE_SUBTITLE)
        .fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
        .fontColor(ThemeConstants.TEXT_PRIMARY)
      Blank()
      if (this.moreText.length > 0) {
        Row() {
          Text(this.moreText)
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.TEXT_HINT)
          Image($r('sys.media.ohos_ic_public_arrow_right'))
            .width(ThemeConstants.ICON_SIZE_SMALL)
            .height(ThemeConstants.ICON_SIZE_SMALL)
            .fillColor(ThemeConstants.TEXT_HINT)
        }
        .onClick(() => {
          this.onMoreClick();
        })
      }
    }
    .width(ThemeConstants.FULL_WIDTH)
    .padding({
      left: ThemeConstants.CARD_PADDING,
      right: ThemeConstants.CARD_PADDING,
      top: ThemeConstants.SECTION_MARGIN,
      bottom: 12
    })
  }
}

2.2 使用示例

// 在首页"热门景点"区块头部使用
SectionHeader({
  title: '热门景点',
  moreText: '查看更多',
  onMoreClick: () => {
    RouterService.push(RouteConstants.PARK_LIST_PAGE);
  }
})

2.3 设计要点

  • @Param title:由父组件传入,组件内部只读渲染,不参与任何状态变更。
  • @Event onMoreClick:事件回调,当 moreText 非空时才显示右侧可点击区域——通过条件渲染自动隐藏不需要的 UI 元素。
  • Blank() 弹性空间:利用 ArkUI 的 Blank() 组件将标题与右侧"更多"推到两端,无需手动计算 margin。

三、ScenicCard — 景点卡片

3.1 完整代码

import { ScenicSpot } from '../models/ScenicSpot';
import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct ScenicCard {
  @Param spot: ScenicSpot = new ScenicSpot();
  @Event onCardClick: (spot: ScenicSpot) => void = () => {};

  build() {
    Column() {
      Stack() {
        Image(this.spot.coverImage)
          .width(160)
          .height(120)
          .objectFit(ImageFit.Cover)
          .borderRadius({
            topLeft: ThemeConstants.CARD_RADIUS,
            topRight: ThemeConstants.CARD_RADIUS
          })

        if (this.spot.isFavorite) {
          Image($r('sys.media.ohos_ic_public_favour_filled'))
            .width(20)
            .height(20)
            .fillColor(ThemeConstants.FAVORITE)
            .position({ right: 8, top: 8 })
        }
      }

      Column() {
        Text(this.spot.name)
          .fontSize(ThemeConstants.FONT_SIZE_BODY)
          .fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
          .fontColor(ThemeConstants.TEXT_PRIMARY)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width(ThemeConstants.FULL_WIDTH)

        Row() {
          Text(this.spot.rating.toFixed(1))
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.WARNING)
            .fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
          Text(' · ' + this.spot.category)
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.TEXT_HINT)
        }
        .margin({ top: 4 })

        Text(this.spot.ticketPrice.length > 0 ? this.spot.ticketPrice : '免费')
          .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
          .fontColor(this.spot.hasTicket ? ThemeConstants.ACCENT : ThemeConstants.SUCCESS)
          .margin({ top: 4 })
      }
      .width(ThemeConstants.FULL_WIDTH)
      .padding(10)
    }
    .width(160)
    .backgroundColor(ThemeConstants.CARD_BG)
    .borderRadius(ThemeConstants.CARD_RADIUS)
    .shadow({
      radius: 8,
      color: '#1A000000',
      offsetY: 2
    })
    .onClick(() => {
      this.onCardClick(this.spot);
    })
  }
}

3.2 使用示例

// 在 Swiper 或 Grid 中遍历景点列表
@Builder
scenicItemBuilder(item: ScenicSpot) {
  ScenicCard({
    spot: item,
    onCardClick: (spot: ScenicSpot) => {
      RouterService.push(RouteConstants.PARK_DETAIL_PAGE, { spotId: spot.id });
    }
  })
}

3.3 设计要点

  • Stack 叠层布局:封面图上方通过 position 叠放收藏心形图标,当 isFavoritetrue 时才渲染——条件渲染减少无效 DOM 节点。
  • 价格颜色语义化hasTickettrue 时用强调色 ACCENT(橙色)突出付费信息,免费景点用 SUCCESS(绿色)传递正向信号。
  • shadow 投影radius: 8 + #1A000000(10% 透明度黑色)+ offsetY: 2,营造轻微悬浮质感,与 Material Design 的 elevation 理念一致。

四、FoodCard — 美食卡片

4.1 完整代码

import { FoodItem } from '../models/FoodItem';
import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct FoodCard {
  @Param food: FoodItem = new FoodItem();
  @Event onCardClick: (food: FoodItem) => void = () => {};

  build() {
    Row() {
      Image(this.food.coverImage)
        .width(100)
        .height(100)
        .objectFit(ImageFit.Cover)
        .borderRadius({
          topLeft: ThemeConstants.CARD_RADIUS,
          bottomLeft: ThemeConstants.CARD_RADIUS
        })

      Column() {
        Text(this.food.name)
          .fontSize(ThemeConstants.FONT_SIZE_BODY)
          .fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
          .fontColor(ThemeConstants.TEXT_PRIMARY)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.food.description)
          .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
          .fontColor(ThemeConstants.TEXT_SECONDARY)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 4 })

        Row() {
          Text(this.food.restaurant)
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.PRIMARY)
          Blank()
          Text(this.food.priceRange)
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.ACCENT)
        }
        .width(ThemeConstants.FULL_WIDTH)
        .margin({ top: 8 })
      }
      .layoutWeight(1)
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
    }
    .width(ThemeConstants.FULL_WIDTH)
    .backgroundColor(ThemeConstants.CARD_BG)
    .borderRadius(ThemeConstants.CARD_RADIUS)
    .shadow({
      radius: 6,
      color: '#14000000',
      offsetY: 2
    })
    .onClick(() => {
      this.onCardClick(this.food);
    })
  }
}

4.2 使用示例

// 在 List 组件中使用
List() {
  ForEach(this.foodList, (item: FoodItem) => {
    ListItem() {
      FoodCard({
        food: item,
        onCardClick: (food: FoodItem) => {
          // 跳转美食详情
        }
      })
    }
    .padding({ left: 16, right: 16, top: 8 })
  })
}

4.3 设计要点

  • 横向布局(Row):左侧固定宽度封面图(100×100),右侧 layoutWeight(1) 自适应撑满,适配不同屏幕宽度。
  • 双行截断:描述文字 maxLines(2) + textOverflow({ overflow: TextOverflow.Ellipsis }),超出两行优雅省略。
  • 餐厅名用品牌色 PRIMARY:引导用户关注商家品牌信息;价格范围用 ACCENT 橙色,符合消费场景的视觉习惯。

五、HotelCard — 住宿卡片

5.1 完整代码

import { HotelItem } from '../models/HotelItem';
import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct HotelCard {
  @Param hotel: HotelItem = new HotelItem();
  @Event onCardClick: (hotel: HotelItem) => void = () => {};

  build() {
    Row() {
      Image(this.hotel.coverImage)
        .width(120)
        .height(100)
        .objectFit(ImageFit.Cover)
        .borderRadius({
          topLeft: ThemeConstants.CARD_RADIUS,
          bottomLeft: ThemeConstants.CARD_RADIUS
        })

      Column() {
        Row() {
          Text(this.hotel.name)
            .fontSize(ThemeConstants.FONT_SIZE_BODY)
            .fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
            .fontColor(ThemeConstants.TEXT_PRIMARY)
            .layoutWeight(1)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(this.hotel.priceLevel)
            .fontSize(ThemeConstants.FONT_SIZE_SMALL)
            .fontColor(ThemeConstants.TEXT_WHITE)
            .backgroundColor(ThemeConstants.PRIMARY)
            .borderRadius(4)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        }
        .width(ThemeConstants.FULL_WIDTH)

        Text(this.hotel.address)
          .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
          .fontColor(ThemeConstants.TEXT_SECONDARY)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 4 })

        Row() {
          Text(this.hotel.rating.toFixed(1) + '分')
            .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
            .fontColor(ThemeConstants.WARNING)
          Blank()
          Text('¥' + this.hotel.pricePerNight + '/晚')
            .fontSize(ThemeConstants.FONT_SIZE_BODY)
            .fontColor(ThemeConstants.ACCENT)
            .fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
        }
        .width(ThemeConstants.FULL_WIDTH)
        .margin({ top: 6 })
      }
      .layoutWeight(1)
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    }
    .width(ThemeConstants.FULL_WIDTH)
    .backgroundColor(ThemeConstants.CARD_BG)
    .borderRadius(ThemeConstants.CARD_RADIUS)
    .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
    .onClick(() => {
      this.onCardClick(this.hotel);
    })
  }
}

5.2 使用示例

// 住宿列表页
List() {
  ForEach(this.hotelList, (item: HotelItem) => {
    ListItem() {
      HotelCard({
        hotel: item,
        onCardClick: (hotel: HotelItem) => {
          // 跳转酒店详情
        }
      })
    }
  })
}

5.3 设计要点

  • 价格等级标签 priceLevel:用品牌主色 PRIMARY 做背景、白色文字、小圆角,形成类似 “高档” / “豪华” 的角标效果,视觉上与酒店 App 的行业惯例一致。
  • 底部价格行:评分用 WARNING 黄色,房价用 ACCENT 橙色加粗——用户扫视卡片时最先捕获的两个关键信息。
  • Blank() 两端对齐:评分与价格分居左右,无需 Flex 的 justify-content: space-between,代码更简洁。

六、LoadingView — 加载占位

6.1 完整代码

import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct LoadingView {
  @Param text: string = '加载中...';

  build() {
    Column() {
      LoadingProgress()
        .width(48)
        .height(48)
        .color(ThemeConstants.PRIMARY)
      Text(this.text)
        .fontSize(ThemeConstants.FONT_SIZE_CAPTION)
        .fontColor(ThemeConstants.TEXT_HINT)
        .margin({ top: 12 })
    }
    .width(ThemeConstants.FULL_WIDTH)
    .justifyContent(FlexAlign.Center)
    .padding(40)
  }
}

6.2 使用示例

// 配合 if/else 条件渲染
if (this.isLoading) {
  LoadingView({ text: '正在获取景点数据...' })
} else {
  // 正式内容
}

6.3 设计要点

  • 系统 LoadingProgress 组件:HarmonyOS 内置的加载动画,自带旋转效果,无需引入第三方 Lottie。
  • 文字可定制:默认 “加载中…”,父组件可传入 “正在获取景点数据…” / “正在搜索附近酒店…” 等具体提示。
  • 最小化设计:整个组件仅接受一个 @Param,无事件回调——加载状态本身就是终态,不需要用户交互。

七、EmptyView — 空状态占位

7.1 完整代码

import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct EmptyView {
  @Param message: string = '暂无数据';
  @Param icon: Resource | null = null;

  build() {
    Column() {
      if (this.icon) {
        Image(this.icon)
          .width(80)
          .height(80)
          .fillColor(ThemeConstants.TEXT_HINT)
      } else {
        Image($r('sys.media.ohos_ic_public_folder'))
          .width(80)
          .height(80)
          .fillColor(ThemeConstants.TEXT_HINT)
      }
      Text(this.message)
        .fontSize(ThemeConstants.FONT_SIZE_BODY)
        .fontColor(ThemeConstants.TEXT_HINT)
        .margin({ top: 16 })
    }
    .width(ThemeConstants.FULL_WIDTH)
    .justifyContent(FlexAlign.Center)
    .padding(60)
  }
}

7.2 使用示例

// 收藏列表为空时
if (this.favorites.length === 0) {
  EmptyView({
    message: '还没有收藏景点哦',
    icon: $r('sys.media.ohos_ic_public_favour')
  })
} else {
  // 收藏列表
}

7.3 设计要点

  • 图标可空设计@Param icon: Resource | null = null,当不传图标时使用系统默认文件夹图标,保证组件始终有视觉锚点。
  • fillColor 统一灰色:无论传入什么图标,都填充为 TEXT_HINT 灰色,保持空状态的"低调"视觉层级,不与正式内容抢夺注意力。
  • 大 padding 居中padding(60) 确保空状态在页面中有足够的留白,避免紧贴边缘显得拥挤。

八、ImageView — 通用图片容器

8.1 完整代码

import { ThemeConstants } from '../constants/ThemeConstants';

@ComponentV2
export struct ImageView {
  @Param src: string | Resource = '';
  @Param width: number = 100;
  @Param height: number = 100;
  @Param radius: number = 0;
  @Param fit: ImageFit = ImageFit.Cover;

  build() {
    Image(this.src)
      .width(this.width)
      .height(this.height)
      .objectFit(this.fit)
      .borderRadius(this.radius)
      .backgroundColor(ThemeConstants.DIVIDER)
  }
}

8.2 使用示例

// 头像
ImageView({
  src: this.userInfo.avatar,
  width: 64,
  height: 64,
  radius: 32
})

// Banner 图片
ImageView({
  src: banner.imageUrl,
  width: 360,
  height: 180,
  radius: 12,
  fit: ImageFit.Cover
})

8.3 设计要点

  • 多参数 @Param:5 个 @Param 覆盖了图片渲染的核心属性——来源、尺寸、圆角、适配模式。
  • src: string | Resource 联合类型:同时支持网络 URL 和本地资源 $r(),一处调用适配两种场景。
  • backgroundColor: DIVIDER:图片加载前显示浅灰色背景,避免白色闪烁(FOUC),提升加载体验。

九、组件注册导出

所有通用组件在 common/Index.ets 中统一导出,feature 模块只需 import { XxxCard } from 'common' 即可使用:

// common/Index.ets(组件部分)

// Components
export { SectionHeader } from './src/main/ets/components/SectionHeader';
export { ScenicCard } from './src/main/ets/components/ScenicCard';
export { FoodCard } from './src/main/ets/components/FoodCard';
export { HotelCard } from './src/main/ets/components/HotelCard';
export { LoadingView } from './src/main/ets/components/LoadingView';
export { EmptyView } from './src/main/ets/components/EmptyView';
export { ImageView } from './src/main/ets/components/ImageView';

导出规范

  • 每个组件使用 export struct 声明,确保可以被外部模块导入。
  • Index.ets 是 common 模块的唯一入口文件,所有导出在此集中管理。
  • 组件导出时不附加任何路径前缀——消费者只需 from 'common'

十、设计原则总结

10.1 @Param 单向数据流

@ComponentV2 中的 @Param 装饰器表示只读参数:父组件传入值,子组件仅用于渲染,无法反向修改。这与 @Prop(V1)类似,但在 V2 体系中语义更明确——@Param 就是"参数",不是"属性"。

@Param title: string = '';    // 父 → 子单向传递
@Param spot: ScenicSpot = new ScenicSpot(); // 对象类型同样适用

10.2 @Event 事件回调

当子组件需要向父组件传递信息时(如卡片被点击),使用 @Event 装饰器声明回调函数:

@Event onCardClick: (spot: ScenicSpot) => void = () => {};
  • 默认值为空函数 () => {},父组件不传也不会报错。
  • 回调参数携带业务数据(如 ScenicSpot 对象),父组件拿到后执行路由跳转等业务逻辑。
  • 组件本身不包含任何导航、网络请求等业务代码——这是 common 组件与 feature 组件的核心分界线。

10.3 无业务逻辑原则

通用组件的 build() 方法中不允许出现

  • RouterService.push() 等路由调用
  • HttpService.get() 等网络请求
  • RdbService 等本地存储操作

所有业务行为通过 @Event 回调上抛给 feature 模块处理。这使得通用组件可以跨 feature 复用,且便于单元测试。

10.4 类型安全

  • 所有 @Param 都有明确的类型声明和默认值(string = ''ScenicSpot = new ScenicSpot())。
  • @Event 的回调签名严格约束参数类型和返回值。
  • ImageViewsrc 使用联合类型 string | Resource,编译期即可捕获非法传值。

这套类型系统确保了在大型多人协作项目中,组件接口的变更能在编译阶段被及时发现,而非等到运行时崩溃。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值