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)项目结构(建议这么放)

entry/src/main/ets/
  entryability/EntryAbility.ets
  pages/StackDemoPage.ets
  components/FabMenu.ets
  components/StackCards.ets

你也可以不拆文件,但拆开后更像真实项目:
页面负责组合,组件负责细节。


4)入口:EntryAbility.ets(确保能打开 StackDemoPage)

你如果是默认工程,有可能已经在这里 loadContent 了。
只要确保打开的是 pages/StackDemoPage 就行。

// 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”,原因很简单:
我想把整个页面当成一个舞台 —— 内容、遮罩、弹层、悬浮按钮都在一个舞台里叠起来。

✅ 主页面代码

// 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 叠出来的)
// 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
  • 点击菜单项回调给页面
  • 点击“关闭”或点遮罩关闭
// 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 改成“跟随屏幕尺寸计算”,适配更好
  • 把弹层换成“半屏卡片 + 拖拽下滑关闭”(更像系统交互)
Logo

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

更多推荐