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 改成"跟随屏幕尺寸计算",适配更好
  • 把弹层换成"半屏卡片 + 拖拽下滑关闭"(更像系统交互)
相关推荐
养猪喝咖啡2 小时前
ArkTS 文本输入组件(TextInput)详解
harmonyos
养猪喝咖啡2 小时前
HarmonyOS ArkTS 页面导航(Navigation)全面介绍
harmonyos
养猪喝咖啡2 小时前
HarmonyOS ArkTS 从 Router 到 Navigation 的迁移指南
harmonyos
Archilect2 小时前
从几何到路径:ArkUI 下的双层容器、缩放偏移与抛掷曲线设计
harmonyos
养猪喝咖啡2 小时前
HarmonyOS ArkTS 创建网格 Grid/GridItem:写得顺、适配稳、滚动不卡的那套方法
harmonyos
子榆.3 小时前
Flutter 与开源鸿蒙(OpenHarmony)性能调优实战:从启动速度到帧率优化的全链路指南
flutter·开源·harmonyos
子榆.3 小时前
Flutter 与开源鸿蒙(OpenHarmony)安全加固实战:防逆向、防调试、数据加密全攻略
flutter·开源·harmonyos
低调电报4 小时前
我的第一个开源项目:鸿蒙分布式“口袋健身”教练
分布式·开源·harmonyos
子榆.4 小时前
Flutter 与开源鸿蒙(OpenHarmony)深度集成实战(二):实现跨设备分布式数据同步
flutter·开源·harmonyos