4、Stack 与 Positioned:层叠布局的坐标系、溢出处理与实战技巧
层叠布局,说白了就是让组件可以「叠」在一起。这在做UI的时候太常见了——比如头像上加个角标、卡片上盖一层遮罩、或者做一个悬浮按钮。Flutter里实现这个效果,靠的就是 Stack 和 Positioned 这对搭档。
我刚开始用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列表的顺序决定了绘制顺序,索引越大的组件越靠上。
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 提供了 top、bottom、left、right 四个属性,用来指定子组件相对于 Stack 边界的距离。你想想看,这其实就是一个「绝对定位」的机制。
4.3 坐标系:到底从哪开始算?
这个问题我问过不少同行,很多人第一反应是「从Stack的左上角开始算」。对,但不完全对。
实际上,Positioned 的坐标原点取决于 Stack 的 alignment 属性。默认情况下 alignment 是 AlignmentDirectional.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 就是「我命由我不由天」,它自己说了算。
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),
),
],
)
默认情况下,Stack 的 clipBehavior 是 Clip.hardEdge,超出部分会被裁剪掉。如果你想让子组件「跑出去」还能显示,就得改成 Clip.none。
我个人习惯在需要做「悬浮效果」或者「气泡提示」时,把 clipBehavior 设为 Clip.none。但要注意,这样做可能会影响性能,因为Flutter需要绘制超出边界的内容。
| clipBehavior 值 | 效果 | 适用场景 |
|---|---|---|
| Clip.hardEdge | 硬裁剪,超出部分不显示 | 默认行为,性能最好 |
| Clip.antiAlias | 抗锯齿裁剪 | 需要圆角或平滑边缘时 |
| Clip.antiAliasWithSaveLayer | 带保存层的抗锯齿裁剪 | 复杂图形,但性能开销大 |
| 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 实例。
- 注意溢出检测:在开发阶段,可以开启
Stack的overflow警告(默认是关闭的),帮助发现潜在的溢出问题。
说实话,Stack 这个组件看起来简单,但真正用好它,还是需要一些实践积累的。我刚开始做 Flutter 开发时,总觉得它就是个「放东西的容器」,后来才发现它的设计其实很精巧——既保留了绝对定位的灵活性,又通过 clipBehavior 给了开发者足够的控制权。嗯,希望今天的分享能帮你少走一些弯路。