鸿蒙开发next【长视频】一多开发实例

一多开发实例(长视频)概述

本文从目前流行的垂类市场中,选择长视频行业应用作为典型案例详细介绍[“一多”]在实际开发中的应用。长视频行业应用的核心功能为沉浸式的视频播放和互动,主要包含首页推荐、视频搜索、视频详情、视频评论、全屏播放等。根据这些核心功能,本文选择[首页]、[搜索页]、[视频详情页]和[全屏播放页]作为典型页面进行开发,遵从多设备的“差异性”、“一致性”、“灵活性”和“兼容性”,能够让开发者快速高效地掌握“一多”能力并实现长视频应用的相关功能。

当前系统的产品形态主要有手机、折叠屏、平板和2in1四种,下文的具体实践也将围绕这几种产品形态展开,同时将分别从UX设计、工程管理、页面开发和功能开发四个角度给出符合“一多”的参考样例,介绍“一多”长视频应用在开发过程中的最佳实践。

UX设计

影音娱乐类的多设备响应式设计指南。

工程管理

本章将介绍如何创建“一多”工程及划分目录结构。

创建工程

根据三层架构创建系统工程,先创建出最基本的项目工程,再在基本目录结构的基础上进行修改。

工程结构

开发者在创建“一多”的工程时,会遇到如何划分工程结构目录的问题。考虑到工程的复用性和可维护性,本文以长视频应用为例给出推荐的参考方案。

HarmonyOS的分层架构主要包括三个层次:产品定制层、基础特性层和公共能力层,为开发者构建了一个清晰、高效、可扩展的设计架构

长视频应用根据一多推荐的commons、features、products的”三层工程架构“划分目录。其中四个页面功能不同,互不依赖,根据页面划分为四个features(基础特性层):首页-home、视频搜索页-search、视频详情页-videoDetail和全屏播放页-videoPlayer。公共常量、媒体播放工具以及窗口管理工具等需要被不同页面依赖引用的内容,划分为一个commons(公共能力层):基础能力-base。其中features层不同页面的功能相对独立、互不影响,推荐创建HAR包;commons层存放公共能力类,被features层和products层依赖,推荐创建HAR包。

工程结构如下:

├──commons                                    // 公共能力层
│  ├──base/src/main/ets                       // 基础能力
│  │  ├──constants
│  │  └──utils
│  └──base/src/Index.ets                      // 对外接口类
├──features                                   // 基础特性层
│  ├──home/src/main/ets                       // 首页
│  │  ├──constants
│  │  ├──utils
│  │  ├──view
│  │  └──viewmodel
│  ├──home/src/main/resources                 // 资源文件目录
│  ├──home/src/Index.ets                      // 对外接口类
│  ├──search/src/main/ets                     // 搜索页
│  │  ├──constants
│  │  ├──view
│  │  └──viewmodel
│  ├──search/src/main/resources               // 资源文件目录
│  ├──search/src/Index.ets                    // 对外接口类
│  ├──videoDetail/src/main/ets                // 视频详情页
│  │  ├──constants
│  │  ├──utils
│  │  ├──view
│  │  └──viewmodel
│  ├──videoDetail/src/main/resources          // 资源文件目录
│  ├──videoPlayer/src/main/ets                // 全屏播放页
│  │  ├──constants
│  │  └──view
│  └──videoPlayer/src/main/resources          // 资源文件目录
└──products                                   // 产品定制层
   ├──phone/src/main/ets                      // 支持手机、折叠屏、平板、2in1
   │  ├──entryability
   │  └──pages
   └──phone/src/main/resources                // 资源文件目录

页面开发

本章介绍长视频应用中如何使用“一多”的布局能力,完成页面层级的一套页面、多端适配。同时介绍长视频应用中的[交互开发]和推荐的[资源使用]方式。

首页

长视频应用首页主要发挥推荐精选视频的作用,解决用户想要看视频的核心需求,所以首页内容都围绕这一功能设计。观察首页在2in1上的UX设计图,可以进行如下设计(图中为包括可滑动区域的内容):

  • 将应用首页划分为8个区域,效果图如下:

1

  • 整个页面响应式适配,借助栅格组件能力监听不同断点变化实现不同的布局效果。

  • 首页区域2在小设备上呈两行显示,在中设备和大设备上单行显示,断点变化时切换显示效果。

  • 首页区域3、4使用自适应布局延伸能力随不同设备尺寸延伸或隐藏。

  • 首页区域1,5-8使用响应式布局中的栅格断点系统,根据断点变化切换改变组件内相应的属性实现布局效果。

长视频应用搜索页的8个基础区域介绍及实现方案如下表所示:

区域编号简介实现方案
1[底部/侧边页签]借助[栅格布局]监听断点变化改变位置。
2[顶部页签及搜索框]栅格布局监听断点变化实现折行显示,[List组件]实现延伸能力,layoutWeight实现拉伸能力。
3[Banner图][Swiper组件],指定displayCount属性实现延伸能力,设置aspectRatio属性实现缩放能力。
4图标列表Swiper组件,指定displayCount属性实现自适应布局延伸能力,设置aspectRatio属性实现缩放能力。
5[推荐视频][网格容器],借助栅格组件能力监听断点变化改变列数,设置aspectRatio属性实现缩放能力。
6新片发布网格容器,借助栅格组件能力监听断点变化改变列数,设置aspectRatio属性实现缩放能力。
7[每日佳片]利用响应式布局的栅格布局,结合[Stack组件]和[Grid组件],设置aspectRatio属性实现缩放能力。
8往期回顾响应式布局的栅格布局,设置aspectRatio属性实现缩放能力。

在实际开发中,区域1为外层导航栏,区域2为内层导航栏,区域3-8为并列的首页内容,所以对应的开发顺序为区域1、区域2和区域3-8。另外,为了提升用户的使用体验,首页设计了额外的功能,包括[首页社区页签的沉浸式设计],[2in1首页Banner图的排版创新],[首页推荐视频区域长按预览],[首页推荐视频区域的缩放]。

  • 底部/侧边页签

    底部/侧边页签区域,使用Tabs组件,设置在不同断点下的vertical属性,实现显示在首页的不同位置。在sm和md断点下,页签显示在底部,高度为56vp;在lg断点下页签显示在左侧,宽度为96vp,且页签居中显示。

    示意图如下:

2

// features/home/src/main/ets/view/Home.ets
// 底部/侧边页签区域
Tabs({
  // lg断点时,页签栏在侧边;sm、md断点时,页签栏在底部
  barPosition: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? BarPosition.Start : BarPosition.End
}) {
  // ...
}
// 底部页签大小的变换
.barWidth(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? $r('app.float.bottom_tab_bar_width_lg') :
  CommonConstants.FULL_PERCENT)
.barHeight(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? CommonConstants.FULL_PERCENT :
  (deviceInfo.deviceType === CommonConstants.DEVICE_TYPES[0] ? $r('app.float.tab_size_lg') :
  $r('app.float.tab_size')))
// 设置不同断点下页签的布局模式
.barMode(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? BarMode.Scrollable : BarMode.Fixed,
  { nonScrollableLayoutStyle: LayoutStyle.ALWAYS_CENTER })
// lg断点时为纵向Tabs,sm、md断点时为横向Tabs
.vertical(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG)
  • 顶部页签及搜索框

    不同断点下,顶部页签和搜索框占用不同栅格列数,使用栅格布局实现在sm断点下分两行显示,在md和lg断点下单行显示。根据设计将栅格在sm、md和lg的断点上分别划分为4列、12列、12列。示意图如下:
    3


// features/home/src/main/ets/view/HomeHeader.ets
// 顶部页签及搜索框
build() {
  GridRow({
    columns: {
      // 栅格数4、12、12列
      sm: CommonConstants.GRID_ROW_COLUMNS[2],
      md: CommonConstants.GRID_ROW_COLUMNS[0],
      lg: CommonConstants.GRID_ROW_COLUMNS[0]
    }
  }) {
    GridCol({
      span: {
        // 顶部页签占用4、7、7列
        sm: CommonConstants.GRID_COLUMN_SPANS[5],
        md: CommonConstants.GRID_COLUMN_SPANS[2],
        lg: CommonConstants.GRID_COLUMN_SPANS[2]
      }
    }) {
      this.TopTabBar()
    }

    GridCol({
      span: {
        // 搜索框占用4、5、5列
        sm: CommonConstants.GRID_COLUMN_SPANS[5],
        md: CommonConstants.GRID_COLUMN_SPANS[3],
        lg: CommonConstants.GRID_COLUMN_SPANS[3]
      }
    }) {
      this.searchBar()
    }
  }
}

随着设备宽度变大,顶部页签间距变大、页面能够展示更多页签内容,使用List组件实现延伸能力;同时使用layoutWeight将增加的空间全部分配给搜索框,实现拉伸能力。

// features/home/src/main/ets/view/HomeHeader.ets
// 顶部页签
@Builder
TopTabBar() {
  Row() {
    Column() {
      List({
        // 随着断点变大,页签间距变大
        space: new BreakpointType(HomeConstants.SEARCH_TAB_LIST_SPACES[0], HomeConstants.SEARCH_TAB_LIST_SPACES[1],
          HomeConstants.SEARCH_TAB_LIST_SPACES[2]).getValue(this.currentBreakpoint)
      }) {
        ...
      }
    }
  }
}

// 搜索框
@Builder
searchBar() {
  Row() {
    Stack({ alignContent: Alignment.Start }) {
      // ...
    }
    .alignSelf(ItemAlign.Center)
    // 增加的空间全部分配给搜索框
    .layoutWeight(1)
  }
}
  • Banner图

    Banner图和图标列表区域,均使用Swiper组件,设置在不同断点下的displayCount属性来实现自适应布局的延伸能力,本章节以Banner图区域作为示例,图标列表的实现读者可以自行查看代码。Banner图区域中,Banner展示数量在sm断点下为1,并显示导航点指示器;在md和lg断点下Banner为2,且前后边距展示前后两张Banner图的部分内容。

    在“一多”的应用中,经常会出现窗口大小改变如果组件随着窗口宽度变化只改变宽度、不改变高度,会导致图片变形,视觉上会给用户带来较差体验。为解决这一痛点,需要给Stack组件设置aspectRatio属性,Stack的高度会跟随宽度变化相应等比发生变化,Banner图大小变化且宽高比保持不变,实现自适应布局的缩放能力。

    示意图如下:

4

// features/home/src/main/ets/view/BannerView.ets
// Banner图区域
Swiper() {
  LazyForEach(this.bannerDataSource, (item: Banner, index: number) => {
    Column() {
      Stack() {
        // ...
      }
      .height(item.getBannerImg().getHeight().getValue(this.currentBreakpoint))
      .width(CommonConstants.FULL_PERCENT)
      // 宽高按照预设的比例,随容器组件发生变化且宽高比不变
      .aspectRatio(new BreakpointType(HomeConstants.BANNER_RATIOS[0], HomeConstants.BANNER_RATIOS[1],
        HomeConstants.BANNER_RATIOS[2]).getValue(this.currentBreakpoint))
    }
  }, (item: Banner, index: number) => index + JSON.stringify(item))
}
// ...
.index(2)
// 设置不同断点下的Banner展示数量,实现自适应布局的延伸能力
.displayCount(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? 1 : HomeConstants.TWO)
.itemSpace(HomeConstants.SWIPER_ITEM_SPACE)
// 设置是否显示导航点指示器
.indicator(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? Indicator.dot()
  .itemWidth($r('app.float.swiper_item_size'))
  .itemHeight($r('app.float.swiper_item_size'))
  .selectedItemWidth($r('app.float.swiper_selected_item_width'))
  .selectedItemHeight($r('app.float.swiper_item_size'))
  .color($r('app.color.swiper_indicator'))
  .selectedColor(Color.White) : false
)
.loop(true)
.width(CommonConstants.FULL_PERCENT)
.visibility((this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) && (this.currentTopIndex === 1) ?
  Visibility.None : Visibility.Visible)
.effectMode(EdgeEffect.None)
// md与lg设备上前后露出两张Banner图的大小不同
.prevMargin(new BreakpointType($r('app.float.swiper_prev_next_margin_sm'),
  $r('app.float.swiper_prev_next_margin_md'), $r('app.float.swiper_prev_next_margin_lg'))
  .getValue(this.currentBreakpoint))
.nextMargin(new BreakpointType($r('app.float.swiper_prev_next_margin_sm'),
  $r('app.float.swiper_prev_next_margin_md'), $r('app.float.swiper_prev_next_margin_lg'))
  .getValue(this.currentBreakpoint))
  • 推荐视频

    视频推荐和新片发布区域,均使用网格布局Grid组件,在不同断点下将父组件分为不同列数,来实现自适应布局的占比能力,本章节以推荐视频区域作为示例,新片发布区域的实现读者可以自行查看代码。

    视频推荐区域中,网格布局在sm断点下分2列,md断点下分3列,lg断点下分4列。示意图如下:

5

// products/phone/src/main/ets/entryability/EntryAbility.ets
private updateBreakpoint(windowWidth: number) :void{
  let windowWidthVp = windowWidth / display.getDefaultDisplaySync().densityPixels;
  let curBp: string = '';
  let videoGridColumn: string = CommonConstants.VIDEO_GRID_COLUMNS[0];
  // 设置不同断点下视频推荐区域的网格布局列数
  if (windowWidthVp < BreakpointConstants.BREAKPOINT_RANGES[1]) {
    curBp = BreakpointConstants.BREAKPOINT_SM;
    videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[0];
  } else if (windowWidthVp < BreakpointConstants.BREAKPOINT_RANGES[2]) {
    curBp = BreakpointConstants.BREAKPOINT_MD;
    videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[1];
  } else {
    curBp = BreakpointConstants.BREAKPOINT_LG;
    videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[2];
  }
  AppStorage.setOrCreate('currentBreakpoint', curBp);
  AppStorage.setOrCreate('videoGridColumn', videoGridColumn);
}

为实现图片大小等比变化,需要给Stack组件设置aspectRatio属性,同[Banner图区域],实现自适应布局的缩放能力。不同的是,因为Grid组件设置了rowsTemplate属性,子组件GridItem均分Grid组件的全部高度,所以Grid组件不能自适应为内容组件的高度,需要用getGridHeight方法先自行计算出Grid组件的高度,从而保证子组件中图片等比放大或缩小。在getGridHeight方法中,先根据窗口宽度和网格列数,计算出单张图片宽度;再根据图片宽度和宽高比计算出图片高度,并与标题和内容栏高度相加;最后乘网格行数得到Grid组件的总高度。

// features/home/src/main/ets/view/RecommendedVideo.ets
build() {
  Column() {
    // 推荐视频区域
    Grid() {
      ForEach(this.videoImgList, (item: VideoImage, index: number) => {
        GridItem() {
          Column() {
            Stack({ alignContent: Alignment.Bottom}) {
              ...
            }
            .width(CommonConstants.FULL_PERCENT)
            // 宽高按照预设的比例,随容器组件发生变化且宽高比不变
            .aspectRatio(HomeConstants.VIDEO_DIALOG_ASPECT_RATIO)
            ...
            VideoTitle({ currentTopIndex: this.currentTopIndex, title: item.getTitle() })
            VideoContent({ currentTopIndex: this.currentTopIndex, content: item.getContent() })
          }
          .alignItems(HorizontalAlign.Start)
        }
      }, (item: VideoImage, index: number) => index + JSON.stringify(item))
    }
    // 设置网格布局列数,均分宽度
    .columnsTemplate(this.videoGridColumn)
    .rowsTemplate(CommonConstants.VIDEO_GRID_COLUMNS[0])
    .columnsGap($r('app.float.video_grid_gap'))
    // 根据不同断点下列数动态计算总高度,保持图片宽高比不变
    .height(this.getGridHeight(this.videoGridColumn, this.currentBreakpoint, this.windowWidth))
    ...
  }
}

// 计算网格布局总高度
getGridHeight(videoGridColumn: string, currentBreakpoint: string, windowWidth: number): string {
  // 获取窗口宽度,减掉两侧空白部分
  let result: number = px2vp(windowWidth) - new BreakpointType(HomeConstants.VIDEO_GRID_MARGIN[0],
    HomeConstants.VIDEO_GRID_MARGIN[1], HomeConstants.VIDEO_GRID_MARGIN[2]).getValue(this.currentBreakpoint);
  if (currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) {
    result = result - HomeConstants.LG_SIDEBAR_WIDTH;
  }
  // 根据网格列数计算单张图片宽度
  if (videoGridColumn === CommonConstants.VIDEO_GRID_COLUMNS[0]) {
    result = (result - HomeConstants.VIDEO_GRID_ITEM_SPACE * 1) / HomeConstants.TWO;
  } else if (videoGridColumn === CommonConstants.VIDEO_GRID_COLUMNS[1]) {
    result = (result - HomeConstants.VIDEO_GRID_ITEM_SPACE * 2) / CommonConstants.THREE;
  } else if (videoGridColumn === CommonConstants.VIDEO_GRID_COLUMNS[2]) {
    result = (result - HomeConstants.VIDEO_GRID_ITEM_SPACE * 3) / CommonConstants.FOUR;
  } else {
    result = (result - HomeConstants.VIDEO_GRID_ITEM_SPACE * 4) / HomeConstants.FIVE;
  }
  // 计算单张图片高度及标题、内容高度,相加计算网格布局总高度
  return result / HomeConstants.VIDEO_DIALOG_ASPECT_RATIO * HomeConstants.TWO +
    HomeConstants.VIDEO_GRID_DESCRIPTION_HEIGHT + HomeConstants.HEIGHT_UNIT;
}
  • 每日佳片

    每日佳片和往期回顾区域,均使用挪移布局实现“上下布局”与“左右布局”间的切换。本章节以每日佳片区域作为示例,往期回顾区域的实现读者可以自行查看代码。

    每日佳片区域中,使用GirdRow组件和GridCol组件设置主图部分和子图部分在sm、md和lg断点下的栅格列数,使用BreakpointType设置不同断点下的高度。子图部分中,使用Grid网格布局,通过2行+2列的布局均分给4张子图。为实现图片大小等比变化,需要给Stack组件设置aspectRatio属性,同[Banner图区域],实现自适应布局的缩放能力。Grid组件的高度计算,getDailyVideoHeight方法同[推荐视频区域]getGridHeight方法。

    示意图如下:

6

// features/home/src/main/ets/view/DailyVideo.ets
// 每日佳片区域
Column() {
  SubtitleComponent({ title: HomeConstants.HOME_SUB_TITLES[1] })
  GridRow({
    columns: {
      // 栅格数4、12、12列
      sm: BreakpointConstants.GRID_ROW_COLUMNS[2],
      md: BreakpointConstants.GRID_ROW_COLUMNS[0],
      lg: BreakpointConstants.GRID_ROW_COLUMNS[0]
    },
    gutter: $r('app.float.grid_row_gutter')
  }) {
    // 主图部分
    GridCol({
      span: {
        // 主图占用4、6、6列
        sm: BreakpointConstants.GRID_COLUMN_SPANS[5],
        md: BreakpointConstants.GRID_COLUMN_SPANS[1],
        lg: BreakpointConstants.GRID_COLUMN_SPANS[1]
      }
    }) {
      Column() {
        ...
      }
      // 动态设置不同断点下的高度
      .height(this.getDailyVideoHeight(this.currentBreakpoint, this.windowWidth, true))
    }
    // 子图部分
    GridCol({
      span: {
        // 子图占用4、6、6列
        sm: BreakpointConstants.GRID_COLUMN_SPANS[5],
        md: BreakpointConstants.GRID_COLUMN_SPANS[1],
        lg: BreakpointConstants.GRID_COLUMN_SPANS[1]
      }
    }) {
      Grid() {
        ForEach(this.dailyVideoImgList, (item: VideoImage) => {
          GridItem() {
            Column() {
              Stack({ alignContent: Alignment.Bottom }) {
                VideoImgComponent({ imgSrc: item.getImgSrc() })
                VideoImgPlay()
                VideoImgRating({ rating: item.getRating() })
              }
              // 宽高按照预设的比例,随容器组件发生变化且宽高比不变
              .aspectRatio(HomeConstants.VIDEO_DIALOG_ASPECT_RATIO)
              ...
            }
            .alignItems(HorizontalAlign.Start)
          }
        }, (item: VideoImage, index: number) => index + JSON.stringify(item))
      }
      ...
      // 根据不同断点下列数动态计算总高度,保持图片宽高比不变
      .height(this.getDailyVideoHeight(this.currentBreakpoint, this.windowWidth, false))
      // 设置网格布局列数,均分高度
      .columnsTemplate(CommonConstants.VIDEO_GRID_COLUMNS[0])
      .rowsTemplate(CommonConstants.VIDEO_GRID_COLUMNS[0])
    }
  }
}
  • 首页社区页签的沉浸式设计

    沉浸式的视频播放和互动作为视频类应用的核心,沉浸式首页的设计在长视频应用中必不可少。为了给用户带了沉浸感强的体验,沉浸式界面往往会发生特别的变化,在页面中使用大型、高分辨率的背景图片,从而创造具有视觉冲击力的效果,同时文字、图标、按钮和背景的颜色也相应的改变。为解决这一难题,可以在项目中分别为不同设备重构一套代码用于展示沉浸式的页面。

    本章针对长视频应用的沉浸式首页的主要特点给出推荐的解决方案:

    主要特点解决方案
    Banner图覆盖到侧边页签和顶部页签栏将Banner图设置为backgroundImage,并使用Row组件显示文字占位,侧边/底部页签和顶部页签栏使用[Tabs组件]和[Stack组件]控制层级,并根据设计将背景色设置为透明。
    背景色与Banner图底色保持统一色调在backgroundImage处设置backgroundColor属性为统一色调的背景色。
    下滑过程中顶部页签栏背景色更改为统一色调在[Scroll组件]的onScoll方法中获取当前y轴滑动偏移量,根据固定偏移量修改顶部页签栏的backgroundColor属性为统一色调的背景色。
    文字和图标颜色与背景色为对比色相关文字和图标设置颜色时增加条件判断。

    效果图如下:

7

// features/home/src/main/ets/view/Home.ets
TabContent() {
  if (this.currentTopIndex === 2) {
    // 沉浸式设计,顶部页签切换为“社区”时展示
    Stack() {
      Scroll(this.sideScroller) {
        Column() {
          HomeContent()
            .visibility(!this.isSearching ? Visibility.Visible : Visibility.None)
          SearchView({ isSearching: $isSearching })
            .visibility(!this.isSearching ? Visibility.None : Visibility.Visible)
        }
        .width(CommonConstants.FULL_PERCENT)
      }
      .scrollBar(BarState.Off)
      .height(CommonConstants.FULL_PERCENT)
      // 获取下滑时y轴滑动偏移量
      .onScrollFrameBegin((offset: number) => {
        this.scrollHeight = this.sideScroller.currentOffset().yOffset;
        return { offsetRemain: offset }
      })

      HomeHeader({ isSearching: $isSearching })
        .visibility(!this.isSearching ? Visibility.Visible : Visibility.None)
        .padding({ left: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ?
          $r('app.float.side_bar_width') : 0 })
    }
    .height(CommonConstants.FULL_PERCENT)
    .width(CommonConstants.FULL_PERCENT)
    .alignContent(Alignment.Top)
  }
}
.tabBar(this.BottomTabBuilder(this.tabList[0], 0))

// features/home/src/main/ets/view/HomeContent.ets
Column() {
  ...
}
// 设置背景图,覆盖侧边页签栏和顶部页签栏
.backgroundImage(this.currentTopIndex === 2 && !this.isSearching ? new BreakpointType(
  $r('app.media.immersive_background_sm'), $r('app.media.immersive_background_md'),
  $r('app.media.immersive_background_lg')).getValue(this.currentBreakpoint) : $r('app.media.white_background'))
// 设置背景图大小
.backgroundImageSize({ width: CommonConstants.FULL_PERCENT, height: new BreakpointType(
  $r('app.float.immersive_background_height_sm'), $r('app.float.immersive_background_height_md'),
  $r('app.float.immersive_background_height_lg')).getValue(this.currentBreakpoint) })
// 设置统一色调背景色
.backgroundColor(this.currentTopIndex === 2 && !this.isSearching ? (this.currentBreakpoint !==
  BreakpointConstants.BREAKPOINT_MD ? $r('app.color.home_content_background') :
  $r('app.color.home_content_background_md')) : Color.White)
.width(CommonConstants.FULL_PERCENT)

// features/home/src/main/ets/view/HomeHeader.ets
Column() {
  GridRow({...}) {
    ...
  }
  // 下滑过程中顶部页签栏背景色更改
  .backgroundColor(this.scrollHeight >= new BreakpointType(HomeConstants.BACKGROUND_CHANGE_HEIGHT[0],
    HomeConstants.BACKGROUND_CHANGE_HEIGHT[1], HomeConstants.BACKGROUND_CHANGE_HEIGHT[2])
    .getValue(this.currentBreakpoint) && this.currentTopIndex === 2 ? $r('app.color.home_content_background') :
    Color.Transparent)
}
  • 2in1的Banner图排版创新

    在lg断点下,切换顶部页签,能够查看Banner图的排版创新,增多展示内容,提高浏览效率。三列Banner图按4:4:3预设的比例排布,使用layoutWeight实现自适应布局的占比能力。

    效果图如下:

    8

// features/home/src/main/ets/view/BannerView.ets
// 切换页签展示Banner的排版创新效果,按照4:4:3
Row({ space: HomeConstants.BANNER_ROW_SPACE }) {
  BannerText({...})
    .layoutWeight(CommonConstants.FOUR)

  BannerText({...})
    .layoutWeight(CommonConstants.FOUR)

  Column() {
    BannerText({...})
      .layoutWeight(1)
    BannerText({...})
      .layoutWeight(1)
  }
  .layoutWeight(CommonConstants.THREE)
}
  • 推荐视频区域长按预览

    长按首页推荐视频区域的第一张图片,在图片的位置显示[自定义弹窗组件]播放视频,弹窗默认向上方展开。当图片上划至窗口顶部时,弹窗自适应向下方展开。长按手势事件用[LongPressGesture方法]实现。

    因为Banner图区域使用了aspectRatio属性来控制图片的宽高比不变,所以不能手动计算视频推荐区域距离窗口顶部的高度,动态获取组件的高度也成为“一多”开发中经常遇到的难点。对这种问题,推荐使用[组件标识]的getInspectorByKey方法获取组件相对于应用窗口左上角的水平和垂直方向坐标,获取的位置属性单位为px,使用[像素单位]的px2vp方法转换单位为vp,从而确定自定义弹窗的偏移量offset。

    效果图如下:

9

// features/home/src/main/ets/view/RecommendedVideo.ets
Stack({ alignContent: Alignment.Center }) {
  Image(item.getImgSrc())
    ...
    // 设置组件id
    .id(JSON.stringify(item))
}
...
.gesture(
LongPressGesture({ repeat: false })
  .onAction(() => {
    if (index !== 0) {
      Logger.info(`Please long press the first image`);
      return;
    }
    // 获取组件所有属性
    let obj: Record<string, string> = JSON.parse(getInspectorByKey(JSON.stringify(item)));
    let rectInfo: string[] = JSON.parse(HomeConstants.SQUARE_BRACKETS[0] + obj.$rect +
      HomeConstants.SQUARE_BRACKETS[1]);
    // 获取组件所有属性
    let rectTop: number = px2vp(JSON.parse(HomeConstants.SQUARE_BRACKETS[0] + rectInfo[0] +
      HomeConstants.SQUARE_BRACKETS[1])[1]);
    let rectTop2: number = px2vp(JSON.parse(HomeConstants.SQUARE_BRACKETS[0] + rectInfo[1] +
      HomeConstants.SQUARE_BRACKETS[1])[1]);
    // 获取组件距离左侧的宽度
    let rectLeft: number = px2vp(JSON.parse(HomeConstants.SQUARE_BRACKETS[0] + rectInfo[0] +
      HomeConstants.SQUARE_BRACKETS[1])[0]);
    let topHeightNeeded: number = new BreakpointType(HomeConstants.VIDEO_DIALOG_HEIGHTS[0],
      HomeConstants.VIDEO_DIALOG_HEIGHTS[1], HomeConstants.VIDEO_DIALOG_HEIGHTS[2])
      .getValue(this.currentBreakpoint) + rectTop - rectTop2;
    if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM) {
      topHeightNeeded += HomeConstants.HOME_HEADER_HEIGHT_SM;
    }
    let dialogYOffset: number;
    // 自适应弹窗展开方向
    if (topHeightNeeded < rectTop) {
      dialogYOffset = rectTop2 - new BreakpointType(HomeConstants.VIDEO_DIALOG_HEIGHTS[0],
        HomeConstants.VIDEO_DIALOG_HEIGHTS[1], HomeConstants.VIDEO_DIALOG_HEIGHTS[2])
        .getValue(this.currentBreakpoint);
    } else {
      dialogYOffset = rectTop;
    }
    this.windowUtil = WindowUtil.getInstance();
    let isLayoutFullScreen: boolean = true;
    if (this.windowUtil === undefined) {
      Logger.error(`WindowUtil is undefined`);
      return;
    }
    let mainWindow = this.windowUtil.getMainWindow();
    if (mainWindow === undefined) {
      Logger.error(`MainWindow is undefined`);
      return;
    }
    isLayoutFullScreen = mainWindow.getWindowProperties().isLayoutFullScreen;
    // 减掉2in1设备中窗口的宽高
    if (deviceInfo.deviceType === CommonConstants.DEVICE_TYPES[0] && !isLayoutFullScreen) {
      dialogYOffset -= HomeConstants.WINDOW_UNDEFINED_TOP;
      rectLeft -= HomeConstants.WINDOW_UNDEFINED_LEFT;
    } else {
      Logger.info(`No need to subtract extra height`);
    }
    this.videoDialogController = new CustomDialogController({
      builder: VideoDialog(),
      autoCancel: true,
      customStyle: true,
      alignment: DialogAlignment.TopStart,
      offset: {
        dx: rectLeft,
        dy: dialogYOffset
      }
    });
    // 显示自定义弹窗播放视频
    this.videoDialogController.open();
  }))
  • 推荐视频区域的缩放

    在md和lg断点下,首页的推荐视频区域支持双指捏合放大与缩小。网格布局在md断点下默认3列显示,两指向中心捏合切换为4列显示,两指向外放大切换为3列显示;lg断点下,4列、5列显示同理。这一效果通过在[PinchGesture]双指触发捏合手势中动态修改网格布局的columnsTemplate属性实现。

    效果图如下:

10

// features/home/src/main/ets/view/RecommendedVideo.ets
Column() {
  // 宫格布局
  Grid() {
    ...
  }
  // 设置两行多列显示
  .columnsTemplate(this.videoGridColumn)
  .rowsTemplate(CommonConstants.VIDEO_GRID_COLUMNS[0])
  ...
}
// 两指捏合控制网格的放大与缩放
.gesture(PinchGesture({ fingers: 2 }).onActionUpdate((event: GestureEvent) => {
  if (event.scale > 1 && this.currentBreakpoint !== BreakpointConstants.BREAKPOINT_SM) {
    if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_MD) {
      animateTo({
        duration: HomeConstants.ANIMATION_DURATION
      }, () => {
        this.videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[1];
      })
    } else {
      animateTo({
        duration: HomeConstants.ANIMATION_DURATION
      }, () => {
        this.videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[2];
      })
    }
  } else if (event.scale < 1 && this.currentBreakpoint !== BreakpointConstants.BREAKPOINT_SM) {
    if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_MD) {
      animateTo({
        duration: HomeConstants.ANIMATION_DURATION
      }, () => {
        this.videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[2];
      })
    } else {
      animateTo({
        duration: HomeConstants.ANIMATION_DURATION
      }, () => {
        this.videoGridColumn = CommonConstants.VIDEO_GRID_COLUMNS[3];
      })
    }
  } else {
    Logger.info(`Two-finger operation is not supported`);
  }
}))

搜索页

长视频应用搜索页需要智能响应用户的特定输入并给出提示,并提供搜索发现内容。观察搜索页在不同设备上的UX设计图,可以进行如下设计:

  • 将搜索页划分为5个区域,效果图如下:

11

12

  • 整个页面响应式适配,借助栅格组件能力监听不同断点变化实现不同的布局效果。

  • 搜索页区域1在不同断点下均占满屏幕,使用layoutWeight实现拉伸能力。

  • 搜索页区域2-4在不同断点下样式基本一致,但列表显示的列数不同,使用List组件的lanes属性实现。

  • 搜索页区域5使用响应式布局中的栅格布局,在不同断点下分别占用不同的栅格列数实现布局效果。

长视频应用搜索页的5个基础区域介绍及实现方案如下表所示:

区域编号简介实现方案
1搜索框layoutWeight实现拉伸能力,同[首页搜索框]。
2[搜索发现]标题栏的空白区域使用Blank实现拉伸能力,内容使用[List组件]的lanes属性,在不同断点下呈2、2、3列显示。
3热搜页签的间距随断点变化,热搜内容使用[List组件]的lanes属性,在不同断点下呈1、2、3列显示。
4“华”搜索智能提示使用[List组件]的lanes属性,在不同断点下呈1、1、2列显示。
5搜索结果响应式布局的栅格布局,同[首页每日佳片]。

在实际开发中,区域1为导航栏,区域2和3,区域4,区域5为并列的搜索页内容,所以对应的开发顺序为区域1和区域2-5。

  • 搜索发现

    搜索发现、热搜和“华智能提示”区域,均使用List组件,设置在不同断点下的lanes属性来实现。本章节以搜索发现区域作为示例,热搜和“华”智能提示区域的实现读者可以自行查看代码。

    示意图如下:

14

搜索发现区域中,标题栏中间的空白区域随父容器尺寸变化而伸缩,使用Blank组件实现自适应布局的拉伸能力;List组件的lanes属性在sm和md断点下为2,lg断点下为3,实现不同布局显示。

// features/search/src/main/ets/view/SearchContent.ets
// 搜索发现区域
@Builder
searchDiscovery() {
  Row() {
    Text(SearchConstants.SEARCH_SUB_TITLES[2])
      .fontSize($r('app.float.search_history_title_font'))
      .fontWeight(CommonConstants.FONT_WEIGHT_500)
    // 实现自适应布局拉伸能力
    Blank()
    Image($r('app.media.ic_public_rotate'))
      .height($r('app.float.search_discovery_img_size'))
      .width($r('app.float.search_discovery_img_size'))
      .margin({ right: $r('app.float.search_discovery_img_margin') })
      .fillColor($r('app.color.type_tabs_font_selected'))
  }

  List() {
    ForEach(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? SearchConstants.SEARCH_DISCOVERY_LARGE :
      SearchConstants.SEARCH_DISCOVERY_LARGE.slice(0, 6), (item: string, index: number) => {
      ListItem() {
        ...
      }
    }, (item: string, index: number) => index + JSON.stringify(item))
  }
  // 设置不同断点下的列数,sm和md断点下为2,lg断点下为3
  .lanes(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? SearchConstants.SEARCH_LIST_LANES[0] :
    SearchConstants.SEARCH_LIST_LANES[1])
}

视频详情页

长视频应用视频详情页提供视频播放、评论互动和提供视频相关信息的功能。观察视频详情页在不同设备上的UX设计图,可以进行如下设计(图中为包括可滑动区域的内容):

  • 将视频详情页划分为5个区域,效果图如下:

16

  • 视频详情页在sm、md和lg断点下有较大差异,在sm和md断点下相关列表、全部评论和写评论区域均显示在视频下方,无视频简介区域;在lg断点下分为侧边栏和主内容区两部分,全部评论和写评论区域显示在右侧边栏,相关列表和视频简介区域显示在主内容区视频下方。

  • 视频详情页区域1为视频播放相关组件,占满整个主内容区。

  • 视频详情页区域2容器组件尺寸变化时,增加或减小的空间全部分配给标题栏的空白区域;内容按照先后顺序随容器组件尺寸变化显示或隐藏。

  • 视频详情页区域3中的图片在sm、md断点下大小不同,在lg断点下随侧边栏尺寸改变大小发生变化。

  • 视频详情页区域4的评论框均占满容器组件剩余空间。

  • 视频详情页区域5只在lg断点下显示,容器组件尺寸变化时,增加或减小的空间,均匀分配给视频简介的图标、全部分配给周边视频标题的空白区域;选集列表内容按照先后顺序随容器组件尺寸变化显示或隐藏。

长视频应用视频详情页的5个基础区域介绍及实现方案如下表所示:

区域编号简介实现方案
1视频播放使用视频播放[AvPlayer]和[xComponent]等相关组件,不详细介绍具体功能实现,读者可以自行查看代码。
2相关列表标题栏的空白区域使用Blank实现拉伸能力,视频列表通过[List组件]实现延伸能力,同[搜索页搜索发现]。
3[全部评论]在不同断点下修改评论区图片的宽高和aspectRatio属性实现自适应布局的缩放能力。
4写评论layoutWeight实现自适应布局的拉伸能力,同[首页搜索框]。
5[视频简介]图标[Row组件]的justifyContent属性设置为FlexAlign.SpaceBetween实现均分能力,选集列表通过[List组件]实现延伸能力,周边视频使用layoutWeight实现拉伸能力。

在实际开发中,sm和md断点下的设计中,区域1-3和区域4为并列的视频详情页内容,所以对应的开发顺序为区域4,区域1-3;lg断点下的设计中,区域1、2、5和区域3-4是并列的视频详情页内容,所以对应的开发顺序为区域3-4,区域1、2、5。另外,视频详情页设计了额外的功能:[边看边评的交互逻辑]。

  • 全部评论

    根据ux设计,全部评论在sm、md断点下显示在视频下方,在lg断点下显示在右侧边栏,实现侧边悬浮面板,使用SideBarContainer组件+断点控制组件显示的位置,同时侧边栏宽度可通过拖拽变宽。全部评论中的图片,在sm、md断点下使用不同的宽高固定值;lg断点下,在SideBarContainer的onAreaChange方法中按侧边栏的宽度变化百分比改变图片宽高,配合图片的aspesctRatio属性控制等比放大或缩小,实现自适应布局的缩放能力。

    效果图如下:

17

// features/VideoDetail/src/main/ets/view/VideoDetail.ets
// 侧边栏及主内容区
SideBarContainer() {
  Column() {
    Scroll() {
      // lg断点下的全部评论区
      AllComments({ commentImgHeight: $commentImgHeight, commentImgWidth: $commentImgWidth })
        .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? Visibility.Visible :
          Visibility.None)
    }

    SelfComment()
      .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? Visibility.Visible :
        Visibility.None)
  }
  // lg断点下,修改图片宽高
  .onAreaChange((newValue: Area) => {
    if (newValue.width !== 0) {
      let height: number = DetailConstants.COMMENT_IMAGE_MIN_HEIGHT_NUMBER + (Number(newValue.width) -
        DetailConstants.SIDE_BAR_MIN_WIDTH_NUMBER) / (this.windowWidth * DetailConstants.COMMENTS_AREA_PERCENT -
        DetailConstants.SIDE_BAR_MIN_WIDTH_NUMBER) * (DetailConstants.COMMENT_IMAGE_MAX_HEIGHT_NUMBER -
        DetailConstants.COMMENT_IMAGE_MIN_HEIGHT_NUMBER);
      let width: number = DetailConstants.COMMENT_IMAGE_MIN_WIDTH_NUMBER + (Number(newValue.width) -
        DetailConstants.SIDE_BAR_MIN_WIDTH_NUMBER) / (this.windowWidth * DetailConstants.COMMENTS_AREA_PERCENT -
        DetailConstants.SIDE_BAR_MIN_WIDTH_NUMBER) * (DetailConstants.COMMENT_IMAGE_MAX_WIDTH_NUMBER -
        DetailConstants.COMMENT_IMAGE_MIN_WIDTH_NUMBER);
      this.commentImgHeight = JSON.stringify(height);
      this.commentImgWidth = JSON.stringify(width);
    }
  })
  Column() {
    // sm、md断点下全部评论区
    VideoDetail({ screenHeight: this.screenHeight, avPlayerUtil: this.avPlayerUtil, relatedVideoHeight:
      $relatedVideoHeight, videoHeight: $videoHeight })
      .layoutWeight(1)
    SelfComment()
      .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? Visibility.None :
        Visibility.Visible)
  }
}
// 控制右侧边栏只在lg断点下显示
.showSideBar(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? true : false)
// 右侧边栏的最小、最大宽度
.minSideBarWidth($r('app.float.side_bar_min_width'))
.maxSideBarWidth(px2vp(this.windowWidth * DetailConstants.COMMENTS_AREA_PERCENT))

// features/VideoDetail/src/main/ets/view/AllComments.ets
// 全部评论区中图片
Image(item.getCommentImageSrc())
  // 设置不同断点下的固定值或可变宽高
  .width(new BreakpointType(DetailConstants.COMMENT_IMAGE_WIDTH[0], DetailConstants.COMMENT_IMAGE_WIDTH[1],
    this.commentImgWidth).getValue(this.currentBreakpoint))
  .height(new BreakpointType(DetailConstants.COMMENT_IMAGE_HEIGHT[0],
    DetailConstants.COMMENT_IMAGE_HEIGHT[1], this.commentImgHeight).getValue(this.currentBreakpoint))
  // 控制lg断点下图片等比放大或缩小,实现缩放能力
  .aspectRatio(DetailConstants.COMMENT_IMAGE_ASPECT_RATIO)
  • 视频简介

    视频简介区域只在lg断点下显示,其中视频简介的图标设置Row组件的justifyContent属性为FlexAlign.SpaceBetween实现均分能力。选集列表使用List组件实现自适应布局的延伸能力,随容器组件尺寸变大,可以看到的集数相应增加,用户也可以通过手指滑动触发列表滑动,查看被隐藏的子节点。周边视频标题栏的空白区域随容器组件尺寸变化而伸缩,使用Blank实现自适应布局的拉伸能力。

    效果图如下:

18

// features/VideoDetail/src/main/ets/view/RelatedList.ets
// 视频简介区域
@Builder
VideoIntroduction() {
  Column() {
    ...
    Row() {
      ForEach(this.iconList, (item: Resource, index: number) => {
        Image(item)
          .height($r('app.float.introduction_icon_size'))
          .width($r('app.float.introduction_icon_size'))
      }, (item: Resource, index: number) => index + JSON.stringify(item))
    }
    // 图标行实现均分能力
    .justifyContent(FlexAlign.SpaceBetween)
    ...
    // 选集列表实现延伸能力
    List({ space: CommonConstants.LIST_SPACE }) {
      ForEach(this.episodes, (item: string, index: number) => {
        ListItem() {
          ...
        }
      }, (item: string, index: number) => index + JSON.stringify(item))
    }
    .scrollBar(BarState.Off)
    .listDirection(Axis.Horizontal)
    // 周边视频标题栏
    this.SubTitleAndMore(DetailConstants.SUB_TITLES[5])
    ...
  }
  // 控制只在lg断点下显示
  .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? Visibility.Visible : Visibility.None)
}

@Builder
SubTitleAndMore(subtitle: string) {
  Row() {
    ...
    // 空白区域实现拉伸能力
    Blank()
    ...
  }
}
  • 边看边评的交互逻辑

    视频详情页中,不同断点下页面下滑效果不同。在sm和md断点下,下滑时先隐藏相关列表区域,完全隐藏后视频区域等比缩小,缩至最小时固定,改为下滑全部评论区域;上滑时先上滑全部评论区,再等比放大视频区域,最后显示相关列表区域。在lg断点下,下滑时等比缩小视频区域,最小时固定改为下滑全部评论区域;上滑时先上滑全部评论区,再等比放大视频区域。这一效果通过[Scroll组件]的onScrollFrameBegin回调方法中控制相关组件的高度和偏移量实现。当组件高度变化时,返回偏移量为0;当全部评论区滑动时,返回实际偏移量。

    示意图如下:

19

// features/videoDetail/src/main/ets/view/RelatedList.ets
build() {
  Scroll(this.scroller) {
    Column() {
      this.RelatedVideoComponent()
      this.VideoIntroduction()
      AllComments({commentImgHeight: $commentImgHeight, commentImgWidth: $commentImgWidth})
        .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? Visibility.None :
          Visibility.Visible)
    }
    .width(CommonConstants.FULL_PERCENT)
    .alignItems(HorizontalAlign.Start)
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
  .onScrollFrameBegin((offset: number) => {
    if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) {
      if ((offset > 0) && (this.videoHeight > DetailConstants.MIN_VIDEO_PERCENT)) {
        // 视频缩小逻辑
        // 滑动占屏幕高度百分比
        let offsetPercent = (Math.abs(offset) * DetailConstants.MAX_VIDEO_PERCENT) / this.screenHeight;
        // 视频缩小百分比
        let heightOffset = offsetPercent < this.videoHeight - DetailConstants.MIN_VIDEO_PERCENT ? offsetPercent :
          this.videoHeight - DetailConstants.MIN_VIDEO_PERCENT;
        this.videoHeight = this.videoHeight - heightOffset;
        // 视频缩小百分比
        return { offsetRemain: 0 };
      } else if ((offset < 0) && (this.videoHeight < DetailConstants.MAX_VIDEO_PERCENT) &&
        (CurrentOffsetUtil.scrollToTop(JSON.stringify(this.scroller.currentOffset())))) {
        // 视频缩小百分比
        let offsetPercent = (Math.abs(offset) * DetailConstants.MAX_VIDEO_PERCENT) / this.screenHeight;
        let heightOffset = offsetPercent < DetailConstants.MAX_VIDEO_PERCENT - this.videoHeight ? offsetPercent :
          DetailConstants.MAX_VIDEO_PERCENT - this.videoHeight;
        this.videoHeight = this.videoHeight + heightOffset;
        // 返回实际偏移量0
        return { offsetRemain: 0 };
      }
      return { offsetRemain: offset };
    } else {
      if ((offset > 0) && (this.videoHeight === DetailConstants.MAX_VIDEO_PERCENT) && (this.relatedVideoHeight > 0)) {
        // 相关列表缩小逻辑
        // 滑动占屏幕高度百分比
        let offsetPercent = (Math.abs(offset) * DetailConstants.TEN) / this.screenHeight;
        this.relatedVideoHeight = (this.relatedVideoHeight - offsetPercent * DetailConstants.RELATED_LIST_HEIGHT) <
          0 ? 0 : (this.relatedVideoHeight - offsetPercent * DetailConstants.RELATED_LIST_HEIGHT);
        // 返回实际偏移量0
        return { offsetRemain: 0 };
      } else if ((offset > 0) && (this.videoHeight > DetailConstants.MIN_VIDEO_PERCENT) &&
        (this.relatedVideoHeight === 0)) {
        // 视频缩小逻辑
        // 滑动占屏幕高度百分比
        let offsetPercent = (Math.abs(offset) * DetailConstants.MAX_VIDEO_PERCENT) / this.screenHeight;
        // 视频缩小百分比
        let heightOffset = offsetPercent < this.videoHeight - DetailConstants.MIN_VIDEO_PERCENT ? offsetPercent :
          this.videoHeight - DetailConstants.MIN_VIDEO_PERCENT;
        this.videoHeight = this.videoHeight - heightOffset;
        // 返回实际偏移量0
        return { offsetRemain: 0 };
      } else if ((offset > 0) && (this.videoHeight === DetailConstants.MIN_VIDEO_PERCENT) &&
        (this.relatedVideoHeight === 0)) {
        return { offsetRemain: offset };
      } else if ((offset < 0) && (this.videoHeight < DetailConstants.MAX_VIDEO_PERCENT) &&
        (this.relatedVideoHeight === 0)) {
        if (CurrentOffsetUtil.scrollToTop(JSON.stringify(this.scroller.currentOffset()))) {
          // 返回实际偏移量0
          let offsetPercent = (Math.abs(offset) * DetailConstants.MAX_VIDEO_PERCENT) / this.screenHeight;
          let heightOffset = offsetPercent < DetailConstants.MAX_VIDEO_PERCENT - this.videoHeight ? offsetPercent :
            DetailConstants.MAX_VIDEO_PERCENT - this.videoHeight;
          this.videoHeight = this.videoHeight + heightOffset;
          // 返回实际偏移量0
          return { offsetRemain: 0 };
        }
        return { offsetRemain: offset };
      } else if ((offset < 0) && (this.videoHeight === DetailConstants.MAX_VIDEO_PERCENT) &&
        (this.relatedVideoHeight >= 0)) {
        // 相关列表放大逻辑
        // 滑动占屏幕高度百分比
        let offsetPercent = (Math.abs(offset) * DetailConstants.TEN) / this.screenHeight;
        this.relatedVideoHeight = (this.relatedVideoHeight + offsetPercent * DetailConstants.RELATED_LIST_HEIGHT) >
          DetailConstants.RELATED_LIST_HEIGHT ? DetailConstants.RELATED_LIST_HEIGHT : (this.relatedVideoHeight +
          offsetPercent * DetailConstants.RELATED_LIST_HEIGHT);
        // 返回实际偏移量0
        return { offsetRemain: 0 };
      }
      return { offsetRemain: 0 };
    }
  })
}
  • 全屏播放不旋转

    在折叠屏展开态这方形尺寸屏幕的设备上,点击“全屏播放”按钮,视频画面不旋转。在折叠屏展开态下,视频详情页进入全屏播放页不旋转,但是跟随系统传感器方向自动旋转,且受控制中心的旋转开关控制。使用window.setPreferredOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED)设置。首页和视频详情页在其他设备上或折叠屏其他折叠态时竖屏显示,使用window.setPreferredOrientation(window.Orientation.PORTRAIT)设置。

// features/videoDetail/src/main/ets/view/VideoDetail.ets
@Component
export struct VideoDetail {
  aboutToAppear() {
    this.windowUtil = WindowUtil.getInstance();
    if (this.windowUtil === undefined) {
      return;
    }
    // 判断为折叠屏展开态,设置窗口跟随传感器自动旋转,且受旋转开关控制
    if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_MD && display.isFoldable()) {
      this.windowUtil.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
    }
  }
}

// products/phone/src/main/ets/entryability/EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage) {
  let windowUtil: WindowUtil | undefined = WindowUtil.getInstance();
  if (windowUtil !== undefined) {
    windowUtil.setWindowStage(windowStage);
    // 设置竖屏显示
    windowUtil.setMainWindowPortrait();
  }
}

全屏播放页

长视频应用全屏播放页为用户带来沉浸式观看视频体验并支持选集。观察全屏播放页在不同设备上的UX设计图,可以进行如下设计:

  • 将全屏播放页划分为3个区域,如

19

  • 整个页面响应式适配,借助栅格组件能力监听不同断点变化实现不同的布局效果。

  • 全屏播放页区域1、2占满屏幕,点击选集弹出区域3,区域2中容器组件尺寸变化时,增大或减小的空间全部分配给进度条和下方工具栏的空白区域。

  • 全屏播放页区域3在不同断点下显示位置和大小不同。在sm断点下显示在右侧,视频与侧边栏比例为3:2;在md断点下显示在底部,视频与底部栏比例为1:1;在lg断点下显示在右侧,侧边栏宽度为固定值360vp。

长视频应用全屏播放页的3个基础区域介绍及实现方案如下表所示:

区域编号简介实现方案
1[全屏视频播放]使用[window窗口]管理窗口隐藏状态栏和导航栏,在sm断点下控制窗口横屏显示,在md、lg上不改变窗口显示方向。视频播放使用[AvPlayer]和[xComponent]等相关组件,不详细介绍具体功能实现,读者可以自行查看代码。
2进度条及工具栏进度条使用layoutWeight、工具栏的空白区域使用Blank实现自适应布局的拉伸能力,同[首页搜索框]。
3选集列表选集列表通过[List组件]实现延伸能力,同[搜索页搜索发现]。

在实际开发中,区域1-2和区域3为并列的视频播放页内容,所以对应的开发顺序为区域1-2,区域3。另外,为了提升用户的使用体验,全屏播放页为折叠屏的半折叠态设计了视频悬停播放。

  • 全屏视频播放

    进入全屏播放页,使用setWindowSystemBarEnable()方法隐藏顶部导航栏和状态栏,并在手机和折叠屏折叠态下使用setMainWindowOrientation()方法设置窗口横屏播放。

// features/VideoPlayer/src/main/ets/view/VideoPlayer.ets
aboutToAppear() {
  this.windowUtil = WindowUtil.getInstance();
  if (this.windowUtil !== undefined) {
    if (deviceInfo.deviceType !== CommonConstants.DEVICE_TYPES[0]) {
      // 隐藏顶部导航栏和状态栏
      this.windowUtil.disableWindowSystemBar();
    }
    if ((!display.isFoldable() && deviceInfo.deviceType === CommonConstants.DEVICE_TYPES[2]) ||
      display.getFoldStatus() === display.FoldStatus.FOLD_STATUS_FOLDED) {
      // 设置窗口横屏播放
      this.windowUtil.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE);
    }
    ...
  } else {
    Logger.info(`Full-screen display in portrait mode`);
  }
}
  • 视频悬停播放

    在折叠屏上浏览视频详情页或全屏播放页时,折叠设备,将自动切换至悬停态的沉浸播放视频体验。折叠屏悬停适配通过[window]的setPreferredOrientation接口设置窗口横向显示,通过[display]的getCurrentFoldCreaseRegion接口获取折叠屏折痕区域的位置和大小,通过自定义方式实现悬停态页面。视频移到上半屏,中间为折叠屏避让区,其他可操作组件堆叠到下半屏。示意图如下:

10

// commons/base/src/main/ets/utils/DisplayUtil.ets
export class DisplayUtil {
  static getFCreaseRegion(): void {
    if (display.isFoldable()) {
      let foldRegion: display.FoldCreaseRegion = display.getCurrentFoldCreaseRegion();
      let rect: display.Rect = foldRegion.creaseRects[0];
      // 折叠屏避让区距离顶部高度,避让区本身高度
      let creaseRegion: number[] = [px2vp(rect.top), px2vp(rect.height)];
      AppStorage.setOrCreate('creaseRegion', creaseRegion);
    }
  }
}

// features/videoPlayer/src/main/ets/view/VideoPlayer.ets
aboutToAppear() {
  ...
  if (display.isFoldable()) {
    // 折叠屏状态为半折叠时,设置窗口横屏显示
    if (this.isHalfFolded) {
      this.windowUtil.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE);
    }
  }
}

Column() {
  Stack({ alignContent: Alignment.Center }) {
    Flex({
      ...
    }) {
      Column() {
        ...
      }
      // 悬停时视频高度为避让区距离顶部高度
      .height(this.isHalfFolded ? this.creaseRegion[0] : CommonConstants.FULL_PERCENT)
    }

    Column() {
      ...
    }
    .height(CommonConstants.FULL_PERCENT)
    .width(CommonConstants.FULL_PERCENT)
    // 主轴尾部对齐,可操作组件堆叠到下半屏
    .justifyContent(FlexAlign.End)
    .visibility(this.isShowingSideBar ? Visibility.None : Visibility.Visible)

    Image($r('app.media.ic_public_back'))
      // 返回图标y轴的位置为避让区距离顶部高度+避让区本身的高度
      .position({
        x: $r('app.float.back_position_x'),
        y: this.isHalfFolded ? this.creaseRegion[0] + this.creaseRegion[1] : $r('app.float.back_position_y')
      })
  }
}

交互开发

交互事件归一

不同类型的智能设备,系统已经针对不同的交互方式做了适配,实现了[交互归一],因此开发者无需额外关注用户不同的交互方式。长视频应用包含的基础输入方式如下(以鼠标为例):

事件应用支持的鼠标交互
点击在Tabs、TextInput等组件上点击左键。
长按在首页首个推荐视频上长按左键预览视频。
拖拽在视频详情页右侧边栏上按压左键并移动鼠标,改变侧边栏的宽度。
滚动/平移在List和Scroll组件中上下滚动滚轮,实现。
缩放在首页推荐视频区域中ctrl+滚动滚轮控制宫格布局的缩放。

键盘走焦

在2in1设备的“华为发布会”的搜索结果页面,[默认可获焦的组件]遵循走焦规范。点击搜索框进入输入场景时,激活并获焦到输入框。按Tab键,焦点从搜索框向下移动到栅格布局时,默认获焦第一张卡片的播放按钮,再次按Tab键焦点此时可向右移动,到尽头后焦点向上移动至搜索框。同时方向上下键控制焦点在卡片间移动,方向左右键控制焦点在卡片内部和侧边页签间移动。

资源使用

本节以长视频应用为例,根据实际的资源使用情况,给出“一多”应用开发中推荐的资源使用方式。

在长视频应用中,多端页面显示的效果不同,使用的资源值随断点变化,推荐使用[媒体查询]获取资源值。借助媒体查询中监听断点的功能做简单的封装,创建BreakpointType类,通过getValue方法获取对应的资源值。在使用时,创建不同的资源文件传入BreakpointType代表sm、md和lg断点下的资源值,实现应用窗口大小变化时的不同效果。

// commons/base/src/main/ets/utils/BreakpointType.ets
export class BreakpointType<T> {
  sm: T;
  md: T;
  lg: T;

  constructor(sm: T, md: T, lg: T) {
    this.sm = sm;
    this.md = md;
    this.lg = lg;
  }

  getValue(currentBreakpoint: string): T {
    if (currentBreakpoint === BreakpointConstants.BREAKPOINT_MD) {
      return this.md;
    }
    if (currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) {
      return this.lg;
    } else {
      return this.sm;
    }
  }
}

// features/home/src/main/ets/view/DailyVideo.ets
Image($r('app.media.daily_small_video'))
  .height(new BreakpointType($r('app.float.small_daily_image_height_sm'),
    $r('app.float.small_daily_image_height_md'), $r('app.float.small_daily_image_height_lg'))
    .getValue(this.currentBreakpoint))
  .width(new BreakpointType($r('app.float.small_daily_image_width_sm'),
    $r('app.float.small_daily_image_width_md'), $r('app.float.small_daily_image_width_lg'))
    .getValue(this.currentBreakpoint))

功能开发

本示例的目标运行设备手机、折叠屏和2in1运行的都是标准系统,其系统能力一致,所以无需考虑[多设备上应用功能开发]的差异。

总结

本文针对长视频应用沉浸式的核心场景,从UX设计、工程管理、页面开发和功能开发四个角度,结合“一多”各项具体的能力,给出了在开发中推荐的参考样例。同时对于开发者在长视频垂类应用的“一多”开发中经常遇到痛点难点问题,给出详细的解决方案,实现了同类别应用的最佳实践,帮助开发者快速适配多种类型的设备,减少了多端开发的工作量,高效构建应用。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值