鸿蒙原生应用开发实战(三):电影列表与搜索筛选 — 电影清单App

前言

随着用户添加的电影越来越多,需要有一个功能完善的列表页面来管理和查找。这篇文章将开发电影列表页,支持多维度状态筛选、关键词搜索和滑动删除。

本文将覆盖:

  1. 卡片式电影列表
  2. 五标签状态筛选(全部/已看/想看/在看/收藏)
  3. 关键词实时搜索
  4. 滑动删除操作
  5. 空状态设计

一、列表页面设计

┌──────────────────────────────────┐
│  < 返回         全部电影     🔍  │
├──────────────────────────────────┤
│  🔍 [搜索片名、导演或分类...]    │  ← 可展开搜索栏
│  全部  ✅已看  👀想看  ▶️在看 ⭐收藏│ ← 筛选标签
├──────────────────────────────────┤
│  8 部电影                        │  ← 计数
├──────────────────────────────────┤
│  ┌──────────────────────────┐   │
│  │ 🐭 疯狂动物城     ▶️在看 >│   │  ← 电影卡片
│  │   2016                     │   │  (支持滑动删除)
│  └──────────────────────────┘   │
│  ┌──────────────────────────┐   │
│  │ 🎭 楚门的世界      ✅已看 >│   │
│  │ ⭐ 1998                   │   │
│  └──────────────────────────┘   │
│  ┌──────────────────────────┐   │
│  │ ❤️ 泰坦尼克号      👀想看 >│   │
│  │   1997                    │   │
│  └──────────────────────────┘   │
└──────────────────────────────────┘

二、状态定义

@Entry
@Component
struct ListPage {
  @State movies: Movie[] = [];
  @State filteredList: Movie[] = [];
  @State filterStatus: string = 'all';
  @State searchText: string = '';
  @State showSearch: boolean = false;
}

三、多维度筛选引擎

3.1 筛选逻辑

筛选引擎支持3个维度的组合过滤:状态 + 关键词。

applyFilter(): void {
  let result: Movie[] = [];
  for (let i = 0; i < this.movies.length; i++) {
    let m = this.movies[i];
    let match = true;

    // 维度1:状态筛选
    if (this.filterStatus === 'watched' && m.status !== MovieStatus.WATCHED) {
      match = false;
    }
    if (this.filterStatus === 'want' && m.status !== MovieStatus.WANT_TO_WATCH) {
      match = false;
    }
    if (this.filterStatus === 'watching' && m.status !== MovieStatus.WATCHING) {
      match = false;
    }
    if (this.filterStatus === 'fav' && !m.isFavorite) {
      match = false;
    }

    // 维度2:关键词搜索
    if (this.searchText !== '') {
      let kw = this.searchText.toLowerCase();
      let titleMatch = m.title.toLowerCase().indexOf(kw) !== -1;
      let dirMatch = m.director.toLowerCase().indexOf(kw) !== -1;
      let genre = getGenreById(m.genreId);
      let genreMatch = genre ? genre.name.indexOf(kw) !== -1 : false;
      if (!titleMatch && !dirMatch && !genreMatch) {
        match = false;
      }
    }

    if (match) {
      result.push(m);
    }
  }

  // 按日期倒序
  result.sort((a, b) => b.dateAdded > a.dateAdded ? 1 : -1);
  this.filteredList = result;
}

3.2 搜索范围

搜索不仅匹配电影名称,还匹配导演和分类名称,提高搜索命中率:

  • 片名m.title.toLowerCase().indexOf(kw)
  • 导演m.director.toLowerCase().indexOf(kw)
  • 分类genre.name.indexOf(kw)

四、可展开搜索栏

4.1 搜索开关

点击标题栏右侧 🔍 按钮展开/收起搜索栏:

// 标题栏
Row() {
  Text('< 返回')
    .fontSize(16).fontColor('#6C63FF')
    .onClick(() => { router.back(); })
  Blank()
  Text('全部电影').fontSize(18).fontWeight(FontWeight.Bold)
  Blank()
  Text('🔍').fontSize(18)
    .onClick(() => {
      this.showSearch = !this.showSearch;
      if (!this.showSearch) {
        this.searchText = '';   // 收起时清空搜索
        this.applyFilter();
      }
    })
}

4.2 条件渲染搜索栏

if (this.showSearch) {
  Row() {
    TextInput({ placeholder: '搜索片名、导演或分类...', text: this.searchText })
      .fontSize(14)
      .layoutWeight(1)
      .height(36)
      .placeholderColor('#CCCCCC')
      .onChange((v: string) => { this.onSearchChange(v); })
  }
  .width('94%')
  .padding(8)
  .backgroundColor('#FFFFFF')
}

五、筛选标签组

5.1 五标签筛选

使用五个 Chip 标签对应全部/已看/想看/在看/收藏:

@Builder filterChip(label: string, status: string) {
  Text(label)
    .fontSize(13)
    .fontColor(this.filterStatus === status ? '#FFFFFF' : '#666666')
    .backgroundColor(this.filterStatus === status ? '#6C63FF' : '#F0F0F0')
    .padding({ left: 10, right: 10, top: 4, bottom: 4 })
    .borderRadius(12)
    .margin({ right: 6 })
    .onClick(() => { this.onFilterClick(status); })
}

使用方式:

Row() {
  this.filterChip('全部', 'all')
  this.filterChip('✅ 已看', 'watched')
  this.filterChip('👀 想看', 'want')
  this.filterChip('▶️ 在看', 'watching')
  this.filterChip('⭐ 收藏', 'fav')
}

5.2 选中状态

状态 筛选值 图标
全部 all
已看 watched
想看 want 👀
在看 watching ▶️
收藏 fav

六、电影卡片

6.1 卡片设计

每部电影使用卡片展示,包含分类图标、片名、状态标签、年份:

@Builder movieCard(item: Movie) {
  Row() {
    // 左侧:分类图标
    Text(getGenreById(item.genreId)?.icon ?? '🎬')
      .fontSize(28).width(46).height(46)
      .textAlign(TextAlign.Center)
      .backgroundColor('#F5F5F5').borderRadius(23)

    // 中间:片名 + 状态 + 年份
    Column() {
      Row() {
        Text(item.title).fontSize(16).fontWeight(FontWeight.Bold)
        if (item.isFavorite) { Text(' ⭐').fontSize(14) }
      }.width('100%')

      Row() {
        // 状态标签(带颜色)
        Text(getStatusLabel(item.status))
          .fontSize(11).fontColor('#FFFFFF')
          .backgroundColor(getStatusColor(item.status))
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(6)
        Text(' ' + item.year)
          .fontSize(12).fontColor('#BBBBBB')
          .margin({ left: 6 })
      }.width('100%').margin({ top: 3 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start)
    .margin({ left: 12 })

    // 右侧箭头
    Text('>').fontSize(16).fontColor('#CCCCCC')
  }
  .width('100%').padding(12)
  .backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ bottom: 6 })
  .onClick(() => {
    router.pushUrl({
      url: 'pages/DetailPage',
      params: { movieId: item.id }
    });
  })
}

6.2 状态颜色编码

状态 颜色 色值
想看 🟠 橙色 #FFA502
在看 🟢 绿色 #2ED573
已看 🟣 紫色 #6C63FF

七、滑动删除

7.1 SwipeAction 实现

ListItem() {
  this.movieCard(item)
}
.swipeAction({
  end: {
    builder: (): void => { this.deleteSwipeBtn(item.id) },
    onAction: (): void => { this.deleteMovie(item.id) }
  }
})

7.2 删除按钮

@Builder deleteSwipeBtn(id: string) {
  Column() {
    Text('删除')
      .fontSize(14)
      .fontColor('#FFFFFF')
      .padding(16)
  }
  .backgroundColor('#FF4757')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.deleteMovie(id);
  })
}

7.3 删除逻辑

deleteMovie(id: string): void {
  let newList: Movie[] = [];
  for (let i = 0; i < this.movies.length; i++) {
    if (this.movies[i].id !== id) {
      newList.push(this.movies[i]);
    }
  }
  this.movies = newList;
  AppStorage.set<Movie[]>('movies', newList);
  this.applyFilter();
}

八、空状态设计

if (this.filteredList.length === 0) {
  Column() {
    Text('🎬').fontSize(48).margin({ bottom: 8 })
    Text('没有找到匹配的电影')
      .fontSize(16).fontColor('#CCCCCC')
  }
  .width('100%').height(200)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

九、ArkTS 注意事项

9.1 可选链操作符

ArkTS 支持 ?. 可选链操作符,可以安全地访问嵌套属性:

// 安全访问
getGenreById(item.genreId)?.icon ?? '🎬'

9.2 滑动删除 API

在 API 23 中,swipeAction 的正确格式是:

.swipeAction({
  end: {
    builder: (): void => { /* 自定义内容 */ },
    onAction: (): void => { /* 触发回调 */ }
  }
})

不能使用 build() 方法或 .bind()

总结

本文完成了电影列表页面的开发:

  • ✅ 卡片式电影列表展示
  • ✅ 五标签状态筛选
  • ✅ 关键词实时搜索(片名/导演/分类)
  • ✅ 滑动删除操作
  • ✅ 空状态友好提示

下一篇,我们将开发电影详情页面,实现评分、收藏和影评功能!


在这里插入图片描述

系列目录

  • ✅ 第一篇:项目搭建与首页概览
  • ✅ 第二篇:添加电影与表单交互
  • ✅ 第三篇:电影列表与搜索筛选(本篇)
  • 📝 第四篇:电影详情与评分评价
  • 📝 第五篇:个人中心与数据统计
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐