【HarmonyOS 6】基于API23的底部悬浮导航

之前我们讲了如何在API22里面,实现一个简易的悬浮导航栏。

往期回顾:【HarmonyOS 6】底部导航实战:Tabs 与玻璃导航栏联动

但是在API23中,官方给了更为便捷的方案。


一、适配工具代码

这是底部导航尺寸适配和底部安全冗余的基础,必须有。

typescript 复制代码
import mediaquery from '@ohos.mediaquery';
import { deviceInfo } from '@kit.BasicServicesKit';

export enum BreakpointType {
  SM = 'sm',  // 手机 320vp - 600vp
  MD = 'md',  // 平板 600vp - 840vp
  LG = 'lg'   // 2in1/大屏 840vp+
}

export class BreakpointValue<T> {
  sm: T;
  md: T;
  lg: T;

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

export function getValueByBreakpoint<T>(breakpoint: BreakpointType, config: BreakpointValue<T>): T {
  switch (breakpoint) {
    case BreakpointType.SM:
      return config.sm;
    case BreakpointType.MD:
      return config.md;
    case BreakpointType.LG:
      return config.lg;
    default:
      return config.sm;
  }
}

export function getPagePadding(breakpoint: BreakpointType): number {
  return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(16, 24, 32));
}

// 底部悬浮导航的安全冗余:避免遮挡页面最后内容
export function getFloatingNavSafePadding(breakpoint: BreakpointType): number {
  return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(84, 92, 0));
}

export type DeviceType = 'phone' | 'tablet' | '2in1' | 'unknown';

export function getDeviceType(): DeviceType {
  const rawType: string = deviceInfo.deviceType;
  switch (rawType) {
    case 'phone':
      return 'phone';
    case 'tablet':
      return 'tablet';
    case '2in1':
      return '2in1';
    default:
      return 'unknown';
  }
}

export function is2In1Device(): boolean {
  return getDeviceType() === '2in1';
}

export function shouldEnableHover(): boolean {
  return is2In1Device();
}

export class BreakpointManager {
  private currentBreakpoint: BreakpointType = BreakpointType.SM;
  private smListener: mediaquery.MediaQueryListener | null = null;
  private mdListener: mediaquery.MediaQueryListener | null = null;
  private lgListener: mediaquery.MediaQueryListener | null = null;
  private callbacks: ((breakpoint: BreakpointType) => void)[] = [];

  constructor() {
    this.initListeners();
  }

  private initListeners(): void {
    this.smListener = mediaquery.matchMediaSync('(320vp<=width<600vp)');
    this.smListener.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        this.updateBreakpoint(BreakpointType.SM);
      }
    });

    this.mdListener = mediaquery.matchMediaSync('(600vp<=width<840vp)');
    this.mdListener.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        this.updateBreakpoint(BreakpointType.MD);
      }
    });

    this.lgListener = mediaquery.matchMediaSync('(840vp<=width)');
    this.lgListener.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        this.updateBreakpoint(BreakpointType.LG);
      }
    });
  }

  private updateBreakpoint(breakpoint: BreakpointType): void {
    if (this.currentBreakpoint !== breakpoint) {
      this.currentBreakpoint = breakpoint;
      this.callbacks.forEach(callback => callback(breakpoint));
    }
  }

  getCurrentBreakpoint(): BreakpointType {
    return this.currentBreakpoint;
  }

  onChange(callback: (breakpoint: BreakpointType) => void): void {
    this.callbacks.push(callback);
  }

  removeCallback(callback: (breakpoint: BreakpointType) => void): void {
    const index = this.callbacks.indexOf(callback);
    if (index > -1) {
      this.callbacks.splice(index, 1);
    }
  }
}

let globalBreakpointManager: BreakpointManager | null = null;

export function getBreakpointManager(): BreakpointManager {
  if (!globalBreakpointManager) {
    globalBreakpointManager = new BreakpointManager();
  }
  return globalBreakpointManager;
}

二、导航核心代码:状态、图标、底栏组件

这部分是底部导航的"公共层",两种移动方案都依赖它。

typescript 复制代码
import { BreakpointManager, BreakpointType, BreakpointValue, getBreakpointManager, getFloatingNavSafePadding, getPagePadding, getValueByBreakpoint, is2In1Device, shouldEnableHover } from '../utils/BreakpointUtils';
import { ExerciseTabContent } from './ExerciseTabContent';
import { SleepTabContent } from './SleepTabContent';
import { ProfileTabContent } from './ProfileTabContent';
import { WaterTabContent } from './WaterTabContent';
import { CheckInTabContent } from './CheckInTabContent';

import common from '@ohos.app.ability.common';
import { deviceInfo } from '@kit.BasicServicesKit';
import { HdsTabs, HdsTabsController } from '@kit.UIDesignKit';
import { SymbolGlyphModifier } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  @State currentTabIndex: number = 0;
  @State hoverNavIndex: number = -1;
  @State currentBreakpoint: BreakpointType = getBreakpointManager().getCurrentBreakpoint();
  @State useApi23FloatingNav: boolean = false;

  private tabController: TabsController = new TabsController();
  private hdsTabController: HdsTabsController = new HdsTabsController();
  private breakpointManager: BreakpointManager | null = null;
  private breakpointListener: ((breakpoint: BreakpointType) => void) | null = null;

  aboutToAppear(): void {
    const ctx = getContext(this) as common.UIAbilityContext;
    this.detectApi23FloatingNav();

    this.breakpointManager = getBreakpointManager();
    this.currentBreakpoint = this.breakpointManager.getCurrentBreakpoint();
    this.breakpointListener = (breakpoint: BreakpointType) => {
      this.currentBreakpoint = breakpoint;
    };
    this.breakpointManager.onChange(this.breakpointListener);
  }

  aboutToDisappear(): void {
    if (this.breakpointManager && this.breakpointListener) {
      this.breakpointManager.removeCallback(this.breakpointListener);
    }
  }

  private detectApi23FloatingNav(): void {
    try {
      const sdkApiVersion: number = deviceInfo.sdkApiVersion;
      this.useApi23FloatingNav = sdkApiVersion >= 23;
    } catch (error) {
      this.useApi23FloatingNav = false;
    }
  }

  private isDesktopMode(): boolean {
    return is2In1Device();
  }

  private switchToTab(index: number): void {
    this.currentTabIndex = index;
    if (!this.isDesktopMode()) {
      if (this.useApi23FloatingNav) {
        this.hdsTabController.changeIndex(index);
      } else {
        this.tabController.changeIndex(index);
      }
    }
    if (index === 0) {
      this.refreshHomeData();
    }
  }

  private getMobileNavHeight(): number {
    return getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(60, 64, 68));
  }

  private getMobileNavRadius(): number {
    return getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(30, 32, 34));
  }

  private getMobileNavItemRadius(): number {
    return getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(16, 18, 20));
  }

  private getMobileNavTextSize(): number {
    return getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(12, 13, 14));
  }

  @Builder
  TabBuilder(title: string, index: number) {
    Column() {
      Text(title)
        .fontSize(getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(12, 14, 16)))
        .fontWeight(this.currentTabIndex === index ? FontWeight.Medium : FontWeight.Normal)
        .fontColor(this.currentTabIndex === index ? $r('app.color.primary_color') : $r('app.color.inactive_color'))
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  private buildTabSymbol(symbol: Resource, selected: boolean): SymbolGlyphModifier {
    return new SymbolGlyphModifier(symbol)
      .fontColor([selected ? $r('app.color.primary_color') : $r('app.color.inactive_color')]);
  }

  private getTabSymbol(index: number): Resource {
    switch (index) {
      case 0:
        return $r('sys.symbol.house');
      case 1:
        return $r('sys.symbol.square_and_pencil');
      case 2:
        return $r('sys.symbol.cup');
      case 3:
        return $r('sys.symbol.figure_run');
      case 4:
        return $r('sys.symbol.sleep');
      default:
        return $r('sys.symbol.person');
    }
  }

  @Builder
  MobileNavItem(title: string, index: number) {
    Column({ space: 4 }) {
      if (this.useApi23FloatingNav) {
        SymbolGlyph(this.getTabSymbol(index))
          .fontSize(getValueByBreakpoint(this.currentBreakpoint, new BreakpointValue<number>(16, 18, 20)))
          .fontColor([this.currentTabIndex === index ? $r('app.color.primary_dark') : $r('app.color.text_secondary')])
      }

      Text(title)
        .fontSize(this.getMobileNavTextSize())
        .fontWeight(this.currentTabIndex === index ? FontWeight.Medium : FontWeight.Normal)
        .fontColor(this.currentTabIndex === index ? $r('app.color.primary_dark') : $r('app.color.text_secondary'))
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 } as Padding)
    .borderRadius(this.getMobileNavItemRadius())
    .backgroundColor(this.currentTabIndex === index ? $r('app.color.glass_nav_active') : Color.Transparent)
    .onClick(() => {
      this.switchToTab(index);
    })
  }

  @Builder
  MobileNavBar() {
    Row() {
      this.MobileNavItem('首页', 0)
      this.MobileNavItem('打卡', 1)
      this.MobileNavItem('饮水', 2)
      this.MobileNavItem('运动', 3)
      this.MobileNavItem('睡眠', 4)
      this.MobileNavItem('我的', 5)
    }
    .width('92%')
    .height(this.getMobileNavHeight())
    .padding({ left: 8, right: 8 } as Padding)
    .justifyContent(FlexAlign.SpaceAround)
    .alignItems(VerticalAlign.Center)
    .backgroundColor($r('app.color.glass_nav_background'))
    .border({ width: 1, color: $r('app.color.glass_nav_border') })
    .borderRadius(this.getMobileNavRadius())
    .shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetY: 6 })
    .margin({ bottom: 16 } as Padding)
  }

  // ... 其他业务代码
}

三、移动端导航实现

这部分是最关键代码:API23+ 走官方提供的悬浮底栏,低版本自动回退。

typescript 复制代码
@Builder
Api23FloatingMobileTabs() {
  HdsTabs({ controller: this.hdsTabController }) {
    TabContent() {
      this.HomeContent()
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(0), false),
      selected: this.buildTabSymbol(this.getTabSymbol(0), true)
    }, '首页'))

    TabContent() {
      CheckInTabContent()
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(1), false),
      selected: this.buildTabSymbol(this.getTabSymbol(1), true)
    }, '打卡'))

    TabContent() {
      WaterTabContent({ currentTab: this.currentTabIndex })
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(2), false),
      selected: this.buildTabSymbol(this.getTabSymbol(2), true)
    }, '饮水'))

    TabContent() {
      ExerciseTabContent()
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(3), false),
      selected: this.buildTabSymbol(this.getTabSymbol(3), true)
    }, '运动'))

    TabContent() {
      SleepTabContent()
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(4), false),
      selected: this.buildTabSymbol(this.getTabSymbol(4), true)
    }, '睡眠'))

    TabContent() {
      ProfileTabContent({ currentTab: this.currentTabIndex })
    }
    .tabBar(new BottomTabBarStyle({
      normal: this.buildTabSymbol(this.getTabSymbol(5), false),
      selected: this.buildTabSymbol(this.getTabSymbol(5), true)
    }, '我的'))
  }
  .width('100%')
  .height('100%')
  .barOverlap(true)
  .barPosition(BarPosition.End)
  .vertical(false)
  .barFloatingStyle({
    barBottomMargin: 16,
    gradientMask: { maskColor: '#66F1F3F5', maskHeight: 92 }
  })
  .onChange((index: number) => {
    this.currentTabIndex = index;
    if (index === 0) {
      this.refreshHomeData();
    }
  })
}

@Builder
LegacyMobileTabs() {
  Stack({ alignContent: Alignment.Bottom }) {
    Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {
      TabContent() {
        this.HomeContent()
      }
      .tabBar(this.TabBuilder('首页', 0))

      TabContent() {
        CheckInTabContent()
      }
      .tabBar(this.TabBuilder('打卡', 1))

      TabContent() {
        WaterTabContent({ currentTab: this.currentTabIndex })
      }
      .tabBar(this.TabBuilder('饮水', 2))

      TabContent() {
        ExerciseTabContent()
      }
      .tabBar(this.TabBuilder('运动', 3))

      TabContent() {
        SleepTabContent()
      }
      .tabBar(this.TabBuilder('睡眠', 4))

      TabContent() {
        ProfileTabContent({ currentTab: this.currentTabIndex })
      }
      .tabBar(this.TabBuilder('我的', 5))
    }
    .width('100%')
    .height('100%')
    .barHeight(0)
    .edgeEffect(EdgeEffect.Spring)
    .onChange((index: number) => {
      this.currentTabIndex = index;
      if (index === 0) {
        this.refreshHomeData();
      }
    })

    this.MobileNavBar()
  }
  .width('100%')
  .height('100%')
}

build() {
  if (this.isDesktopMode()) {
    Row() {
      this.DesktopSidebar()
      Column() {
        this.DesktopContent()
      }
      .layoutWeight(1)
      .height('100%')
      .backgroundColor($r('app.color.background_color'))
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.background_color'))
  } else {
    if (this.useApi23FloatingNav) {
      this.Api23FloatingMobileTabs();
    } else {
      this.LegacyMobileTabs();
    }
  }
}

而且官方提供的悬浮底栏,还支持沉浸光感、以及迷你栏的功能。目前我们只是实现了一个最简易的导航栏。下次我们可以继续细讲一下剩下的内容。

四、最终实现效果

API23:

API22及以下:

相关推荐
音视频牛哥1 小时前
鸿蒙 NEXT 时代:如何构建工业级稳定和低延迟的同屏推流模块?
华为·harmonyos·大牛直播sdk·鸿蒙next 无纸化同屏·鸿蒙next同屏推流·鸿蒙rtmp同屏·鸿蒙无纸化会议同屏
IntMainJhy1 小时前
【fluttter for open harmony】Flutter 三方库适配实战:在 OpenHarmony 上实现图片压缩功能(附超详细踩坑记录)
flutter·华为·harmonyos
jiejiejiejie_2 小时前
Flutter for OpenHarmony 多语言国际化超简单实现指南
flutter·华为·harmonyos
2301_814809862 小时前
【HarmonyOS 6.0】ArkWeb 嵌套滚动快速调度策略:从机制到落地的全景解析
华为·harmonyos
前端不太难2 小时前
用 ArkUI 写一个小游戏,体验如何?
状态模式·harmonyos
南村群童欺我老无力.2 小时前
鸿蒙中AppStorage全局状态管理的生命周期问题
华为·harmonyos
SameX3 小时前
鸿蒙呼吸动画踩了三个坑:GPU降级时机、设计Token校验、i18n漏key——具体怎么处理的
harmonyos
音视频牛哥4 小时前
鸿蒙 NEXT 时代的“同屏推流”:从底层架构设计到工程落地全解析
华为·harmonyos·大牛直播sdk·鸿蒙next无纸化同屏·鸿蒙next屏幕采集推流·纯血鸿蒙无纸化会议·鸿蒙同屏rtmp推流
小成Coder5 小时前
【Jack实战】原生接入“悬浮导航 + 沉浸光感”Tab
华为·harmonyos·鸿蒙