HarmonyOS 6.0 侧滑导航与抽屉菜单
侧滑导航是移动端最经典的交互模式之一——从屏幕边缘划出菜单、露出遮罩、点击跳转,这套逻辑几乎所有 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 在 -280 和 0 之间切换。配合 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 |
更多推荐



所有评论(0)