4、Stack 与 Positioned:层叠布局的坐标系、溢出处理与实战技巧

层叠布局,说白了就是让组件可以「叠」在一起。这在做UI的时候太常见了——比如头像上加个角标、卡片上盖一层遮罩、或者做一个悬浮按钮。Flutter里实现这个效果,靠的就是 StackPositioned 这对搭档。

我刚开始用Stack的时候,总觉得它就是个「高级点的Container」。后来踩了几个坑才发现,它的坐标系、溢出规则、还有和Positioned的配合方式,其实有不少门道。今天咱们就把它彻底聊透。

4.1 Stack 的基本用法

Stack 本身是一个容器,它允许子组件「叠放」在一起。默认情况下,子组件会从左上角开始排列,后添加的组件会盖在先添加的组件上面。

Stack(
  children: [
    Container(width: 200, height: 200, color: Colors.blue),
    Container(width: 150, height: 150, color: Colors.red),
    Container(width: 100, height: 100, color: Colors.green),
  ],
)

这段代码跑起来,你会看到三个方块叠在一起。绿色在最上面,蓝色在最下面。嗯,这里要注意:children列表的顺序决定了绘制顺序,索引越大的组件越靠上。

小技巧: 如果你想让某个组件「沉底」,把它放在children列表的最前面就行。我习惯把背景层放在索引0的位置,这样逻辑上更清晰。

4.2 Positioned:精确控制位置

光靠默认的左上角对齐肯定不够用。实际项目中,我们经常需要把某个子组件放到指定位置——比如右上角、底部居中、或者距离左边20像素的地方。这时候就需要 Positioned 出马了。

Stack(
  children: [
    Container(width: 300, height: 300, color: Colors.grey[200]),
    Positioned(
      top: 10,
      right: 10,
      child: Icon(Icons.favorite, color: Colors.red, size: 32),
    ),
    Positioned(
      bottom: 20,
      left: 0,
      right: 0,
      child: Text('底部居中文字', textAlign: TextAlign.center),
    ),
  ],
)

Positioned 提供了 topbottomleftright 四个属性,用来指定子组件相对于 Stack 边界的距离。你想想看,这其实就是一个「绝对定位」的机制。

核心要点: Positioned 的坐标是相对于 Stack 的边界计算的。如果 Stack 没有设置宽高,它会尽可能包裹住所有非 Positioned 的子组件。这一点很容易被忽略,我早期就因为这个吃过亏。

4.3 坐标系:到底从哪开始算?

这个问题我问过不少同行,很多人第一反应是「从Stack的左上角开始算」。对,但不完全对。

实际上,Positioned 的坐标原点取决于 Stackalignment 属性。默认情况下 alignmentAlignmentDirectional.topStart,也就是左上角。但如果你改了 alignment,坐标原点也会跟着变。

Stack(
  alignment: Alignment.center,
  children: [
    Container(width: 200, height: 200, color: Colors.yellow),
    Positioned(
      top: 0,
      left: 0,
      child: Text('这个位置是相对于Stack中心?还是左上角?'),
    ),
  ],
)

答案是:Positioned 的坐标始终相对于 Stack 的边界,不受 alignment 影响。而 alignment 只影响那些没有用 Positioned 包裹的子组件。说白了,Positioned 就是「我命由我不由天」,它自己说了算。

注意: 如果你同时设置了 top 和 bottom,或者 left 和 right,Positioned 会拉伸子组件来满足约束。这有时候会导致意想不到的布局效果。我曾经在做一个全屏遮罩时,因为同时设置了 top:0 和 bottom:0,结果遮罩把整个Stack撑满了,完全不是我想要的效果。

4.4 溢出处理:组件跑出去了怎么办?

这是Stack最容易被忽视的问题。当 Positioned 的子组件尺寸太大,或者坐标设置不合理时,组件可能会超出 Stack 的边界。

Stack(
  clipBehavior: Clip.none,  // 默认是 Clip.hardEdge
  children: [
    Container(width: 100, height: 100, color: Colors.blue),
    Positioned(
      top: -20,
      left: -20,
      child: Container(width: 50, height: 50, color: Colors.red),
    ),
  ],
)

默认情况下,StackclipBehaviorClip.hardEdge,超出部分会被裁剪掉。如果你想让子组件「跑出去」还能显示,就得改成 Clip.none

我个人习惯在需要做「悬浮效果」或者「气泡提示」时,把 clipBehavior 设为 Clip.none。但要注意,这样做可能会影响性能,因为Flutter需要绘制超出边界的内容。

clipBehavior 值 效果 适用场景
Clip.hardEdge 硬裁剪,超出部分不显示 默认行为,性能最好
Clip.antiAlias 抗锯齿裁剪 需要圆角或平滑边缘时
Clip.antiAliasWithSaveLayer 带保存层的抗锯齿裁剪 复杂图形,但性能开销大
Clip.none 不裁剪,允许溢出 悬浮按钮、气泡提示等
避坑指南: 我曾经在做一个列表项的滑动删除效果时,用了 Positioned 把删除按钮放在列表项右侧外部。结果因为默认的 Clip.hardEdge,按钮死活显示不出来。排查了半天才发现是裁剪的问题。改成 Clip.none 后一切正常。

4.5 实战技巧:几个常用模式

4.5.1 角标(Badge)

这是Stack最经典的应用场景。在头像或图标的右上角加一个小红点或数字。

Stack(
  clipBehavior: Clip.none,
  children: [
    Icon(Icons.notifications, size: 48),
    Positioned(
      top: -4,
      right: -4,
      child: Container(
        padding: EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: Colors.red,
          shape: BoxShape.circle,
        ),
        child: Text('3', style: TextStyle(color: Colors.white, fontSize: 12)),
      ),
    ),
  ],
)

这里我把 clipBehavior 设成了 Clip.none,因为角标通常会超出图标的边界。嗯,这个模式我在实际项目中用了不下几十次。

4.5.2 全屏遮罩

有时候需要在某个组件上覆盖一层半透明遮罩,比如加载状态或者弹窗背景。

Stack(
  children: [
    // 主内容
    YourMainContent(),
    // 遮罩层
    Positioned.fill(
      child: Container(
        color: Colors.black.withOpacity(0.5),
        child: Center(child: CircularProgressIndicator()),
      ),
    ),
  ],
)

Positioned.fill 是一个便捷构造,相当于同时设置了 left:0, right:0, top:0, bottom:0,让子组件填满整个 Stack。这个用法很实用,我经常用它来做加载遮罩。

4.5.3 底部悬浮按钮

比如在页面底部固定一个「提交」按钮,不管内容怎么滚动,按钮始终在底部。

Stack(
  children: [
    // 可滚动内容
    SingleChildScrollView(
      padding: EdgeInsets.only(bottom: 80), // 给按钮留空间
      child: Column(
        children: [/* 你的内容 */],
      ),
    ),
    // 底部按钮
    Positioned(
      bottom: 16,
      left: 16,
      right: 16,
      child: ElevatedButton(
        onPressed: () {},
        child: Text('提交'),
        style: ElevatedButton.styleFrom(
          minimumSize: Size(double.infinity, 48),
        ),
      ),
    ),
  ],
)

这里有个细节:我给 SingleChildScrollView 加了 padding,防止内容被按钮挡住。这个「留空间」的思路,在很多布局场景下都适用。

4.6 性能与最佳实践

Stack 本身性能开销不大,但如果你在 Stack 里放了很多 Positioned 组件,或者频繁更新它们的位置,就要注意了。

  • 避免嵌套过深:Stack 套 Stack 套 Stack... 这种写法会让布局计算变得复杂。能用一个 Stack 解决的,就别用两个。
  • 合理使用 const:如果 Stack 的子组件不会变化,加上 const 关键字,让 Flutter 复用 widget 实例。
  • 注意溢出检测:在开发阶段,可以开启 Stackoverflow 警告(默认是关闭的),帮助发现潜在的溢出问题。
总结一下: Stack + Positioned 是 Flutter 中层叠布局的核心方案。理解它的坐标系(相对于 Stack 边界)、溢出处理(clipBehavior 控制)、以及常见的实战模式(角标、遮罩、悬浮按钮),你就能应对大部分层叠布局的需求了。

说实话,Stack 这个组件看起来简单,但真正用好它,还是需要一些实践积累的。我刚开始做 Flutter 开发时,总觉得它就是个「放东西的容器」,后来才发现它的设计其实很精巧——既保留了绝对定位的灵活性,又通过 clipBehavior 给了开发者足够的控制权。嗯,希望今天的分享能帮你少走一些弯路。