在鸿蒙应用开发中,页面是用户与应用交互的主要界面。一个完整的应用通常由多个功能页面组成,这些页面通过导航相互连接,共同构成用户体验。

在"爱影家"APP中,主要实现了首页、电影列表页、电影详情页、搜索页面和视频播放页面等核心页面。

课程地址:https://blog.csdn.net/yyz_1987/article/details/153418477

本次学习几笔将详细介绍鸿蒙应用核心页面的实现方法,包括数据管理、UI布局、用户交互以及页面导航等方面,帮助开发者掌握鸿蒙应用页面开发的完整流程。

二、首页轮播图实现详解

1. 数据源设计

在实现轮播图之前,首先需要设计一个高效的数据源。在鸿蒙开发中,可以实现IDataSource接口来创建自定义数据源。

class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  totalCount(): number {
    return this.originDataArray.length;
  }

  getData(index: number): T {
    return this.originDataArray[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.slice(pos, 1);
    }
  }

  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    })
  }

  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    })
  }
}

2. 实现懒加载数据源

为了提升轮播图的性能,我们可以扩展基础数据源,实现懒加载功能:

class SwiperDataSource<T> extends BasicDataSource<T> {
  private dataArray: T[] = [];

  totalCount(): number {
    return this.dataArray.length;
  }

  getData(index: number): T {
    return this.dataArray[index];
  }

  // 在列表末尾添加数据并通知监听器
  pushData(data: T): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  // 重载数据
  reloadData(): void {
    this.dataArray = [];
    this.notifyDataReload();
  }
}

3. 轮播图组件实现

使用Swiper组件和LazyForEach实现高性能的轮播图:

@Component
struct BannerComponent {
  private dataSource: SwiperDataSource<BannerItem> = new SwiperDataSource<BannerItem>();
  private bannerList: BannerItem[] = [];
  @State currentIndex: number = 0;
  
  async aboutToAppear() {
    // 获取轮播图数据
    try {
      const result = await getBannerList();
      this.bannerList = result.data;
      // 更新数据源
      this.bannerList.forEach(item => {
        this.dataSource.pushData(item);
      });
    } catch (error) {
      console.error('Failed to load banner:', error);
    }
  }
  
  onBannerClick(id: string) {
    // 跳转到电影详情页
    router.push({
      uri: 'pages/detail/DetailPage',
      params: { movieId: id }
    });
  }
  
  build() {
    Column() {
      Swiper({
        index: 0,
        autoPlay: true,
        interval: 3000,
        indicatorStyle: {
          size: 8,
          selectedSize: 8,
          selectedColor: Color.White,
          color: Color.Gray
        }
      })
      .width('100%')
      .height(200)
      .onChange((index: number) => {
        this.currentIndex = index;
      })
      {
        LazyForEach(this.dataSource, (item: BannerItem) => {
          Stack() {
            Image(item.imageUrl)
              .width('100%')
              .height('100%')
              .objectFit(ImageFit.Cover)
            
            // 标题遮罩
            Flex() {
              Text(item.title)
                .fontSize(16)
                .fontColor(Color.White)
                .fontWeight(FontWeight.Bold)
            }
            .position({ bottom: 0 })
            .width('100%')
            .padding(10)
            .backgroundColor('rgba(0, 0, 0, 0.5)')
          }
          .onClick(() => this.onBannerClick(item.movieId))
        }, (item: BannerItem) => item.movieId)
      }
      
      // 自定义指示器(可选)
      Row() {
        ForEach(this.bannerList, (_, index) => {
          Column() {
            Text()
              .width(index === this.currentIndex ? 20 : 8)
              .height(8)
              .backgroundColor(index === this.currentIndex ? Color.White : Color.Gray)
              .borderRadius(4)
          }
          .margin({ left: 5, right: 5 })
        })
      }
      .margin({ top: 10 })
    }
  }
}

三、电影详情页实现

1. 页面布局设计

电影详情页需要展示丰富的信息,包括电影海报、标题、评分、简介、演职员信息等。合理的布局设计对于提升用户体验至关重要。

@Component
struct DetailPage {
  @State detailData: DetailMvResp | null = null;
  private srcData: MvSourceResp | null = null;
  private movieId: string = '';
  
  // 控制简介展开/收起
  @State isExpanded: boolean = false;
  
  async onPageShow() {
    // 获取页面参数
    const params = router.getParams();
    if (params && params.movieId) {
      this.movieId = params.movieId as string;
      await this.loadMovieData();
    }
  }
  
  async loadMovieData() {
    try {
      // 获取电影详情
      const detailResult = await getDetailMv(this.movieId);
      this.detailData = detailResult;
      
      // 获取电影播放源
      const srcResult = await getMovieSrc(this.movieId);
      this.srcData = srcResult;
    } catch (error) {
      console.error('Failed to load movie data:', error);
    }
  }
  
  toggleDescription() {
    this.isExpanded = !this.isExpanded;
  }
  
  navigateToPlayer() {
    if (this.srcData?.sources && this.srcData.sources.length > 0) {
      router.push({
        uri: 'pages/player/PlayerPage',
        params: {
          sources: this.srcData.sources,
          title: this.detailData?.title
        }
      });
    }
  }
  
  build() {
    NavDestination() {
      Scroll() {
        Column() {
          if (this.detailData) {
            // 电影基本信息区域
            Row() {
              Image(this.detailData.images)
                .width(120)
                .borderRadius(5)
              
              Column({ space: 8 }) {
                Text(this.detailData.title)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                
                Text(`${this.detailData.year} · ${this.detailData.genre}`)
                  .fontSize(14)
                  .fontColor(Color.Gray)
                
                // 评分区域
                Row() {
                  Text(`${this.detailData.rating}`)
                    .fontSize(20)
                    .fontColor(Color.Red)
                    .fontWeight(FontWeight.Bold)
                  
                  Rating({
                    rating: this.detailData.rating / 2,
                    maxRating: 5,
                    numStars: 5,
                    indicator: true
                  })
                }
                
                // 想看/看过统计
                Row() {
                  Badge({
                    count: this.detailData.wish_count,
                    maxCount: 10000,
                    position: BadgePosition.RightTop,
                    style: { badgeSize: 22, badgeColor: '#fffab52a' }
                  }) {
                    Row() {
                      Text() {
                        SymbolSpan($r('sys.symbol.heart'))
                          .fontWeight(FontWeight.Lighter)
                          .fontSize(32)
                          .fontColor(['#fffab52a'])
                      }
                      Text('想看')
                    }
                    .backgroundColor('#f8f4f5')
                    .borderRadius(5)
                    .padding(5)
                  }
                  .padding(8)
                }
              }
              .flexGrow(1)
              .marginLeft(10)
            }
            .padding(15)
            
            // 操作按钮
            Button('播放', { type: ButtonType.Capsule })
              .onClick(() => this.navigateToPlayer())
              .backgroundColor(Color.Red)
              .fontColor(Color.White)
              .padding({ left: 40, right: 40, top: 10, bottom: 10 })
              .margin({ top: 10, bottom: 20 })
            
            // 电影简介
            Column() {
              Text('剧情简介')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .margin({ bottom: 10 })
              
              Text(this.detailData.summary)
                .fontSize(14)
                .lineHeight(22)
                .maxLines(this.isExpanded ? undefined : 3)
              
              Text(this.isExpanded ? '收起' : '展开')
                .fontSize(14)
                .fontColor(Color.Blue)
                .margin({ top: 5 })
                .onClick(() => this.toggleDescription())
            }
            .padding(15)
            .backgroundColor(Color.White)
            .marginBottom(10)
            
            // 演职员信息
            Column() {
              Text('演职员')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .margin({ bottom: 10 })
              
              if (this.detailData.casts && this.detailData.casts.length > 0) {
                List() {
                  ForEach(this.detailData.casts, (cast: DetailMvRespCast) => {
                    ListItem() {
                      Column() {
                        Image(cast.avatar)
                          .width(80)
                          .height(100)
                          .borderRadius(5)
                        Text(cast.name)
                          .fontSize(14)
                          .margin({ top: 5 })
                        Text(cast.role)
                          .fontSize(12)
                          .fontColor(Color.Gray)
                      }
                      .padding(5)
                    }
                  }, (cast: DetailMvRespCast) => cast.id)
                }
                .listDirection(Axis.Horizontal)
                .showsHorizontalScrollbar(false)
              }
            }
            .padding(15)
            .backgroundColor(Color.White)
          } else {
            // 加载状态
            LoadingProgress()
              .height(50)
              .color(Color.Blue)
          }
        }
      }
    }
    .title('电影详情')
  }
}

2. 组件复用与封装

在实现详情页时,我们可以将一些可复用的UI元素封装成独立的组件,提高代码的可维护性。

// 评分组件
@Component
struct RatingBar {
  private score: number;
  
  constructor(score: number) {
    this.score = score;
  }
  
  build() {
    Row() {
      Text(`${this.score}`)
        .fontSize(20)
        .fontColor(Color.Red)
        .fontWeight(FontWeight.Bold)
      
      Rating({
        rating: this.score / 2,
        maxRating: 5,
        numStars: 5,
        indicator: true
      })
    }
  }
}

// 演员卡片组件
@Component
struct CastCard {
  private cast: DetailMvRespCast;
  
  constructor(cast: DetailMvRespCast) {
    this.cast = cast;
  }
  
  build() {
    Column() {
      Image(this.cast.avatar)
        .width(80)
        .height(100)
        .borderRadius(5)
      Text(this.cast.name)
        .fontSize(14)
        .margin({ top: 5 })
      Text(this.cast.role)
        .fontSize(12)
        .fontColor(Color.Gray)
    }
    .padding(5)
  }
}

四、搜索页面实现

1. 搜索功能实现

搜索页面是用户查找特定电影的重要入口。我们需要实现实时搜索、搜索历史记录、热门搜索等功能。

@Component
struct SearchPage {
  @State keyword: string = '';
  @State searchResults: MovieSearchItem[] = [];
  @State isSearching: boolean = false;
  @State historyKeywords: string[] = [];
  @State hotKeywords: string[] = ['热门', '最新', '科幻', '动作', '喜剧'];
  
  // 防抖的搜索函数
  private debouncedSearch = debounce(async (text: string) => {
    if (text.trim().length > 0) {
      await this.performSearch(text);
    } else {
      this.searchResults = [];
    }
  }, 500);
  
  onPageShow() {
    // 加载搜索历史
    this.loadSearchHistory();
  }
  
  async performSearch(text: string) {
    this.isSearching = true;
    try {
      const result = await searchMovies(text);
      this.searchResults = result.data;
      
      // 保存到搜索历史
      this.saveToHistory(text);
    } catch (error) {
      console.error('Search failed:', error);
    } finally {
      this.isSearching = false;
    }
  }
  
  saveToHistory(keyword: string) {
    // 去重
    const index = this.historyKeywords.indexOf(keyword);
    if (index > -1) {
      this.historyKeywords.splice(index, 1);
    }
    
    // 添加到开头
    this.historyKeywords.unshift(keyword);
    
    // 最多保存10条
    if (this.historyKeywords.length > 10) {
      this.historyKeywords.pop();
    }
    
    // 保存到存储
    // TODO: 实现存储功能
  }
  
  loadSearchHistory() {
    // TODO: 从存储加载历史记录
  }
  
  clearHistory() {
    this.historyKeywords = [];
    // TODO: 清空存储
  }
  
  onKeywordChange(event: TextInputChangeEvent) {
    this.keyword = event.text;
    this.debouncedSearch(this.keyword);
  }
  
  onSearchSubmit() {
    if (this.keyword.trim().length > 0) {
      this.performSearch(this.keyword);
    }
  }
  
  navigateToDetail(id: string) {
    router.push({
      uri: 'pages/detail/DetailPage',
      params: { movieId: id }
    });
  }
  
  build() {
    NavDestination() {
      Column() {
        // 搜索框
        Row() {
          Search({
            value: this.keyword,
            placeholder: '搜索电影',
            onSubmit: () => this.onSearchSubmit(),
            onChange: (value: string) => this.keyword = value
          })
          .backgroundColor('#f0f0f0')
          .textFont({ size: 16 })
          .height(40)
          .width('85%')
          
          Button('搜索')
            .onClick(() => this.onSearchSubmit())
            .marginLeft(10)
        }
        .padding(10)
        
        // 内容区域
        if (this.keyword.trim().length === 0) {
          // 搜索前显示历史和热门
          Column() {
            // 搜索历史
            if (this.historyKeywords.length > 0) {
              Row() {
                Text('搜索历史')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                
                Text('清空')
                  .fontSize(14)
                  .fontColor(Color.Gray)
                  .onClick(() => this.clearHistory())
              }
              .justifyContent(FlexAlign.SpaceBetween)
              .padding(10)
              
              FlowLayout() {
                ForEach(this.historyKeywords, (item: string) => {
                  Text(item)
                    .fontSize(14)
                    .padding({ left: 15, right: 15, top: 5, bottom: 5 })
                    .backgroundColor('#f0f0f0')
                    .borderRadius(15)
                    .margin(5)
                    .onClick(() => {
                      this.keyword = item;
                      this.performSearch(item);
                    })
                }, (item: string) => item)
              }
            }
            
            // 热门搜索
            Text('热门搜索')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .padding(10)
              .alignSelf(ItemAlign.Start)
            
            FlowLayout() {
              ForEach(this.hotKeywords, (item: string) => {
                Text(item)
                  .fontSize(14)
                  .padding({ left: 15, right: 15, top: 5, bottom: 5 })
                  .backgroundColor('#f0f0f0')
                  .borderRadius(15)
                  .margin(5)
                  .onClick(() => {
                    this.keyword = item;
                    this.performSearch(item);
                  })
              }, (item: string) => item)
            }
          }
        } else if (this.isSearching) {
          // 搜索中
          LoadingProgress()
            .height(50)
            .color(Color.Blue)
        } else {
          // 搜索结果
          if (this.searchResults.length > 0) {
            List() {
              ForEach(this.searchResults, (item: MovieSearchItem) => {
                ListItem() {
                  Row() {
                    Image(item.posterUrl)
                      .width(80)
                      .height(120)
                      .borderRadius(5)
                    
                    Column({ space: 8 })
                      .marginLeft(10)
                      .flexGrow(1)
                    {
                      Text(item.title)
                        .fontSize(16)
                        .fontWeight(FontWeight.Bold)
                      
                      Text(`${item.year} · ${item.genre}`)
                        .fontSize(14)
                        .fontColor(Color.Gray)
                      
                      Rating({
                        rating: item.rating / 2,
                        maxRating: 5,
                        numStars: 5,
                        indicator: true
                      })
                    }
                  }
                  .padding(15)
                  .onClick(() => this.navigateToDetail(item.id))
                }
              }, (item: MovieSearchItem) => item.id)
            }
            .divider({ strokeWidth: 1, color: '#f0f0f0' })
          } else {
            Text('没有找到相关电影')
              .fontSize(16)
              .fontColor(Color.Gray)
              .marginTop(50)
          }
        }
      }
    }
    .title('搜索')
  }
}

2. 防抖函数实现

为了避免频繁搜索请求,我们可以实现一个防抖函数:

function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: number | null = null;
  
  return function(...args: Parameters<T>) {
    if (timeout !== null) {
      clearTimeout(timeout);
    }
    
    timeout = setTimeout(() => {
      func.apply(this, args);
    }, wait);
  };
}

五、视频播放页面实现

1. 视频播放核心功能

视频播放是影视APP的核心功能,我们需要实现视频播放、暂停、全屏切换、剧集选择等功能。

@Component
struct PlayerPage {
  @State isPlaying: boolean = false;
  @State isFullScreen: boolean = false;
  @State currentSourceIndex: number = 0;
  private sources: string[] = [];
  private title: string = '';
  private videoController: VideoController = new VideoController();
  
  onPageShow() {
    // 获取页面参数
    const params = router.getParams();
    if (params) {
      this.sources = params.sources as string[] || [];
      this.title = params.title as string || '视频播放';
    }
    
    // 自动播放第一个视频
    if (this.sources.length > 0) {
      this.videoController.play();
      this.isPlaying = true;
    }
  }
  
  togglePlay() {
    if (this.isPlaying) {
      this.videoController.pause();
    } else {
      this.videoController.play();
    }
    this.isPlaying = !this.isPlaying;
  }
  
  toggleFullScreen() {
    // 切换全屏模式
    this.isFullScreen = !this.isFullScreen;
    // TODO: 实现屏幕方向切换
  }
  
  onChangeSource(index: number) {
    this.currentSourceIndex = index;
    // 重置播放状态
    this.videoController.start();
    this.isPlaying = true;
  }
  
  build() {
    NavDestination() {
      Column() {
        if (this.sources.length > 0) {
          // 视频播放区域
          Stack() {
            Video({
              src: this.sources[this.currentSourceIndex],
              currentProgressRate: PlaybackSpeed.Speed_Forward_1_00_X,
              controller: this.videoController
            })
            .width('100%')
            .height(this.isFullScreen ? '100%' : 300)
            .autoPlay(true)
            .onStart(() => {
              console.log('Video started');
              this.isPlaying = true;
            })
            .onPause(() => {
              console.log('Video paused');
              this.isPlaying = false;
            })
            .onFinish(() => {
              console.log('Video finished');
              this.isPlaying = false;
            })
            
            // 自定义播放控制
            Row() {
              Button() {
                Image(this.isPlaying ? '/assets/pause.png' : '/assets/play.png')
                  .width(24)
                  .height(24)
              }
              .onClick(() => this.togglePlay())
              .backgroundColor('rgba(0, 0, 0, 0.5)')
              .borderRadius(20)
              .width(40)
              .height(40)
              
              Button() {
                Image('/assets/fullscreen.png')
                  .width(24)
                  .height(24)
              }
              .onClick(() => this.toggleFullScreen())
              .backgroundColor('rgba(0, 0, 0, 0.5)')
              .borderRadius(20)
              .width(40)
              .height(40)
              .marginLeft(10)
            }
            .position({ bottom: 20, right: 20 })
          }
          
          // 剧集选择
          if (this.sources.length > 1) {
            Column() {
              Text('选择剧集')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .margin({ bottom: 10 })
              
              FlowLayout() {
                ForEach(this.sources, (_, index: number) => {
                  Text(`第${index + 1}集`)
                    .fontSize(14)
                    .padding({ left: 15, right: 15, top: 5, bottom: 5 })
                    .backgroundColor(index === this.currentSourceIndex ? Color.Red : '#f0f0f0')
                    .fontColor(index === this.currentSourceIndex ? Color.White : Color.Black)
                    .borderRadius(15)
                    .margin(5)
                    .onClick(() => this.onChangeSource(index))
                }, (_, index: number) => index.toString())
              }
            }
            .padding(15)
          }
        } else {
          Text('暂无播放源')
            .fontSize(16)
            .fontColor(Color.Gray)
            .marginTop(50)
        }
      }
    }
    .title(this.title)
  }
}

2. 全屏播放实现

全屏播放是视频播放器的重要功能,我们可以使用鸿蒙的窗口管理API来实现:

import { window } from '@kit.ArkUI';

// 在toggleFullScreen方法中添加
async function toggleFullScreen() {
  const windowClass = await window.getLastWindow(getContext(this));
  
  if (this.isFullScreen) {
    // 退出全屏
    await windowClass.setWindowMode(window.WindowMode.WINDOW_MODE_UNDEFINED);
    await windowClass.setPreferredOrientation(window.Orientation.PORTRAIT);
  } else {
    // 进入全屏
    await windowClass.setWindowMode(window.WindowMode.FULLSCREEN);
    await windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE);
  }
  
  this.isFullScreen = !this.isFullScreen;
}

六、页面导航与参数传递

1. 基本导航功能

鸿蒙提供了router模块用于页面导航:

import router from '@ohos.router';

// 跳转到详情页
router.push({
  uri: 'pages/detail/DetailPage',
  params: {
    movieId: '1292052',
    title: '肖申克的救赎'
  }
});

// 返回上一页
router.back();

// 替换当前页面(不保留历史记录)
router.replace({
  uri: 'pages/login/LoginPage'
});

2. 参数接收

在目标页面中,可以通过router.getParams()方法获取传递的参数:

onPageShow() {
  const params = router.getParams();
  if (params) {
    this.movieId = params.movieId as string;
    this.title = params.title as string || '';
    // 使用参数加载数据
    this.loadMovieData();
  }
}

3. 导航动画

鸿蒙支持自定义导航动画,提升用户体验:

router.push({
  uri: 'pages/detail/DetailPage',
  params: { movieId: '1292052' },
  animation: {
    type: router.AnimationType.Slide,
    duration: 300,
    curve: router.AnimationCurve.EaseOut
  }
});

七、总结

鸿蒙应用页面开发是一个综合性的工作,涉及UI布局、数据处理、用户交互等多个方面。通过本文的学习,了解了鸿蒙应用核心页面的实现方法,包括首页轮播图、电影详情页、搜索页面和视频播放页面等。

在实际开发中,需要注重以下几点:

  1. 性能优化:使用懒加载、缓存等技术提升页面性能
  2. 用户体验:合理的布局设计、流畅的动画效果、清晰的交互反馈
  3. 代码复用:将可复用的UI元素封装成组件,提高代码的可维护性
  4. 错误处理:完善的错误处理机制,确保应用的稳定性

通过不断的实践和总结,我们能够更好地掌握鸿蒙应用页面开发的技巧,构建出高质量的鸿蒙应用。

Logo

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

更多推荐