鸿蒙HarmonyOS开发:仿抖音首页沉浸式页面效果,利用Navigation组件实现弹出评论弹窗

一、沉浸式页面

沉浸式模式通常指让应用的界面更加专注于内容,不希望用户被无关元素干扰。在移动端应用中,全屏窗口元素包括状态栏、应用界面和导航栏(如下图),沉浸式页面开发常通过将应用页面延伸到状态栏和导航栏的方式,来达到以下目的:

  • 使页面和避让区域的色调统一,为用户提供更好的视觉体验。
  • 最大程度利用屏幕可视区域,使页面获得更大的布局空间。
  • 提供完全沉浸的体验,让用户沉浸其中,不被其他事物所干扰。
页面结构 全屏沉浸式
在这里插入图片描述 在这里插入图片描述
1、实现沉浸式效果方案对比

一般来说,整个应用(所有页面)都需要沉浸式效果,可以选择设置窗口全屏方案统一实现,针对具体页面的避让场景设置padding;单个页面或者仅需要将背景延伸到状态栏和导航栏,页面内容(子组件)希望避让状态栏和导航栏,使用expandSafeArea属性扩展对应组件安全区域来实现沉浸式效果更为方便。以下对两种方案的差异进行详细的说明。

方案一:设置窗口全屏模式

  • 页面的所有组件布局范围从安全区域扩展为整个窗口(包括状态栏和导航栏),见下图。
  • 页面内容需根据状态栏和导航栏高度合理避让处理。

方案二:扩展组件安全区域

  • 该属性只将当前组件延伸到状态栏和导航栏,不影响其他组件的布局范围,其他组件仍在安全区域内进行布局,见下图。
  • 由于其他组件在安全区域内进行布局,无需进行额外的避让处理。
  • 该属性有一定使用限制,详见expandSafeArea属性限制。

在这里插入图片描述

二、关键技术

1、使用Window.setWindowLayoutFullScreen()方法设置窗口为全屏模式。

设置主窗口或子窗口的布局是否为沉浸式布局。

setWindowLayoutFullScreen(isLayoutFullScreen: boolean): Promise<void>

沉浸式布局生效时,布局不避让状态栏与导航栏,组件可能产生与其重叠的情况。
非沉浸式布局生效时,布局避让状态栏与导航栏,组件不会与其重叠。

2、设置组件的expandSafeArea属性

控制组件扩展其安全区域。

expandSafeArea(types?: Array<SafeAreaType>, edges?: Array<SafeAreaEdge>)
参数名 类型 必填 说明
types Array 配置扩展安全区域的类型。
未添加Metadata配置项时,页面不避让挖孔, CUTOUT类型不生效。
edges Array 配置扩展安全区域的方向。

SafeAreaType枚举说明

扫描二维码关注公众号,回复: 17560962 查看本文章
名称 描述
SYSTEM 系统默认非安全区域,包括状态栏、导航栏。
CUTOUT 设备的非安全区域,例如刘海屏或挖孔屏区域。
KEYBOARD 软键盘区域。

SafeAreaEdge枚举说明

名称 描述
TOP 上方区域。
BOTTOM 下方区域。
START 前部区域。
END 尾部区域。
3、处理避让区域和页面内容的适配问题

由于避让区本身是有内容展示,如状态栏中的电量、时间等系统信息,或是手势交互,如导航条点击或上滑,在实现应用页面沉浸式效果后,往往会和避让区域产生UI元素的遮挡、视觉上的违和或交互上的冲突等问题,开发者可以针对不同场景选择以下方式对避让区和应用页面进行适配。

  • 使用Window.setWindowSystemBarEnable()方法或Window.setSpecificSystemBarEnabled()方法设置状态栏和导航栏的显隐。

  • 使用Window.setWindowSystemBarProperties()方法设置状态栏和导航栏的样式。

  • 使用Window.getWindowAvoidArea()方法获取避让区域的高度,据此设置应用页面内容的上下padding实现避让状态栏和导航栏。

  • 使用Display.getCutoutInfo()方法获取挖孔区域宽高和位置信息,设置对应避让元素的margin实现挖孔区避让。

三、常见问题

1、单个页面实现沉浸式

在A页面跳转B页面时使用setWindowLayoutFullScreen()方法让页面变沉浸式,在B页面的aboutToDisappear生命周期中退出沉浸式,回到A页面,A页面会由沉浸式效果过度为非沉浸式效果

单个页面实现沉浸式,优先考虑使用expandSafeArea属性扩展安全区域的方案实现,该方案只会影响当前组件的布局。

若必须使用setWindowLaoutFullScreen()方法实现沉浸式,可在NavDestination组件的onShown生命周期中进入沉浸式,onHidden生命周期中退出沉浸式。如果使用的是Router进行路由,可在组件的onPageShow生命周期中进入沉浸式,onPageHide生命周期中退出沉浸式,避免aboutToDisappear延迟执行产生过渡效果。

2、给组件设置了expandSafeArea属性不生效

设置expandSafeArea属性常会触碰以下限制,导致组件并没有扩展安全区域。

  • 设置expandSafeArea属性进行组件绘制扩展时,组件不能设置固定宽高尺寸。可尝试用百分比或padding改变组件宽高。

  • 设置expandSafeArea属性的组件需与安全区域边界重合。

  • 当设置expandSafeArea属性的组件的父组件是滚动类容器,需设置父组件的clip属性,不裁剪内部组件。

3、组件默认沉浸式

某些场景下没有使用setWindowLayoutFullScreen()方法或expandSafeArea属性,页面也延伸到了状态栏或导航栏

在页面中使用了以下组件,且满足expandSafeArea属性生效条件,则会有默认的扩展安全区域行为。开发者可重写该属性覆盖默认行为。

  • Navigation组件和NavDestination组件默认扩展安全区域至状态栏和导航栏,相当于默认设置了expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])属性。

  • Tabs组件默认扩展安全区域至导航栏,相当于默认设置了expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])属性。

四、Navigation (组件)

组件导航(Navigation)主要用于实现页面间以及组件内部的页面跳转,支持在不同组件间传递跳转参数,提供灵活的跳转栈操作,从而更便捷地实现对不同页面的访问和复用。本文将从组件导航(Navigation)的显示模式、路由操作、子页面管理、跨包跳转以及跳转动效等几个方面进行详细介绍。

Navigation是路由导航的根视图容器,一般作为页面(@Entry)的根容器,包括单栏(Stack)、分栏(Split)和自适应(Auto)三种显示模式。Navigation组件适用于模块内和跨模块的路由切换,通过组件级路由能力实现更加自然流畅的转场体验,并提供多种标题栏样式来呈现更好的标题和内容联动效果。一次开发,多端部署场景下,Navigation组件能够自动适配窗口显示大小,在窗口较大的场景下自动切换分栏展示效果。

Navigation组件主要包含导航页和子页。导航页由标题栏(包含菜单栏)、内容区和工具栏组成,可以通过hideNavBar属性进行隐藏,导航页不存在页面栈中,与子页,以及子页之间可以通过路由操作进行切换。

在API Version 9上,Navigation需要配合NavRouter组件实现页面路由。从API Version 10开始,更推荐使用NavPathStack实现页面路由。

1、接口
Navigation(pathInfos: NavPathStack)
参数名 类型 必填 说明
pathInfos NavPathStack 路由栈信息。
  • NavPathStack

Navigation路由栈,允许被继承12+。开发者可以在派生类中新增属性方法,也可以重写基类NavPathStack的方法。派生类对象可以替代基类NavPathStack对象使用。

2、属性
  • hideTitleBar
    设置是否隐藏标题栏。

  • mode
    设置导航栏的显示模式。支持Stack、Split与Auto模式。

  • navDestination
    创建NavDestination组件。使用builder函数,基于name和param构造NavDestination组件。builder下只能有一个根节点。builder中允许在NavDestination组件外包含一层自定义组件, 但自定义组件不允许设置属性和事件,否则仅显示空白。

3、事件
  • pushPath
    将info指定的NavDestination页面信息入栈。

  • pushPathByName
    将name指定的NavDestination页面信息入栈,传递的数据为param。

  • replacePath
    将当前页面栈栈顶退出,将info指定的NavDestination页面信息入栈。

  • pop
    弹出路由栈栈顶元素。

  • clear
    清除栈中所有页面。

4、示例代码
// 使用builder函数,基于name和param构造NavDestination组件。
@Builder
NavDialogPageMap(name: string) {
    
    
    //.......
}
  
// 创建一个页面栈对象
@Provide navDialogPageInfos: NavPathStack = new NavPathStack()

build() {
    
    
    // 页面栈对象传入Navigation
    Navigation(this.navDialogPageInfos) {
    
    
       //.......
    }
    .hideTitleBar(true)
    .mode(NavigationMode.Stack)
    .navDestination(this.NavDialogPageMap) // 创建NavDestination组件
}

五、window (窗口)

窗口提供管理窗口的一些基础能力,包括对当前窗口的创建、销毁、各属性设置,以及对各窗口间的管理调度。

该模块提供以下窗口相关的常用功能:

  • Window:当前窗口实例,窗口管理器管理的基本单元。

  • WindowStage:窗口管理器。管理各个基本窗口单元。

1、getMainWindowSync

获取该WindowStage实例下的主窗口。

getMainWindowSync(): Window
2、setWindowLayoutFullScreen

设置主窗口或子窗口的布局是否为沉浸式布局。

setWindowLayoutFullScreen(isLayoutFullScreen: boolean): Promise<void>

沉浸式布局生效时,布局不避让状态栏与导航栏,组件可能产生与其重叠的情况。
非沉浸式布局生效时,布局避让状态栏与导航栏,组件不会与其重叠。

3、setWindowSystemBarProperties

设置主窗口三键导航栏、状态栏的属性。

setWindowSystemBarProperties(systemBarProperties: SystemBarProperties): Promise<void>

该接口在2in1设备上调用不生效,其他设备在分屏模式(即窗口模式为window.WindowStatusType.SPLIT_SCREEN)、自由悬浮窗口模式(即窗口模式为window.WindowStatusType.FLOATING)、自由多窗模式(可点击设备控制中心中的自由多窗按钮开启)下调用不会立刻生效,只有进入全屏主窗口才会生效。

子窗口调用后不生效。

4、SystemBarProperties

状态栏、导航栏的属性。

名称 类型 必填 说明
statusBarColor string 状态栏背景颜色
isStatusBarLightIcon boolean 状态栏图标是否为高亮状态。
statusBarContentColor string 状态栏文字颜色
navigationBarColor string 导航栏背景颜色
isNavigationBarLightIcon boolean 导航栏图标是否为高亮状态
navigationBarContentColor string 导航栏文字颜色
enableStatusBarAnimation boolean 是否使能状态栏属性变化时动画效果
enableNavigationBarAnimation boolean 是否使能导航栏属性变化时动画效果
5、示例代码

// EntryAbility.ets

onWindowStageCreate(windowStage: window.WindowStage): void {
    
    
    // Main window is created, set main page for this ability
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

    // 获取该WindowStage实例下的主窗口。
    const mainWindow = windowStage.getMainWindowSync();
    // 设置主窗口的布局是否为沉浸式布局。
    mainWindow.setWindowLayoutFullScreen(true).then(() => {
    
    
      hilog.info(0x0000, 'testTag', 'Succeeded in setting the window layout to full-screen mode');
    }).catch((err: BusinessError) => {
    
    
      hilog.info(0x0000, 'testTag', 'Failed to set the window layout to full-screen mode. Cause: %{public}s', JSON.stringify(err) ?? '');
    })

    // 状态栏文字颜色。
    const sysBarProps: window.SystemBarProperties = {
    
    
      statusBarContentColor: '#ffffff'
    };
    // 设置主窗口三键导航栏、状态栏的属性。
    mainWindow.setWindowSystemBarProperties(sysBarProps).then(() => {
    
    
      hilog.info(0x0000, 'testTag', 'Succeeded in setting the system bar properties');
    }).catch((err: BusinessError) => {
    
    
      hilog.info(0x0000, 'testTag', 'Failed to set system bar properties. Cause: %{public}s', JSON.stringify(err) ?? '');
    })

    // ........
}

六、display (屏幕属性)

1、getAllDisplays

获取当前所有的display对象Array。

名称 类型 说明
id number 显示设备的id号。
name string 显示设备的名称。
rotation number 显示设备的屏幕顺时针旋转角度。
width number 显示设备的屏幕宽度,单位为px。
height number 显示设备的屏幕高度,单位为px。
densityDPI number 显示设备屏幕的物理像素密度,表示每英寸上的像素点数。
orientation Orientation 表示屏幕当前显示的方向。
2、示例代码
// 导入模块
import { display } from "@kit.ArkUI";
// 获取当前所有的display对象Array
const rects = await display.getAllDisplays()
// 获取当前显示设备的屏幕高度
this.fullHeight = this.getUIContext().px2vp(rects[0].height)

七、示例

1、实现功能
  • 仿抖音首页
  • 自定义不同的tabs效果
  • 实现沉浸式页面效果
  • 点击消息图标,弹出评论列表弹窗。
  • 评论弹窗抬起时压缩页面大小为减去弹窗的高度。
2、具体代码
  • index.ets
import {
    
     HomeContent } from "./HomeContent";
import {
    
     CommentList } from "./CommentList";

interface TabBar {
    
    
  icon?: Resource
  text?: string
}

@Extend(Column)
function tabBarContainerStyle() {
    
    
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}

@Entry
@Component
struct DouyinIndexPage {
    
    
  // 创建一个页面栈对象
  @Provide navDialogPageInfos: NavPathStack = new NavPathStack()
  @State selectedTabIndex: number = 0
  private tabBars: TabBar[] = [
    {
    
     text: '首页' },
    {
    
     text: '朋友' },
    {
    
     text: '发布', icon: $r('app.media.add') },
    {
    
     text: '消息' },
    {
    
     text: '我' }
  ]

  // 使用builder函数,基于name和param构造NavDestination组件。
  @Builder
  NavDialogPageMap(name: string) {
    
    
    if (name === "CommentList") {
    
    
      // 评论弹窗子页面
      CommentList()
    }
  }

  @Builder
  TabBarTextBuilder(tabBarText: string, tabIndex: number) {
    
    
    Column() {
    
    
      Text(tabBarText)
        .fontColor(Color.White)
        .opacity(
          this.selectedTabIndex === tabIndex ? 1 : 0.6
        )
    }
    .tabBarContainerStyle()
  }

  @Builder
  TabBarIconBuilder(icon: Resource) {
    
    
    Column() {
    
    
      Image(icon)
        .width(36)
    }
    .tabBarContainerStyle()
  }

  onSelectedTabChange(index: number) {
    
    
    this.selectedTabIndex = index
  }

  build() {
    
    
    // 页面栈对象传入Navigation
    Navigation(this.navDialogPageInfos) {
    
    
      Tabs({
     
      barPosition: BarPosition.End }) {
    
    
        ForEach(this.tabBars, (tabBar: TabBar, index) => {
    
    
          TabContent() {
    
    
            if (index === 0) {
    
    
              HomeContent()
            } else {
    
    
              Text(tabBar.text)
                .fontColor(Color.White)
                .fontSize(40)
            }
          }
          .tabBar(
            tabBar.icon ?
            this.TabBarIconBuilder(tabBar.icon) :
            this.TabBarTextBuilder(tabBar.text, index)
          )
        }, (tabBar: TabBar) => JSON.stringify(tabBar))
      }
      .barHeight(56)
      .backgroundColor(Color.Black)
      .padding({
    
     bottom: 20 })
      .divider({
    
     strokeWidth: 1, color: 'rgba(255, 255, 255, 0.20)' })
      .onChange((index: number) => {
    
    
        this.onSelectedTabChange(index)
      })
    }
    .hideTitleBar(true)
    .mode(NavigationMode.Stack)
    .navDestination(this.NavDialogPageMap) // 创建NavDestination组件
  }
}

  • HomeContent.ets
import {
    
     display } from "@kit.ArkUI";

interface OperateButtonType {
    
    
  icon: Resource
  count: number
  onClick?: (event: ClickEvent) => void
}

@Component
struct OperateButton {
    
    
  @Require @Prop icon: Resource;
  @Require @Prop count: number;
  handleClick: (event: ClickEvent) => void = event => {
    
    
  }

  build() {
    
    
    Column() {
    
    
      Image(this.icon)
        .width(36)
      Text(this.count.toString())
        .fontColor(Color.White)
    }
    .onClick(this.handleClick)
  }
}

@Extend(Text)
function videoInfoStyle() {
    
    
  .fontSize(14)
  .fontColor('rgba(255, 255, 255, 0.80)')
}


@Component
export struct HomeContent {
    
    
  @Consume navDialogPageInfos: NavPathStack;
  @StorageLink('commentListHeight') commentListHeight: number = 0;
  private fullHeight: number = 0;
  private operateButtons: OperateButtonType[] = [
    {
    
    
      icon: $r('app.media.like'),
      count: 155
    },
    {
    
    
      icon: $r('app.media.comment'),
      count: 566,
      onClick: this.onCommentButtonClick.bind(this)
    },
    {
    
    
      icon: $r('app.media.share'),
      count: 234
    }
  ];

  aboutToAppear(): void {
    
    
    this.setFullHeight();
  }

  // 获取设备高度
  async setFullHeight() {
    
    
    // 获取当前所有的display对象Array
    const rects = await display.getAllDisplays()
    // 获取当前显示设备的屏幕高度
    this.fullHeight = this.getUIContext().px2vp(rects[0].height)
  }

  onCommentButtonClick(event: ClickEvent) {
    
    
    this.navDialogPageInfos.pushPathByName('CommentList', null)
  }

  build() {
    
    
    Stack({
     
      alignContent: Alignment.Top }) {
    
    
      Image($r('app.media.bg'))
        .width('100%')
        .height(this.commentListHeight ? this.fullHeight - this.commentListHeight : '100%')
        .objectFit(this.commentListHeight ? ImageFit.Contain : ImageFit.Cover)
      Row() {
    
    
        Column({
     
      space: 15 }) {
    
    
          Row({
     
      space: 10 }) {
    
    
            Text('@地理风光')
              .fontColor(Color.White)
            Text('2小时前')
              .videoInfoStyle()
          }

          Text('三江源地区位于青海省南部,平均海拔4000米以上,这里是长江、黄河和...')
            .videoInfoStyle()
          Row({
     
      space: 5 }) {
    
    
            Image($r('app.media.video_music'))
              .width(16)
            Text('创作的音乐')
              .videoInfoStyle()
          }
        }
        .padding(16)
        .width('80%')
        .alignItems(HorizontalAlign.Start)

        Column({
     
      space: 20 }) {
    
    
          Image($r('app.media.user'))
            .width(44)
            .borderRadius(44)
          ForEach(this.operateButtons, (operateButton: OperateButtonType) => {
    
    
            OperateButton({
    
    
              icon: operateButton.icon,
              count: operateButton.count,
              handleClick: operateButton.onClick
            })
          }, (operateButton: OperateButtonType) => JSON.stringify(operateButton))
        }
        .padding(16)
        .width('20%')
      }
      .height('100%')
      .alignItems(VerticalAlign.Bottom)
    }
  }
}
  • CommentList.ets
@Component
export struct CommentList {
    
    
  @Consume navDialogPageInfos: NavPathStack;
  onCommentListAreaChange = (oldArea: Area, newArea: Area) => {
    
    
    AppStorage.setOrCreate('commentListHeight', newArea.height)
  }
  closeCommentList = () => {
    
    
    this.navDialogPageInfos.pop();
  }

  aboutToDisappear(): void {
    
    
    AppStorage.setOrCreate('commentListHeight', 0);
  }

  build() {
    
    
    NavDestination() {
    
    
      Stack() {
    
    
        Column()
          .height('100%')
          .width('100%')
          .backgroundColor(Color.Transparent)
          .onClick(this.closeCommentList)
        Column() {
    
    
          Row() {
    
    
            Text('23条评论')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
            Blank()
            Button() {
    
    
              Image($r('app.media.cancel'))
                .width(18)
            }
            .padding(10)
            .backgroundColor('rgba(0,0,0,0.05)')
            .onClick(this.closeCommentList)
          }
          .padding(15)
          .width('100%')

          Image($r('app.media.comment_list'))
            .width('100%')
        }
        .backgroundColor(Color.White)
        .borderRadius({
    
    
          topLeft: 32,
          topRight: 32
        })
        .padding({
    
     bottom: 20 })
        .onAreaChange(this.onCommentListAreaChange)
      }
      .height('100%')
      .alignContent(Alignment.Bottom)
    }
    .mode(NavDestinationMode.DIALOG)
    .hideTitleBar(true)
    .expandSafeArea([SafeAreaType.KEYBOARD]) //安全区避让,软键盘区域。
  }
}
3、效果展示
沉浸式页面效果 弹出评论弹窗
在这里插入图片描述 在这里插入图片描述

点击下方按钮添加微信,领取资料和学习方案。一键三连+关注,你的支持是我创作的动力。在这里,我乐于倾囊相授。