侧滑导航是移动端最经典的交互模式之一——从屏幕边缘划出菜单、露出遮罩、点击跳转,这套逻辑几乎所有 App 都在用。HarmonyOS NEXT 里实现它不需要第三方库,用 Column + translateX + PanGesture 就能搞定,这篇把侧滑导航与抽屉菜单的完整方案讲清楚。

基本结构:主内容 + 抽屉面板

在这里插入图片描述

侧滑导航的核心思路很简单——抽屉面板始终存在,只是通过 translateX 藏在屏幕左侧外面,需要时滑出来即可。主内容区和抽屉面板用 Stack 叠放,遮罩层夹在中间。

interface DrawerMenuItem {
  id: string
  title: string
  icon: ResourceStr
  routeUrl: string
}

@Entry
@Component
struct DrawerPage {
  @State drawerOpen: boolean = false
  @State translateX: number = -280
  @State maskOpacity: number = 0

  build() {
    Stack() {
      // 主内容区
      Column() {
        Text('主内容区域')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#f5f5f5')

      // 遮罩层
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => this.closeDrawer())

      // 抽屉面板
      Column() {
        this.DrawerContent()
      }
      .width(280)
      .height('100%')
      .backgroundColor(Color.White)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

Stack 里的层叠顺序就是绘制顺序——先放主内容,再放遮罩,最后放抽屉面板,这样抽屉永远在最上层。

translateX 动画与开关逻辑

抽屉的开关本质就是 translateX 在 -2800 之间切换。配合 animateTo 做平滑过渡,体验立刻从"硬切"变成"丝滑"。

@Entry
@Component
struct DrawerPage {
  @State drawerOpen: boolean = false
  @State translateX: number = -280
  @State maskOpacity: number = 0
  private drawerWidth: number = 280

  openDrawer() {
    this.drawerOpen = true
    animateTo({ duration: 250, curve: Curve.EaseOut }, () => {
      this.translateX = 0
      this.maskOpacity = 0.4
    })
  }

  closeDrawer() {
    animateTo({ duration: 200, curve: Curve.EaseIn }, () => {
      this.translateX = -this.drawerWidth
      this.maskOpacity = 0
    }, () => {
      // 动画结束后再标记关闭,避免闪烁
      this.drawerOpen = false
    })
  }

  build() {
    Stack() {
      Column() {
        // 汉堡菜单按钮
        Button({ type: ButtonType.Circle }) {
          Text('☰')
            .fontSize(24)
        }
        .width(44)
        .height(44)
        .margin({ left: 16, top: 48 })
        .onClick(() => this.openDrawer())
      }
      .width('100%')
      .height('100%')

      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => this.closeDrawer())

      Column() {
        this.DrawerContent()
      }
      .width(this.drawerWidth)
      .height('100%')
      .backgroundColor(Color.White)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

注意: animateTo 的 onFinish 回调里才把 drawerOpen 置为 false,否则动画还没播完面板就消失了。

遮罩层与透明度联动

遮罩层的 opacity 跟抽屉的打开程度绑定——完全关闭时 opacity 为 0(不可见),完全打开时为 0.4 左右。点击遮罩关闭抽屉是标准交互,用户不需要去找关闭按钮。

@Entry
@Component
struct DrawerPage {
  @State maskOpacity: number = 0
  @State translateX: number = -280

  build() {
    Stack() {
      // 主内容
      Column() {
        Text('主内容')
      }
      .width('100%')
      .height('100%')

      // 遮罩——点击即关闭
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .hitTestBehavior(this.maskOpacity > 0 ? HitTestMode.Default : HitTestMode.None)
        .onClick(() => this.closeDrawer())

      // 抽屉
      Column() {
        this.DrawerContent()
      }
      .width(280)
      .height('100%')
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

关键区别: hitTestBehavior 设置为 HitTestMode.None 时遮罩不响应触摸,这样关闭状态下不会挡住主内容的交互。打开时切回 Default 才能点击关闭。

PanGesture 拖拽开关

光靠按钮开关抽屉还不够——用户习惯从屏幕左边缘右滑来呼出菜单,左滑收起。PanGesture 就是干这个的,配合偏移量实时跟手。

@Entry
@Component
struct DrawerPage {
  @State translateX: number = -280
  @State maskOpacity: number = 0
  @State drawerOpen: boolean = false
  private drawerWidth: number = 280
  private startX: number = -280

  build() {
    Stack() {
      Column() {
        Text('主内容')
      }
      .width('100%')
      .height('100%')
      .gesture(
        PanGesture({ fingers: 1, direction: PanDirection.Horizontal })
          .onActionStart((event: GestureEvent) => {
            this.startX = this.translateX
          })
          .onActionUpdate((event: GestureEvent) => {
            let newX = this.startX + event.offsetX
            // 限制范围:不能超过右边界,也不能超出左边界
            if (newX > 0) { newX = 0 }
            if (newX < -this.drawerWidth) { newX = -this.drawerWidth }
            this.translateX = newX
            // 遮罩透明度跟随偏移比例
            this.maskOpacity = (newX + this.drawerWidth) / this.drawerWidth * 0.4
          })
          .onActionEnd((event: GestureEvent) => {
            // 偏移超过一半则打开,否则关闭
            if (this.translateX > -this.drawerWidth / 2) {
              this.openDrawer()
            } else {
              this.closeDrawer()
            }
          })
      )

      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => this.closeDrawer())

      Column() {
        this.DrawerContent()
      }
      .width(this.drawerWidth)
      .height('100%')
      .backgroundColor(Color.White)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

注意: PanGesture 的 offsetX 是相对起点的累计偏移,不是绝对位置。所以要用 startX + offsetX 来算实时位置,否则手势每次触发都会跳。

抽屉宽度与屏幕适配

抽屉宽度不能写死——小屏手机 280vp 差不多,大屏平板可能要 360vp。用屏幕宽度的比例来算更合理,一般抽屉占屏幕宽度的 70%-80%。

interface DrawerConfig {
  widthRatio: number   // 抽屉宽度占屏幕比例
  maxEdgeOffset: number // 边缘触发区域宽度
}

@Entry
@Component
struct DrawerPage {
  @State translateX: number = -280
  @State maskOpacity: number = 0
  private screenWidth: number = 360
  private config: DrawerConfig = { widthRatio: 0.75, maxEdgeOffset: 20 }

  aboutToAppear() {
    // 实际项目中从 display.getDefaultDisplaySync() 获取屏幕宽度
    this.screenWidth = 360
  }

  private getDrawerWidth(): number {
    return Math.floor(this.screenWidth * this.config.widthRatio)
  }

  build() {
    Stack() {
      Column() {
        Text('主内容')
      }
      .width('100%')
      .height('100%')
      .gesture(
        // 仅左边缘 20vp 区域响应呼出
        PanGesture({ fingers: 1, direction: PanDirection.Right })
          .onActionStart(() => {
            this.translateX = -this.getDrawerWidth()
          })
          .onActionUpdate((event: GestureEvent) => {
            let newX = -this.getDrawerWidth() + event.offsetX
            if (newX > 0) { newX = 0 }
            if (newX < -this.getDrawerWidth()) { newX = -this.getDrawerWidth() }
            this.translateX = newX
            this.maskOpacity = (newX + this.getDrawerWidth()) / this.getDrawerWidth() * 0.4
          })
          .onActionEnd(() => {
            if (this.translateX > -this.getDrawerWidth() / 2) {
              this.openDrawer()
            } else {
              this.closeDrawer()
            }
          })
      )

      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => this.closeDrawer())

      Column() {
        this.DrawerContent()
      }
      .width(this.getDrawerWidth())
      .height('100%')
      .backgroundColor(Color.White)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

关键区别: 用比例计算而非固定值,能自动适配不同屏幕。平板上抽屉会更宽,内容展示更充裕。

菜单项与路由跳转

抽屉里的菜单项通常是一个列表,点击后跳转到对应页面。用 router 推入新页面,同时关闭抽屉——先关抽屉再跳转,体验更流畅。

interface DrawerMenuItem {
  id: string
  title: string
  icon: ResourceStr
  routeUrl: string
}

@Entry
@Component
struct DrawerPage {
  @State translateX: number = -280
  @State maskOpacity: number = 0
  @State menuItems: DrawerMenuItem[] = [
    { id: 'home', title: '首页', icon: $r('app.media.ic_home'), routeUrl: 'pages/HomePage' },
    { id: 'profile', title: '个人中心', icon: $r('app.media.ic_profile'), routeUrl: 'pages/ProfilePage' },
    { id: 'settings', title: '设置', icon: $r('app.media.ic_settings'), routeUrl: 'pages/SettingsPage' },
    { id: 'about', title: '关于', icon: $r('app.media.ic_about'), routeUrl: 'pages/AboutPage' }
  ]
  @State activeMenuId: string = 'home'

  @Builder
  DrawerContent() {
    Column() {
      // 用户头像区域
      Column() {
        Text('用户名')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        Text('user@example.com')
          .fontSize(12)
          .fontColor('#cccccc')
          .margin({ top: 4 })
      }
      .width('100%')
      .height(160)
      .padding({ left: 20 })
      .justifyContent(HorizontalAlign.Start)
      .alignItems(HorizontalAlign.Start)
      .backgroundColor('#333333')

      // 菜单列表
      List() {
        ForEach(this.menuItems, (item: DrawerMenuItem) => {
          ListItem() {
            Row() {
              Image(item.icon)
                .width(24)
                .height(24)
                .margin({ right: 16 })
              Text(item.title)
                .fontSize(16)
                .fontColor(this.activeMenuId === item.id ? '#007DFF' : '#333333')
            }
            .width('100%')
            .height(56)
            .padding({ left: 20, right: 20 })
            .backgroundColor(this.activeMenuId === item.id ? '#f0f7ff' : Color.Transparent)
            .borderRadius(8)
            .onClick(() => {
              this.activeMenuId = item.id
              // 先关抽屉,延迟一点再跳路由
              this.closeDrawer()
              setTimeout(() => {
                router.pushUrl({ url: item.routeUrl })
              }, 300)
            })
          }
        }, (item: DrawerMenuItem) => item.id)
      }
      .width('100%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }

  // openDrawer / closeDrawer 同前,省略

  build() {
    Stack() {
      Column() {
        Text('主内容')
      }
      .width('100%')
      .height('100%')

      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => this.closeDrawer())

      Column() {
        this.DrawerContent()
      }
      .width(280)
      .height('100%')
      .backgroundColor(Color.White)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
  }
}

注意: setTimeout 延迟 300ms 再跳路由,是为了让关闭抽屉的动画先播完,否则动画会被路由切换打断。

子菜单与展开折叠

菜单层级深了就需要子菜单——点击父级展开子项,再点折叠。用 @State 控制展开状态,animateTo 控制高度动画。

interface SubMenuItem {
  id: string
  title: string
  routeUrl: string
}

interface DrawerMenuGroup {
  id: string
  title: string
  icon: ResourceStr
  expanded: boolean
  children: SubMenuItem[]
}

@Entry
@Component
struct DrawerWithSubMenu {
  @State menuGroups: DrawerMenuGroup[] = [
    {
      id: 'product', title: '产品管理', icon: $r('app.media.ic_product'), expanded: false,
      children: [
        { id: 'product_list', title: '产品列表', routeUrl: 'pages/ProductListPage' },
        { id: 'product_add', title: '添加产品', routeUrl: 'pages/ProductAddPage' }
      ]
    },
    {
      id: 'order', title: '订单管理', icon: $r('app.media.ic_order'), expanded: false,
      children: [
        { id: 'order_list', title: '订单列表', routeUrl: 'pages/OrderListPage' },
        { id: 'order_refund', title: '退款管理', routeUrl: 'pages/RefundPage' }
      ]
    }
  ]

  @Builder
  MenuGroupItem(group: DrawerMenuGroup) {
    Column() {
      // 父级
      Row() {
        Image(group.icon).width(24).height(24).margin({ right: 16 })
        Text(group.title).fontSize(16).layoutWeight(1)
        Text(group.expanded ? '▲' : '▼').fontSize(12).fontColor('#999999')
      }
      .width('100%')
      .height(56)
      .padding({ left: 20, right: 20 })
      .onClick(() => {
        animateTo({ duration: 200 }, () => {
          // 切换展开状态,触发 UI 刷新
          group.expanded = !group.expanded
        })
      })

      // 子菜单
      if (group.expanded) {
        ForEach(group.children, (child: SubMenuItem) => {
          Row() {
            Text(child.title)
              .fontSize(14)
              .fontColor('#666666')
          }
          .width('100%')
          .height(44)
          .padding({ left: 60 })
          .onClick(() => {
            router.pushUrl({ url: child.routeUrl })
          })
        }, (child: SubMenuItem) => child.id)
      }
    }
  }

  build() {
    Column() {
      List() {
        ForEach(this.menuGroups, (group: DrawerMenuGroup) => {
          ListItem() {
            this.MenuGroupItem(group)
          }
        }, (group: DrawerMenuGroup) => group.id)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

注意: group.expanded 的修改要在 animateTo 回调里,否则子菜单的出现/消失没有过渡效果。

平板与手机的导航差异

手机上侧滑抽屉是主流,但平板屏幕大——常驻侧边导航栏比抽屉更合适。判断设备类型或屏幕宽度来切换布局模式,小屏用抽屉,大屏用常驻侧栏。

interface NavLayoutConfig {
  mode: string       // 'drawer' 或 'side'
  panelWidth: number // 侧栏宽度
  showMask: boolean  // 是否需要遮罩
}

@Entry
@Component
struct AdaptiveNavPage {
  @State layoutConfig: NavLayoutConfig = { mode: 'drawer', panelWidth: 280, showMask: true }
  @State translateX: number = -280
  @State maskOpacity: number = 0
  private screenWidth: number = 360

  aboutToAppear() {
    // 大于 600vp 切换为常驻侧栏模式
    if (this.screenWidth >= 600) {
      this.layoutConfig = { mode: 'side', panelWidth: 240, showMask: false }
      this.translateX = 0
    }
  }

  @Builder
  NavPanel() {
    Column() {
      Text('首页').fontSize(16).padding(16)
      Text('个人中心').fontSize(16).padding(16)
      Text('设置').fontSize(16).padding(16)
    }
    .width(this.layoutConfig.panelWidth)
    .height('100%')
    .backgroundColor('#ffffff')
    .border({ width: { right: 1 }, color: '#e0e0e0' })
  }

  build() {
    if (this.layoutConfig.mode === 'side') {
      // 大屏:侧栏常驻 + 主内容
      Row() {
        this.NavPanel()
        Column() {
          Text('主内容区域')
            .fontSize(20)
        }
        .layoutWeight(1)
        .height('100%')
      }
      .width('100%')
      .height('100%')
    } else {
      // 小屏:抽屉模式
      Stack() {
        Column() {
          Text('主内容')
        }
        .width('100%')
        .height('100%')

        if (this.layoutConfig.showMask) {
          Column()
            .width('100%')
            .height('100%')
            .backgroundColor('#000000')
            .opacity(this.maskOpacity)
            .onClick(() => this.closeDrawer())
        }

        Column() {
          this.NavPanel()
        }
        .width(this.layoutConfig.panelWidth)
        .height('100%')
        .translate({ x: this.translateX })
      }
      .width('100%')
      .height('100%')
    }
  }
}

关键区别: 常驻侧栏模式下没有遮罩,translateX 始终为 0,主内容通过 layoutWeight(1) 占满剩余空间。这种布局在横屏平板上体验最好。

踩坑清单

问题 原因 解决
抽屉面板遮挡主内容点击 面板虽然 translateX 移出但仍然占触摸区域 关闭时设置 .enabled(false) 或调整 hitTest
PanGesture 与 List 滚动冲突 水平 Pan 和垂直滚动互相抢占 限定 PanGesture 方向为 Horizontal
遮罩点击无法关闭 opacity 为 0 时 hitTest 仍生效或失效 用 hitTestBehavior 按状态切换
关闭动画被路由跳转打断 router.pushUrl 立即切换页面 setTimeout 延迟 300ms 再跳转
子菜单展开无动画 直接修改 expanded 未包裹 animateTo 在 animateTo 回调内修改状态
大屏抽屉太窄 固定 280vp 宽度不适配 用屏幕宽度比例计算
汉堡按钮点击区域太小 44vp 的圆按钮边缘不好点 增大点击热区或用 padding 扩展
抽屉滑出时内容闪烁 translateX 直接赋值无过渡 统一使用 animateTo 包裹赋值
屏幕旋转后布局错乱 屏幕宽度缓存未更新 监听尺寸变化重新计算 config
多级菜单状态混乱 直接修改对象属性未触发 UI 刷新 替换整个数组元素或用 @Observed
Logo

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

更多推荐