手绘墨水风格架构图,标题为HarmonyOS7 ArkUI多媒体播放列表。核心模块分为三层:UI层包

前言

我比较喜欢这种能看出业务味道的案例。代码里不只有布局,还有筛选、统计、切换、校验这些真正项目里常见的东西。

这个案例的主线是 多媒体播放列表,后面的 视频列表、分类Tab与播放状态 则把页面做得更像真实业务页。读代码时,建议把交互、状态和列表这三条线一起看。

为什么这个案例值得写

MediaPlaylistPage 对应的是 多媒体播放列表 - 视频列表、分类Tab与播放状态 这个业务场景。别把它只当成一个 UI 练习页,它真正有价值的地方在于:同一个页面里同时出现了状态切换、列表渲染、条件展示和用户反馈。

观察点 在这个案例里的表现
页面主题 多媒体播放列表
主要文案 技术, 鸿蒙开发从入门到项目实战, 码农小王, 8.2万, TypeScript 高级特性详解, 前端进阶
常用组件 Column, Divider, ForEach, Row, Scroll, Stack, Text, TextInput
适合练习 状态驱动 UI、局部刷新、事件回调、页面结构拆分

使用方法

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

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

界面背后的数据流

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

状态字段 类型 说明
activeCategory number 当前选中项,决定内容切换、高亮或过滤结果
playingId number 布尔状态,控制弹层、模式或显示隐藏
videos VideoItem_zjd6[] 记录当前展示状态或页面选择
searchText string 用户输入值,会直接参与计算、筛选或提交

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

单独看这段代码

片段 1:get filteredVideos(): VideoItem_zjd6[] {

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

  get filteredVideos(): VideoItem_zjd6[] {
    const cat = this.categories[this.activeCategory]
    return this.videos.filter((v: VideoItem_zjd6) => {
      const matchCat = cat === '全部' || v.category === cat
      const matchSearch = this.searchText === '' || v.title.includes(this.searchText) || v.author.includes(this.searchText)
      return matchCat && matchSearch
    })
  }

手绘墨水流程图,展示视频筛选逻辑get filteredVideos。起点:用户输入searchTe

片段 2:toggleLike(id: number) {

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

  toggleLike(id: number) {
    this.videos = this.videos.map((v: VideoItem_zjd6) => {
      if (v.id === id) {
        v.likes = v.isLiked ? v.likes - 1 : v.likes + 1
        v.isLiked = !v.isLiked
        return v
      }
      return v
    })
  }

必读小结

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

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

完整代码

// MediaPlaylistPage - 多媒体播放列表 - 视频列表、分类Tab与播放状态

interface VideoItem_zjd6 {
  id: number
  title: string
  author: string
  duration: string
  views: string
  likes: number
  coverColor: string
  category: string
  isLiked: boolean
  isCollected: boolean
}

@Entry
@Component
struct MediaPlaylistPage {
  @State activeCategory: number = 0
  @State playingId: number = 1
  @State videos: VideoItem_zjd6[] = [
    { id: 1, title: 'HarmonyOS ArkUI 完整教程 2024', author: '技术达人', duration: '2:30:15', views: '12.5万', likes: 3210, coverColor: '#667eea', category: '技术', isLiked: false, isCollected: true },
    { id: 2, title: '鸿蒙开发从入门到项目实战', author: '码农小王', duration: '1:45:30', views: '8.2万', likes: 1890, coverColor: '#f64f59', category: '技术', isLiked: true, isCollected: false },
    { id: 3, title: 'TypeScript 高级特性详解', author: '前端进阶', duration: '58:20', views: '5.6万', likes: 990, coverColor: '#00b894', category: '技术', isLiked: false, isCollected: false },
    { id: 4, title: '深圳旅游4K风景大片', author: '旅行者', duration: '15:40', views: '23.1万', likes: 8900, coverColor: '#fd79a8', category: '旅行', isLiked: false, isCollected: false },
    { id: 5, title: '西藏自驾游全程记录', author: '探险家', duration: '42:10', views: '18.7万', likes: 6230, coverColor: '#74b9ff', category: '旅行', isLiked: true, isCollected: true },
    { id: 6, title: '健身房胸部训练完整计划', author: '健身教练', duration: '25:05', views: '4.8万', likes: 2100, coverColor: '#6c5ce7', category: '运动', isLiked: false, isCollected: false },
    { id: 7, title: '零基础学瑜伽 第一课', author: '瑜伽老师', duration: '30:00', views: '11.2万', likes: 4500, coverColor: '#a29bfe', category: '运动', isLiked: false, isCollected: false },
    { id: 8, title: '家常川菜10道合集', author: '厨艺大师', duration: '22:15', views: '31.8万', likes: 12000, coverColor: '#ff7675', category: '美食', isLiked: true, isCollected: true },
  ]
  @State searchText: string = ''

  private categories: string[] = ['全部', '技术', '旅行', '运动', '美食']

  get filteredVideos(): VideoItem_zjd6[] {
    const cat = this.categories[this.activeCategory]
    return this.videos.filter((v: VideoItem_zjd6) => {
      const matchCat = cat === '全部' || v.category === cat
      const matchSearch = this.searchText === '' || v.title.includes(this.searchText) || v.author.includes(this.searchText)
      return matchCat && matchSearch
    })
  }

  toggleLike(id: number) {
    this.videos = this.videos.map((v: VideoItem_zjd6) => {
      if (v.id === id) {
        v.likes = v.isLiked ? v.likes - 1 : v.likes + 1
        v.isLiked = !v.isLiked
        return v
      }
      return v
    })
  }

  toggleCollect(id: number) {
    this.videos = this.videos.map((v) => { if (v.id === id) { v.isCollected = !v.isCollected } return v })
  }

  formatLikes(n: number): string {
    return n >= 10000 ? `${(n / 10000).toFixed(1)}` : `${n}`
  }

  @Builder
  VideoCard(item: VideoItem_zjd6) {
    Column({ space: 0 }) {
      // 封面
      Stack({ alignContent: Alignment.BottomStart }) {
        Column()
          .width('100%')
          .height(200)
          .linearGradient({ angle: 135, colors: [[item.coverColor, 0], ['#00000080', 1]] })

        // 播放按钮
        Stack() {
          Circle().width(52).height(52).fill('#ffffff40')
          Text(this.playingId === item.id ? '⏸' : '▶')
            .fontSize(22)
            .fontColor('#ffffff')
            .margin({ left: this.playingId === item.id ? 0 : 2 })
        }
        .position({ x: '50%', y: '50%' })
        .translate({ x: -26, y: -26 })
        .onClick(() => { this.playingId = item.id })

        // 时长
        Text(item.duration)
          .fontSize(12)
          .fontColor('#ffffff')
          .backgroundColor('#00000080')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(4)
          .margin(10)

        if (this.playingId === item.id) {
          Text('正在播放')
            .fontSize(11)
            .fontColor('#ffffff')
            .backgroundColor('#1890ff')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(4)
            .position({ x: 0, y: 0 })
            .margin(10)
        }
      }
      .width('100%')

      // 视频信息
      Column({ space: 10 }) {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1a1a1a')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .lineHeight(22)
          .width('100%')

        Row({ space: 10 }) {
          // 作者头像
          Stack() {
            Circle().width(28).height(28).fill(item.coverColor + '60')
            Text(item.author.charAt(0)).fontSize(12).fontColor('#ffffff').fontWeight(FontWeight.Bold)
          }
          Text(item.author)
            .fontSize(13)
            .fontColor('#666666')
          Blank()
          Text(`${item.views}次观看`)
            .fontSize(12)
            .fontColor('#aaaaaa')
        }
        .width('100%')

        Row({ space: 0 }) {
          Text(item.category)
            .fontSize(11)
            .fontColor(item.coverColor)
            .backgroundColor(item.coverColor + '20')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10)
          Blank()
          // 点赞
          Row({ space: 4 }) {
            Text(item.isLiked ? '❤️' : '🤍').fontSize(16)
            Text(this.formatLikes(item.likes)).fontSize(13).fontColor(item.isLiked ? '#ff4d4f' : '#aaaaaa')
          }
          .padding({ left: 10, right: 10 })
          .onClick(() => this.toggleLike(item.id))

          // 收藏
          Row({ space: 4 }) {
            Text(item.isCollected ? '⭐' : '☆').fontSize(16)
          }
          .padding({ left: 6, right: 6 })
          .onClick(() => this.toggleCollect(item.id))

          // 分享
          Text('📤').fontSize(16).padding({ left: 6 })
        }
        .width('100%')
      }
      .padding(14)
    }
    .width('100%')
    .backgroundColor('#ffffff')
    .borderRadius(12)
    .clip(true)
  }

  build() {
    Column({ space: 0 }) {
      // 顶部
      Column({ space: 12 }) {
        Row() {
          Text('视频').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')
          Blank()
          Text('🔍').fontSize(20).fontColor('#666666')
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('🔍').fontSize(16).fontColor('#aaaaaa')
          TextInput({ placeholder: '搜索视频或作者...', text: this.searchText })
            .layoutWeight(1).height(36).fontSize(14).backgroundColor('transparent')
            .onChange((v: string) => { this.searchText = v })
        }
        .padding({ left: 12, right: 12 })
        .backgroundColor('#f5f5f5')
        .borderRadius(20)
        .height(44)
      }
      .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      .backgroundColor('#ffffff')

      // 分类Tab
      Scroll() {
        Row({ space: 0 }) {
          ForEach(this.categories, (cat: string, idx: number) => {
            Column() {
              Text(cat)
                .fontSize(15)
                .fontWeight(this.activeCategory === idx ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.activeCategory === idx ? '#1890ff' : '#888888')
                .padding({ left: 16, right: 16, top: 10, bottom: 8 })
              Divider()
                .strokeWidth(2.5)
                .color(this.activeCategory === idx ? '#1890ff' : 'transparent')
                .margin({ left: 12, right: 12 })
            }
            .onClick(() => { this.activeCategory = idx })
          })
        }
        .padding({ left: 4, right: 4 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .backgroundColor('#ffffff')
      .border({ width: { bottom: 0.5 }, color: '#f0f0f0' })

      // 视频列表
      Scroll() {
        Column({ space: 12 }) {
          ForEach(this.filteredVideos, (item: VideoItem_zjd6) => {
            this.VideoCard(item)
          }, (item: VideoItem_zjd6) => item.id.toString())
          Column().height(24)
        }
        .padding({ left: 16, right: 16, top: 12 })
      }
      .layoutWeight(1)
      .backgroundColor('#f5f7fa')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f7fa')
  }
}

继续扩展

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

Logo

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

更多推荐