HarmonyOS ArkTS Stack 实战:做一个“悬浮按钮 + 遮罩弹层 + 底部菜单”的完整小项目

HarmonyOS ArkTS Stack 实战:做一个"悬浮按钮 + 遮罩弹层 + 底部菜单"的完整小项目

鸿蒙第四期开发者活动

你学 Stack 的时候,最容易卡在一句话: "Stack 就是叠在一起。"------听懂了,但真写页面时还是不知道怎么落地。

我这次直接用一个小项目把 Stack 的典型用法串起来:背景叠字、角标、悬浮按钮、遮罩、防穿透、底部弹出菜单,全都在一个页面里。你跑起来后,基本就能把 Stack 用顺手。


1)这个 Demo 最终长什么样

页面结构大概是这样(你可以脑补成三层):

  • 第 1 层:页面内容 顶部封面图 + 渐变遮罩 + 标题;中间是"叠层卡片"
  • 第 2 层:遮罩 点击 FAB 后出现的半透明黑色遮罩,点一下就关闭
  • 第 3 层:弹层 一个底部弹出菜单(像很多 App 的"快捷操作"那种)

右下角有个 悬浮按钮(FAB) ,永远浮在最上面。


2)为什么这个 Demo 一定要用 Stack?

因为它同时包含了 Stack 的 3 种"高频真实用途":

  1. 背景 + 前景(叠字) 比如封面图上叠标题、叠渐变遮罩
  2. 覆盖层(遮罩 / 弹窗 / 菜单) 这是 Stack 的老本行
  3. 层级控制(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 改成"跟随屏幕尺寸计算",适配更好
  • 把弹层换成"半屏卡片 + 拖拽下滑关闭"(更像系统交互)
相关推荐
richard_yuu1 小时前
鸿蒙心理测评模块实战|PHQ-9/GAD7双量表答题、实时计分与结果本地化存储
华为·harmonyos
不爱吃糖的程序媛4 小时前
2026年Electron 鸿蒙PC环境搭建指南
人工智能·华为·harmonyos
nashane4 小时前
HarmonyOS 6学习:长截图功能开发中的滚动拼接与权限处理实战
人工智能·华为·harmonyos
大师兄66686 小时前
从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
harmonyos·服务卡片·harmonyos6·formkit
Python私教11 小时前
鸿蒙 NEXT 也能接 MCP?用 ArkTS 跑通 AI Agent 工具链
人工智能·华为·harmonyos
Swift社区14 小时前
分布式能力在鸿蒙 PC 上到底怎么用?
分布式·华为·harmonyos
nashane1 天前
HarmonyOS 6学习:外接键盘CapsLock与长截图功能的实战调试与完整解决方案
学习·华为·计算机外设·harmonyos
aqi001 天前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony
环信即时通讯云1 天前
环信Flutter UIKit适配鸿蒙实战指南
flutter·华为·harmonyos