HarmonyOS ArkTS Stack 实战:做一个"悬浮按钮 + 遮罩弹层 + 底部菜单"的完整小项目
你学 Stack 的时候,最容易卡在一句话: "Stack 就是叠在一起。"------听懂了,但真写页面时还是不知道怎么落地。
我这次直接用一个小项目把 Stack 的典型用法串起来:背景叠字、角标、悬浮按钮、遮罩、防穿透、底部弹出菜单,全都在一个页面里。你跑起来后,基本就能把 Stack 用顺手。
1)这个 Demo 最终长什么样
页面结构大概是这样(你可以脑补成三层):
- 第 1 层:页面内容 顶部封面图 + 渐变遮罩 + 标题;中间是"叠层卡片"
- 第 2 层:遮罩 点击 FAB 后出现的半透明黑色遮罩,点一下就关闭
- 第 3 层:弹层 一个底部弹出菜单(像很多 App 的"快捷操作"那种)
右下角有个 悬浮按钮(FAB) ,永远浮在最上面。
2)为什么这个 Demo 一定要用 Stack?
因为它同时包含了 Stack 的 3 种"高频真实用途":
- 背景 + 前景(叠字) 比如封面图上叠标题、叠渐变遮罩
- 覆盖层(遮罩 / 弹窗 / 菜单) 这是 Stack 的老本行
- 层级控制(zIndex) 不管你写得多漂亮,层级没控制好就是"按钮点不到 / 菜单被盖住"
3)项目结构(建议这么放)
css
entry/src/main/ets/
entryability/EntryAbility.ets
pages/StackDemoPage.ets
components/FabMenu.ets
components/StackCards.ets
你也可以不拆文件,但拆开后更像真实项目: 页面负责组合,组件负责细节。
4)入口:EntryAbility.ets(确保能打开 StackDemoPage)
你如果是默认工程,有可能已经在这里 loadContent 了。 只要确保打开的是 pages/StackDemoPage 就行。
javascript
// entry/src/main/ets/entryability/EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.loadContent('pages/StackDemoPage', (err) => {
if (err) {
console.error('loadContent failed: ' + JSON.stringify(err));
}
});
}
}
5)主页面:StackDemoPage.ets(核心就在这里)
我习惯把页面写成一个"大 Stack",原因很简单: 我想把整个页面当成一个舞台 ------ 内容、遮罩、弹层、悬浮按钮都在一个舞台里叠起来。
主页面代码
scss
// entry/src/main/ets/pages/StackDemoPage.ets
import { FabMenu } from '../components/FabMenu';
import { StackCards } from '../components/StackCards';
@Entry
@Component
struct StackDemoPage {
@State private menuOpen: boolean = false;
build() {
// 整个页面:一个大 Stack 负责"叠图层"
Stack({ alignContent: Alignment.TopStart }) {
// ========== A. 第 1 层:页面内容 ==========
Column() {
// 顶部:封面图 + 渐变遮罩 + 标题(这块就是 Stack 的经典用法)
Stack({ alignContent: Alignment.BottomStart }) {
// 1)封面图
Image($r('app.media.icon'))
.width('100%')
.height(220)
.objectFit(ImageFit.Cover);
// 2)渐变遮罩(让白字在图上更清楚)
Rect()
.width('100%')
.height(220)
.fill(Color.Transparent)
.linearGradient({
angle: 180,
colors: [[0x00000000, 0.0], [0xAA000000, 1.0]]
});
// 3)标题文字
Column({ space: 6 }) {
Text('Stack 实战:浮层 + 菜单 + 徽标')
.fontSize(22)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold);
Text('点击右下角按钮,体验遮罩与弹出面板')
.fontSize(13)
.fontColor(0xE6FFFFFF);
}
.padding({ left: 16, right: 16, bottom: 16 });
}
// 中间内容:叠层卡片
Column({ space: 14 }) {
Text('层叠卡片示例')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.padding({ left: 16, top: 12 });
StackCards()
Text('Stack 更像"图层容器":背景、遮罩、角标、弹窗都靠它。')
.fontSize(13)
.fontColor(0x99000000)
.padding({ left: 16, right: 16, top: 10 });
// 留一点底部空间,让 FAB 不挡内容
Blank().height(120);
}
.width('100%');
}
.width('100%')
.height('100%')
.backgroundColor(0xFFF5F6F8);
// ========== B. 第 2 层:遮罩(只在打开菜单时出现) ==========
if (this.menuOpen) {
// 遮罩一定要覆盖全屏,而且要能点击关闭
Rect()
.width('100%')
.height('100%')
.fill(0x66000000)
.onClick(() => this.menuOpen = false)
.zIndex(10);
}
// ========== C. 第 3 层:底部弹出菜单 ==========
FabMenu({
open: this.menuOpen,
onClose: () => (this.menuOpen = false),
onAction: (name: string) => {
console.info('action: ' + name);
this.menuOpen = false;
}
})
.zIndex(20);
// ========== D. 永远在最上层:悬浮按钮(FAB) ==========
Stack({ alignContent: Alignment.Center }) {
// 外圈:白底 + 阴影(看起来更"浮")
Circle()
.width(58)
.height(58)
.fill(0xFFFFFFFF)
.shadow({ radius: 18, color: 0x33000000, offsetX: 0, offsetY: 6 });
// 内圈:主色
Circle()
.width(50)
.height(50)
.fill(0xFF1677FF);
Text('+')
.fontSize(26)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold);
}
// position:把它钉到右下角附近
.position({ x: '82%', y: '84%' })
.onClick(() => this.menuOpen = !this.menuOpen)
.zIndex(30);
}
.width('100%')
.height('100%');
}
}
6)叠层卡片:StackCards.ets(用 translate + zIndex 做"层次感")
这一块我想表达的是: Stack 不只是"盖住",它还能做视觉层次(卡片叠层、影子叠层)。
写法要点:
- 底层卡:略往下偏移一点
- 中间卡:再往上一点
- 顶层卡:最完整,还带一个角标(角标就是 Stack 叠出来的)
scss
// entry/src/main/ets/components/StackCards.ets
@Component
export struct StackCards {
build() {
Stack({ alignContent: Alignment.Center }) {
this.card('基础层:背景卡片', 0xFFE9EEF7)
.translate({ x: 0, y: 14 })
.zIndex(1);
this.card('中间层:信息卡片', 0xFFFFFFFF)
.translate({ x: 0, y: 6 })
.zIndex(2);
// 顶层卡:再套一层 Stack 用来叠角标
Stack({ alignContent: Alignment.TopEnd }) {
this.card('顶层:带角标 / 徽标', 0xFFFFFFFF);
Row({ space: 6 }) {
Circle().width(8).height(8).fill(0xFFFF3B30);
Text('NEW')
.fontSize(12)
.fontColor(Color.White)
.fontWeight(FontWeight.Medium);
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(0xFFFF3B30)
.borderRadius(999)
.margin({ top: 10, right: 10 });
}
.zIndex(3);
}
.height(210)
.padding({ left: 16, right: 16 });
}
private card(title: string, bg: number) {
return Column({ space: 10 }) {
Text(title).fontSize(15).fontWeight(FontWeight.Medium);
Text('这张卡片用 Stack 叠在其他卡片上,通过 translate 做出层次感。')
.fontSize(12)
.fontColor(0x99000000)
.maxLines(2);
Row({ space: 10 }) {
this.tag('Stack');
this.tag('zIndex');
this.tag('translate');
}
}
.padding(16)
.width('100%')
.height(160)
.backgroundColor(bg)
.borderRadius(16)
.shadow({ radius: 14, color: 0x22000000, offsetX: 0, offsetY: 6 });
}
private tag(text: string) {
return Text(text)
.fontSize(11)
.fontColor(0xFF1677FF)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(0xFFEAF2FF)
.borderRadius(999);
}
}
7)底部弹出菜单:FabMenu.ets(弹层本质就是"在最上面叠一个面板")
这里我写得比较"项目味"一点:
- 菜单是一个 Column 卡片
- 位置在底部(用 Stack 的
Alignment.BottomCenter) - 每个菜单项都是 Row
- 点击菜单项回调给页面
- 点击"关闭"或点遮罩关闭
scss
// entry/src/main/ets/components/FabMenu.ets
@Component
export struct FabMenu {
open: boolean = false;
onClose?: () => void;
onAction?: (name: string) => void;
build() {
Stack({ alignContent: Alignment.BottomCenter }) {
if (this.open) {
Column({ space: 12 }) {
Row() {
Text('快捷操作').fontSize(16).fontWeight(FontWeight.Medium);
Blank();
Text('关闭')
.fontSize(13)
.fontColor(0xFF1677FF)
.onClick(() => this.onClose?.());
}
.width('100%');
this.actionItem('新建笔记', 'create_note');
this.actionItem('扫描二维码', 'scan_qr');
this.actionItem('分享当前页', 'share');
Divider().strokeWidth(1).color(0x11000000).margin({ top: 4, bottom: 2 });
this.actionItem('设置', 'settings');
}
.width('92%')
.padding(16)
.backgroundColor(0xFFFFFFFF)
.borderRadius(18)
.shadow({ radius: 24, color: 0x22000000, offsetX: 0, offsetY: 8 })
.margin({ bottom: 18 });
}
}
.width('100%')
.height('100%');
}
private actionItem(title: string, name: string) {
return Row({ space: 12 }) {
Circle().width(34).height(34).fill(0xFFEAF2FF);
Text(title).fontSize(14);
Blank();
Text('>').fontSize(14).fontColor(0x66000000);
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 8, right: 8 })
.borderRadius(12)
.backgroundColor(0xFFF8FAFF)
.onClick(() => this.onAction?.(name));
}
}
8)我写 Stack 弹层时最在意的 3 个细节(很容易踩坑)
① 层级要清楚:遮罩、弹层、FAB 谁在上?
我一般会固定一个习惯:
- 遮罩
zIndex(10) - 弹层
zIndex(20) - FAB
zIndex(30)
这样你永远不会遇到"按钮被盖住点不到"的问题。
② 遮罩要"吃掉点击"
你不吃掉点击,用户点在遮罩上就会穿透点到下面页面(体验很糟)。 所以遮罩必须 onClick(()=>close)。
③ 弹层不要写死位置太死
我这里用 BottomCenter + margin(bottom),比用绝对坐标稳一点。 你要做多设备适配时会省很多事。
9)你可以怎么把这个 Demo 改成你的项目组件
- 把
FabMenu做成通用组件:传入items数组就能渲染菜单 - 把 FAB 的
position改成"跟随屏幕尺寸计算",适配更好 - 把弹层换成"半屏卡片 + 拖拽下滑关闭"(更像系统交互)