1. 从 Row/Column 换到 Stack:布局思路得整个换一遍
第一次在项目里用 Stack,是因为一个卡片右上角要挂一个"促销"角标。当时我的第一反应是 Column 套 Row,再算一算内边距,让角标刚好压在图片右上角。改到第三版设计稿的时候,角标位置从右上挪到了正中偏上,还要求一半压在图片上、一半露在卡片外面,我那天下午基本就在跟 padding 和 SizedBox 较劲。后来同事看了一眼说,这不就是 Stack 加 Positioned 的事吗。换成层叠布局之后,那个角标从"算位置"变成了"说位置",代码量少了三分之二。
这篇主要聊四件事:Stack 层叠容器到底怎么算尺寸、Positioned 的定位参数在两个方向都给了之后会发生什么、屏幕相关尺寸(MediaQuery 那一套)该在什么场景取哪个值、以及 Navigator 路由跳转的完整用法和返回值处理。这些是 Flutter 日常开发里出现频率最高的几类 API,看起来都简单,但每一个都有几个不看文档就会踩的点。适合已经能写出页面、但布局和跳转还处在"能跑就行"阶段的同学,也适合被Incorrect use of ParentDataWidget报错卡过的人。
先说清楚 Stack 到底解决什么问题。Row 和 Column 是线性布局,它们把子元素排成一条线,彼此不重叠,位置由排列顺序和主轴对齐方式决定。Stack 是层叠布局,子元素共享同一块矩形区域,按添加顺序一层层往上叠。所以判据很简单:元素之间需要重叠、需要脱离兄弟元素的排列流、需要按"上下左右"而不是"前后"来定位的时候,就该考虑 Stack。反过来,如果只是想控制间距和对齐,硬上 Stack 反而会让自适应变差,因为 Stack 里的定位是相对坐标,父容器一变尺寸,你的固定数值就不准了。
1.1 线性布局撞墙的三个典型场景
我把实际项目里必须用 Stack 的场景归成三类,遇到这三类基本不用犹豫。
第一类是信息叠加。头像上挂在线状态小圆点、商品图角落挂价格标签、地图上叠一层操作按钮。这类元素的共同点是它们不参与主内容的排版,位置相对某个参照物固定,用 Row/Column 表达会非常别扭。
第二类是遮罩和浮层。弹窗背后的半透明黑色、图片上的渐变蒙层、加载中的毛玻璃效果。遮罩必须铺满父容器,而且必须盖在内容之上,这正是 Stack 的强项。用Container(color: Colors.black54)作为 Stack 的最后一个 child,再配合Positioned.fill,两行就能搞定。
第三类是比例定位。视频播放器的控制栏贴底、轮播图的指示点居中偏下、悬浮按钮固定在右下角。这类位置和父容器的尺寸有关系,但不是简单的"居中"或"靠边",Stack 配合Alignment或Positioned表达起来最直接。
反过来,有些场景我会刻意避开 Stack。比如列表项内部的左图标右文字,用 ListTile 或者 Row 远比 Stack 稳;再比如需要随内容自适应的卡片内部结构,Stack 的尺寸推导规则会让高度计算变得难以预测,尤其是里面混着定位和非定位子元素的时候。
1.2 Stack 的 fit 参数决定了它自己有多大
这是我最初完全忽略的一个点:Stack 自己的尺寸是怎么来的。
规则其实不复杂,但分成两种情况。如果 Stack 里有非定位子元素(也就是没有包在 Positioned 里的 child),Stack 的尺寸由这些非定位子元素里最大的那个决定,同时受父级约束的限制。如果 Stack 里全是定位子元素,那就没有任何东西能撑起它的尺寸,Stack 会尽量占满父级给的约束空间,也就是"能多大就多大"。
这就解释了一个常见现象:写了个Stack只放了几个Positioned,结果它撑满了整个屏幕。不是 bug,是设计如此。
fit参数控制的是约束怎么传给非定位子元素,三个取值差异很大:
| StackFit 取值 | 传给子元素的约束 | 实际效果 |
|---|---|---|
loose(默认) | 父约束放宽,最小为 0 | 子元素可以小于 Stack,按 alignment 摆放 |
expand | 强制为父约束的最大尺寸 | 子元素被撑满,适合整屏遮罩 |
passthrough | 原样传递父约束 | 子元素看到的就是父级给的约束,不做处理 |
默认的loose有个坑:如果 Stack 的直接父级给的是无界约束(比如放在 Column 里但没套 Expanded),非定位子元素又想要撑满,就会拿不到确定的最大值,布局直接报错或者表现异常。我一般的做法是,只要 Stack 里有需要撑满的子元素,就明确写fit: StackFit.expand,让意图写在代码里,而不是依赖默认值碰运气。
alignment参数只对非定位子元素生效,默认是AlignmentDirectional.topStart,所以默认情况下非定位子元素是左上角对齐的。这一点和 Container 的默认行为不一样,容易看走眼。
还有一个参数值得单独提:clipBehavior,默认是Clip.hardEdge。也就是说,超出 Stack 边界的部分会被裁掉。那个"一半露在卡片外面"的角标,如果不设置clipBehavior: Clip.none,露出去的那一半根本看不见。我第一次遇到这个问题时以为是 Positioned 的负值没生效,查了半天才发现是被裁了。
1.3 绘制顺序和点击命中的方向是相反的
这个细节很反直觉,但是理解了之后很多"点不动"和"点错了"的问题就迎刃而解。
绘制是从第一个子元素画到最后一个,后画的盖在先画的上面。所以视觉上,越靠后的子元素在越上层。
点击命中测试是从最后一个子元素往前遍历。也就是说,最上层的元素最先收到触摸事件。
两者方向其实是自洽的:上层元素先响应点击,符合直觉。但问题在于,如果上层元素铺满了整个区域并且可响应点击(比如一个全屏的 GestureDetector),下层元素就永远收不到事件了。
解决办法有两个。一是把不参与交互的浮层用IgnorePointer包起来,让它对触摸事件完全透明,事件直接穿到下层去。二是用AbsorbPointer,它会吞掉子树范围内的事件,但自己可以响应,适合做"加载中禁用全部操作"这种效果。这两个组件的区别我用一句话记:IgnorePointer是"我不接收,你们继续",AbsorbPointer是"我拦住,谁也别想要"。
2. Positioned 的约束语义:它做的不是"移动",而是"重新分配空间"
很多人对 Positioned 的理解是"把子元素挪到某个位置",这个理解会导致大量误用。准确的说法是:Positioned 通过 ParentData 告诉 Stack,这个子元素应该被分配多大的空间、放在哪个偏移上,子元素自己完全不知道外面发生了什么。
这个区别在调试时特别明显。你在 Positioned 里放一个Text,即使指定了top: 100, left: 100,Text 的渲染盒还是它自己的文字大小,Positioned 只是改变了它相对于 Stack 左上角的位置。子元素内部的布局约束是被"重新计算"过的,而不是平移过去的。
2.1 四个方向参数两两组合等于什么
left、top、right、bottom这四个参数,实际语义是边距,不是坐标。理解它们的关键在于看哪个方向给了值:
| 参数组合 | 水平方向的约束结果 | 常见用途 |
|---|---|---|
只给left | 宽度取子元素自身宽度,左边缘距 Stack 左侧left | 固定左上角的标签 |
只给right | 宽度取子元素自身宽度,右边缘距 Stack 右侧right | 右上角角标 |
同时给left和right | 宽度 = Stack 宽度 − left − right,子元素被横向撑开 | 铺满一侧的横条、进度条 |
同时给left、right、width | width被忽略,以 left/right 计算为准 | 需要警惕的写法 |
最后一行是很多人没注意的地方:left、right、width三个同时写,不会报错,也不会警告,但width会静默失效。垂直方向上top、bottom、height同理。我现在的习惯是,要么写left+width,要么写left+right让子元素自适应,绝不混着写,避免以后改代码的人(包括三个月后的自己)误判。
只给top不给left的时候,子元素在水平方向上会按 Stack 的alignment对齐,这个行为也容易被忽略。想让它水平居中又贴顶,可以不写left,把 Stack 的alignment设为Alignment.topCenter,代码反而更简洁。
2.2 为什么 Positioned 一定要是 Stack 的直接孩子
报错信息长这样:
Incorrect use of ParentDataWidget. Positioned widgets must be placed directly inside Stack widgets.这个错误的根源在于 Flutter 的渲染树 ParentData 机制。Positioned 不是普通的 Widget,它继承自 ParentDataWidget,本身不产生 RenderObject,只负责往子 RenderObject 的 parentData 字段里写数据。真正读取这份数据的是 Stack 对应的 RenderStack。
所以问题就清楚了:如果 Positioned 和 RenderStack 之间隔了别的 RenderObject(比如中间套了个 Column、Container、Padding),那份 parentData 的写入毫无意义,因为 RenderStack 根本看不到它——中间那个 RenderObject 也不认这份数据。
常见的错误写法是把 Positioned 塞进 Column:
Stack( children: [ Positioned( top: 0, child: Column(children: [Text('A'), Positioned(top: 10, child: Text('B'))]), // 报错 ), ], )修法有两种:要么把内层的 Positioned 拆出来,成为 Stack 的直接孩子;要么放弃用 Positioned,在内层改用Transform.translate或者Align。我倾向于前者,因为层叠布局的结构应该扁平化,嵌套太深反而不好维护。
需要区分的是,Positioned 和 Stack 之间允许有不产生 RenderObject 的 Widget,比如Builder、绝大多数的StatelessWidget、StatefulWidget。因为你写的自定义 Widget 最终会展开成具体的 RenderObject 树,只要中间没有插进别的 RenderObject,parentData 就能正确挂到 RenderStack 上。所以把一段 Positioned 封装成一个自定义组件是没问题的,这也是我做复用时的常规操作。
2.3 Positioned.fill 与 Positioned.directional 的适用面
Positioned.fill是个语法糖,等价于left: 0, right: 0, top: 0, bottom: 0。它同时把宽高撑满,我几乎只在一种情况下用它:做遮罩层。
Stack( children: [ content, Positioned.fill( child: IgnorePointer( ignoring: !isLoading, child: Container(color: Colors.black38), ), ), ], )这里注意IgnorePointer的位置:遮罩层即使透明的(ignoring: false的时候)也会拦截触摸,加载中就该拦,加载完成后必须放开,所以ignoring的值要绑在状态上。如果不加这个,遮罩隐藏之后整个页面就点不动了,这是我很早以前排查了半天的一个问题。
Positioned.directional用的是start和end而不是left和right,它的方向由textDirection决定。做多语言适配、特别是需要支持从右往左书写的语言环境时,这个应该优先用。日常只做中文界面的话,用left/right更直观,别为了"优雅"给自己加理解成本。
还有一种写法容易被忽略:Positioned.fromRect和Positioned.fromRelativeRect,可以传一个Rect或RelativeRect对象。我一般在做拖拽跟随、或者位置由计算结果驱动的时候用,比如把计算结果存在状态里,直接Positioned.fromRect(rect: computedRect, child: ...),比拆成四个参数传更好看。
3. 屏幕尺寸这套体系:好几个入口,取值各不相同
屏幕相关尺寸这块,Flutter 至少提供了三个层级的入口:MediaQuery、View、LayoutBuilder。新手最容易犯的错是随便挑一个用,然后发现在某些设备上数据不对。我梳理一下它们各自的职责边界。
MediaQuery拿的是当前 Widget 所在上下文的媒体信息,包括尺寸、padding(安全区)、viewInsets(键盘)、textScaler(字体缩放)等等。它会受到祖先 Widget 的影响,比如一个SafeArea或者Scaffold内部的 MediaQuery 已经被修改过了。
View(老版本是WidgetsBinding.instance.window)拿的是最底层的设备视口数据,不受任何祖先 Widget 影响,是真正的物理窗口信息。做全局性的判断(比如整个应用的分辨率档位)用这个更准。
LayoutBuilder拿的是父级传给这个 Widget 的约束,它不是屏幕尺寸,是"我能用多大空间"。这个区别非常关键,我在做响应式布局时首选 LayoutBuilder,因为它反映的是真实可用空间,而不是屏幕有多大。
3.1 逻辑像素、设备像素比和设计稿换算
Flutter 里所有尺寸单位都是逻辑像素(logical pixel,也叫 device-independent pixel)。MediaQuery.sizeOf(context).width在一台 1080 物理像素宽、像素比 3.0 的手机上返回的是 360,不是 1080。
设备像素比通过MediaQuery.devicePixelRatioOf(context)拿。这个值的用途主要是:当你需要极其精确的 1 物理像素边框时,1 / devicePixelRatio就是 1 个物理像素对应的逻辑像素宽度。
Container( decoration: BoxDecoration( border: Border( bottom: BorderSide( width: 1 / MediaQuery.devicePixelRatioOf(context), color: Colors.grey, ), ), ), )做设计稿换算时,我一般不会用MediaQuery.of(context).size.width / 375这种全局缩放系数。原因是这个系数在不同尺寸设备上会导致字号、圆角这些本来不该缩放的东西跟着变,平板上的表现尤其糟糕。更稳妥的做法是给关键尺寸设几个断点:
final width = MediaQuery.sizeOf(context).width; final double horizontalPadding; if (width >= 900) { horizontalPadding = 48; } else if (width >= 600) { horizontalPadding = 32; } else { horizontalPadding = 16; }断点方案在小屏手机上不会过度压缩,在平板上也不会把内容拉到撑满整行导致阅读困难。如果确实要做等比缩放,我会限制缩放区间,比如scale.clamp(0.85, 1.3),避免极端值。
3.2 of(context) 和 sizeOf(context) 的重建代价差在哪
较新的 Flutter 版本给 MediaQuery 加了一组xxxOf的静态方法:sizeOf、paddingOf、viewInsetsOf、devicePixelRatioOf、textScalerOf、platformBrightnessOf等等。
区别在于依赖注册的粒度。MediaQuery.of(context)注册的是对整个MediaQueryData的依赖,只要其中任何一个字段变化,依赖它的 Widget 全部重建。而MediaQuery.sizeOf(context)只注册对size字段的依赖,键盘弹起导致viewInsets变化时,用它取尺寸的地方不会重建。
这个差异在频繁变化场景下非常明显。最典型的是输入框页面:键盘弹起会让viewInsets.bottom不停变化,如果页面里到处都是MediaQuery.of(context).size.width,每次键盘动画帧都会触发一轮无谓重建。我把项目里所有取单一字段的地方都换成xxxOf之后,输入页的掉帧情况肉眼可见地好转了。
还有一个细节:不要在build方法之外缓存MediaQuery.of(context)的结果。旋转屏幕、分屏、字体大小设置的变更都会让这份数据失效,而你的缓存不会自动更新。需要跨方法用的,把 context 传进去现取,或者用InheritedWidget配合didChangeDependencies。
3.3 安全区、键盘和状态栏高度别混着用
三个概念经常被混在一起:
| 取法 | 含义 | 典型用途 |
|---|---|---|
MediaQuery.paddingOf(context).top | 顶部安全区内边距 | 避开刘海、状态栏、挖孔 |
MediaQuery.viewPaddingOf(context) | 与 padding 类似,但不受键盘影响 | 需要稳定的顶部避让值 |
MediaQuery.viewInsetsOf(context).bottom | 键盘等系统输入法占据的高度 | 手动调整底部按钮位置 |
MediaQuery.paddingOf(context).bottom | 底部安全区 | 避开手势条、虚拟导航键 |
SafeArea组件本质上就是读取这几个值然后自动加 padding。日常布局直接用 SafeArea 就够了,不需要自己算。但有两种情况需要手动取:一是需要在键盘弹起时精确控制某个悬浮元素的位置(比如聊天输入框旁边的表情面板),二是需要在 SafeArea 之外绘制背景(背景色要铺满,内容才避让)。
键盘高度这块还有一个容易踩的点:Scaffold的resizeToAvoidBottomInset默认是true,键盘弹起时会自动把 body 区域压缩。如果你的页面里有固定高度的 Column,压缩之后就有可能溢出,报A RenderFlex overflowed by X pixels。解决方式要么设成false自己处理滚动,要么把内容包进SingleChildScrollView,让它在空间变小时可以滚。
3.4 LayoutBuilder 拿到的才是父级给的约束
我在做宽窄屏自适应的时候,越来越倾向于用LayoutBuilder,因为它给出的是这个 Widget 实际能用的空间,而不是整个屏幕。
设想一个页面,左侧有个固定 200 宽的侧栏,右侧内容区要根据剩余宽度决定是单列还是双列。如果用MediaQuery.sizeOf,判断依据是整个屏幕宽度,但在窄侧栏里就不准了;用LayoutBuilder,constraints.maxWidth就是右侧区域的真实宽度,判断逻辑天然正确。
LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 720; return isWide ? Row(children: [Expanded(child: listA), Expanded(child: listB)]) : listA; }, )用的时候注意两点。一是LayoutBuilder的builder在布局阶段被调用,所以里面不能做会引起重新布局的操作,也不能直接调用setState(会抛异常)。二是它拿到的约束可能是无界的,比如放在SingleChildScrollView的垂直方向里,constraints.maxHeight会是infinity,用之前必须判断constraints.hasBoundedHeight。
4. Navigator 路由跳转:页面栈、返回值和转场
Navigator 管理的是一个栈,push把新页面压进去,pop把栈顶弹出来。这个模型本身很简单,但实际项目里的分支不少:什么时候该用替换、什么时候该清空栈、返回值怎么接、命名路由怎么传参。我把常用的几种操作整理成一张表。
4.1 一次 push 到底发生了什么
final result = await Navigator.of(context).push<String>( MaterialPageRoute( builder: (context) => const DetailPage(), settings: const RouteSettings(name: '/detail'), ), );这行代码做了几件事:创建一个 Route 对象;把它压入 Navigator 的栈顶;触发页面转场动画;返回一个Future<String?>,这个 Future 在新页面被 pop 的时候完成,携带 pop 时传回来的值。
MaterialPageRoute在 Android 上默认是从下往上进、同时淡入,在 iOS 上是从右侧滑入、返回时支持边缘右滑手势。这个平台差异是自动处理的,不需要自己判断。
maintainState参数默认是true,意思是压入新页面后,旧页面的状态和渲染树都保留着。如果某条路径明确不需要返回(比如从启动页进主页),把它设成false可以省一点内存,但要注意设成false之后,返回时下方页面的状态会被重建(如果它被移出过渲染树)。我一般不动这个参数,除非在内存吃紧的场景里排查过。
fullscreenDialog: true会让页面以全屏对话框的形式呈现:iOS 上是从下往上弹出、左上角是"关闭"而不是返回箭头。做设置页、新建表单这类"从模态里进来"的页面时用这个语义更准确。
4.2 四种跳转方式的选择依据
| 方法 | 对页面栈的操作 | 适用场景 |
|---|---|---|
push | 压入新页面,原页面保留在栈里 | 常规的详情页、二级页面 |
pushReplacement | 压入新页面同时弹出栈顶 | 登录成功后进主页,不该返回登录页 |
pushAndRemoveUntil | 压入新页面并清空到指定位置 | 退出登录回登录页,清空全部历史 |
popUntil | 连续 pop 直到满足条件 | 从深层页面回到首页 |
pushReplacement有一个使用细节容易出错:它同样返回一个 Future,但你在 A 页调用pushReplacement跳到 B 页时,A 页的 context 会被销毁。如果之后还想在 A 页的回调里操作,就会出问题。所以需要返回值的场景,老老实实用push。
pushAndRemoveUntil的第二个参数是个判断函数:
Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const HomePage()), (route) => route.isFirst, // 保留栈底第一个页面,其余全部清掉 );退出登录时我一般用(route) => false,把整个栈清空。但这里有个坑:如果栈被清空到只剩新页面,Android 上的物理返回键行为会变成退出应用。如果不想退出应用,就应该保留一个栈底页面。
popUntil的用法类似,只是不压入新页面:
Navigator.of(context).popUntil((route) => route.isFirst);在从"首页 → 列表 → 详情 → 详情弹层"这种多级跳转里,首页的某个按钮想直接回首页,用popUntil最省事,不用一层层手动 pop。手动连续 pop 还有个隐患:pop 的动画会一层层播放,视觉上很乱,popUntil是一次性回退。
关于返回键拦截,WillPopScope已经在新版本中被PopScope替代。如果需要在用户按返回时弹一个"确认退出"的对话框,用PopScope的onPopInvokedWithResult配合canPop: false来做,别再用老写法了。
4.3 命名路由和参数传递的两种路径
导航写法分两派:匿名路由(直接 new 一个MaterialPageRoute)和命名路由(给路由起名字,统一在onGenerateRoute里处理)。
匿名路由的好处是参数可以直接通过构造函数传,类型安全,跳转处一目了然:
Navigator.of(context).push( MaterialPageRoute(builder: (_) => DetailPage(id: 1024)), );缺点是路由分散在各处,做大范围结构改动(比如加统一的转场动画、加权限拦截)很麻烦。
命名路由的好处是集中管理,方便做统一的鉴权、埋点、转场配置:
MaterialApp( initialRoute: '/', onGenerateRoute: (settings) { switch (settings.name) { case '/': return MaterialPageRoute(builder: (_) => const HomePage()); case '/detail': final args = settings.arguments as Map<String, dynamic>?; return MaterialPageRoute( builder: (_) => DetailPage(id: args?['id'] as int? ?? 0), ); default: return MaterialPageRoute(builder: (_) => const NotFoundPage()); } }, )缺点是settings.arguments的类型是Object?,需要手动强转,参数写错在编译期发现不了,只能运行时炸。我的做法是:参数不超过两个、且只有一处调用,用匿名路由;参数结构复杂、多处调用或者需要统一拦截,用命名路由,并且在onGenerateRoute里对参数做完整校验,别相信调用方一定传对。
还有一个容易被忽略的点:MaterialApp同时配置了routes和onGenerateRoute时,routes表优先匹配。如果某个路由名字在两张表里都写了,生效的是routes,onGenerateRoute里的逻辑根本不会走。这个优先级我踩过一次,排查了很久才想起来。
4.4 返回值经常是 null 的几个原因
await Navigator.push(...)拿到的结果,如果没做处理,默认是null。我见过好几个项目里这个返回值一直被当成"操作成功"来判断,其实一直是 null,只是因为判断写成了if (result != null)才没有出问题。
第一个原因:跳转后的页面根本没调用pop传值。默认返回的null就是字面意义上的没值。
第二个原因:用户按了系统返回键或者手势返回。这种情况下 Navigator 会自己 pop,不会走你写的那行Navigator.pop(context, value),所以结果必然是null。如果业务上需要区分"主动返回"和"取消返回",得在目标页里用PopScope拦截系统返回,统一从你自己的返回按钮走。
第三个原因:pop的时候 context 用错了。比如在一个Future回调里 pop,页面此时可能已经被销毁,Navigator.of(context)会报Looking up a deactivated widget's ancestor is unsafe。稳妥的写法是在异步操作之前先拿到NavigatorState:
final navigator = Navigator.of(context); final result = await doSomethingAsync(); if (!mounted) return; navigator.pop(result);注意mounted检查必须在await之后、使用 context 之前,这是使用 State 对象时的标准范式,很多偶现的崩溃都跟缺这一行有关。
5. 组合实战:悬浮操作球 + 遮罩 + 详情页跳转
光看 API 不动手容易记不牢。我把上面四块内容串成一个完整场景:一个列表页,右下角悬浮一个操作球,点开之后弹出一层半透明遮罩和一组快捷操作,点其中一项跳到详情页,详情页返回时把选中的值带回列表页。这个场景把 Stack、Positioned、屏幕尺寸、Navigator 全用上了。
5.1 结构拆解与层级划分
先别急着写代码,把层级想清楚。整个页面是一个 Stack,自下而上分三层:
底层是主体内容:AppBar 之下的列表区域,这部分用Column或者CustomScrollView都行。中间层是遮罩:Positioned.fill加半透明 Container,只在快捷菜单展开时显示。顶层是操作球和它展开的菜单项,用Positioned定位到右下角。
操作球的位置需要考虑底部安全区。手机有手势条的那个区域不能放可点击元素,所以底边距要写成16 + MediaQuery.paddingOf(context).bottom。这是很多人会忘的一步,在模拟器上没感觉,在真机上就发现按钮贴在手势条上点不到。
菜单项从球上方向外展开,用Positioned配合bottom定位,每一项的bottom值随索引递增,加个AnimatedPositioned就有展开动画了。
还有一个尺寸细节:整块Positioned区域的可用宽度要用LayoutBuilder或者MediaQuery.sizeOf判断。如果屏幕特别窄(小屏手机横屏),操作球和菜单项会被挤到屏幕外,需要把右边缘和球心在整个页面里的位置一起算进定位逻辑。我在小屏横屏场景下就遇到过按钮只露出半个的问题,原因就是右边缘的位置写死成了 24。
5.2 完整代码
class ListPage extends StatefulWidget { const ListPage({super.key}); @override State<ListPage> createState() => _ListPageState(); } class _ListPageState extends State<ListPage> { bool _menuOpen = false; String? _selectedAction; Future<void> _openDetail(String action) async { setState(() => _menuOpen = false); final result = await Navigator.of(context).push<String>( MaterialPageRoute(builder: (_) => DetailPage(action: action)), ); if (!mounted) return; if (result != null) { setState(() => _selectedAction = result); } } @override Widget build(BuildContext context) { final bottomInset = MediaQuery.paddingOf(context).bottom; return Scaffold( appBar: AppBar(title: const Text('列表')), body: Stack( clipBehavior: Clip.none, children: [ // 第一层:主体内容 ListView.builder( padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + bottomInset), itemCount: 20, itemBuilder: (_, i) => ListTile(title: Text('条目 $i')), ), // 第二层:遮罩,展开时显示,点击可收起 if (_menuOpen) Positioned.fill( child: GestureDetector( onTap: () => setState(() => _menuOpen = false), child: Container(color: Colors.black.withValues(alpha: 0.35)), ), ), // 第三层:菜单项 if (_menuOpen) ...[ AnimatedPositioned( duration: const Duration(milliseconds: 180), right: 24, bottom: 88 + bottomInset, child: _MenuItem( label: '新建', onTap: () => _openDetail('create'), ), ), AnimatedPositioned( duration: const Duration(milliseconds: 180), right: 24, bottom: 148 + bottomInset, child: _MenuItem( label: '导入', onTap: () => _openDetail('import'), ), ), ], // 第三层:操作球本体 Positioned( right: 24, bottom: 24 + bottomInset, child: FloatingActionButton( onPressed: () => setState(() => _menuOpen = !_menuOpen), child: Icon(_menuOpen ? Icons.close : Icons.add), ), ), // 反馈提示 if (_selectedAction != null) Positioned( left: 16, right: 16, bottom: 96 + bottomInset, child: IgnorePointer( child: Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(8), ), child: Text( '已选择操作:$_selectedAction', style: const TextStyle(color: Colors.white), ), ), ), ), ], ), ); } }详情页把选中项带回:
class DetailPage extends StatelessWidget { const DetailPage({super.key, required this.action}); final String action; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('详情 - $action')), body: Center( child: ElevatedButton( onPressed: () => Navigator.of(context).pop(action), child: const Text('选中并返回'), ), ), ); } }这段代码跑到真机上,我需要调的有三个值:操作球距右边的 24、距底部的 24,以及菜单项之间的 60 间距。这三个数是按 44 的可点击区域加 16 的呼吸空间估的,实际操作时建议手动点一遍确认不打架。
5.3 实测中改掉的三处写法
第一版跑完之后,我在真机上发现三个问题,都值得记一下。
第一个:遮罩层盖住了操作球。因为我把遮罩放在操作球之前,遮罩盖不到它,这个顺序是对的;但我最初把遮罩放在了操作球之后,结果点开菜单后球被黑纱盖住,看着很怪。教训是层级顺序严格按照"内容、遮罩、浮层"从下往上排,这跟直觉上"遮罩在最后"不一样。
第二个:菜单项在收起动画播放期间仍然能被点到。AnimatedPositioned只是改变位置,Widget 还在树上,自然还能响应点击。加上if (_menuOpen)的条件渲染之后,收起时直接不构建,问题消失。如果一定要保留退出动画,那就得用AnimatedSwitcher或者AnimatedOpacity配合IgnorePointer。
第三个:MediaQuery.paddingOf(context).bottom在Scaffold内部取到的值可能和你预期不同。因为Scaffold自己可能会消费掉一部分底部安全区(比如有bottomNavigationBar的时候)。这种情况下,用MediaQuery.of(context).viewPadding.bottom取到的原始值更可靠。我在加了个底部导航栏之后才意识到这个差别,之前一直用 padding,数值突然变小,浮球位置就不对了。
6. 排查清单:这几个报错我踩过不止一次
最后把我在这四块内容上踩过、并且见过别人反复踩的问题汇总一下。这些不是文档里的标准答案,是从报错信息倒推出来的经验。
6.1 Incorrect use of ParentDataWidget 的两种变体
前面说过 Positioned 必须是 Stack 的直接孩子。这个报错还有另一个变体,来自Expanded、Flexible和Column/Row的配合:
Incorrect use of ParentDataWidget. Expanded widgets must be placed directly inside Flex widgets.根因完全一样,都是 ParentData 挂错了地方。排查时看报错信息里的第二段,Flutter 会打印出期望的父组件类型和实际的父组件类型,照着那个类型差一层层找就行。我的经验是往上看两三层,问题的原因往往不是报错那一行,而是更上面某个把子树包起来的容器。
另外提醒一句:Positioned不能放在ListView里。ListView 的子元素位置由滚动列表决定,不接受外部指定的偏移。想要在滚动内容里做层叠定位,常见做法是把 ListView 放进 Stack,定位元素作为 Stack 的另一层。
6.2 Stack 里的点击穿透与误触
现象是:点某个按钮没反应,或者点 A 触发了 B。三种成因。
第一种是前面说的,上层有个透明但可点击的区域挡住了。解决方法是给那层包IgnorePointer。判断方法很简单,把上层的颜色改成半透明看一眼,如果能看到那片区域,就知道是谁挡的。
第二种是层叠的可点击区域重叠。比如操作球和列表项在位置上重叠了,点击事件按命中测试顺序被球拿走了。这时候要检查的是两者的实际边界,用debugPaintSizeEnabled = true打开布局边界线一眼就能看出来。这个开关我建议常驻在开发环境里,布局问题排查效率至少翻倍。
第三种是GestureDetector的behavior参数没配合好。默认的HitTestBehavior.deferToChild意味着只有子元素命中的时候才响应,如果GestureDetector包着一个Container但没有设背景色,它的实际尺寸可能是 0,点不到。改法是给Container加个透明色,或者把behavior设成HitTestBehavior.opaque。后者更省事,但会让整块区域变成可点击,需要注意别吞掉了本该传下去的触摸。
6.3 键盘弹起后的布局溢出
这个和 Stack、Navigator 都有关系。典型场景是:页面用 Stack 把输入框定位在底部bottom: 16,键盘弹起后输入框被键盘盖住。
原因是 Stack 的定位基于它自己的坐标空间,而键盘弹起时,如果Scaffold的resizeToAvoidBottomInset为true,body 会被压缩,Stack 的尺寸变小,bottom: 16使得元素仍然贴在新的底部,但新底部的位置已经变了;如果为false,body 尺寸不变,输入框的位置完全不变,就被键盘盖住了。
两种解决思路:一是用MediaQuery.viewInsetsOf(context).bottom作为额外的底部偏移,让输入框随键盘上移;二是不用绝对定位,改用Align(alignment: Alignment.bottomCenter)配合AnimatedPadding,让位置由约束驱动。我现在的输入类页面基本都用第二种,稳定性更好。
另外,resizeToAvoidBottomInset: true配合SingleChildScrollView时,viewInsets.bottom会被Scaffold消费掉一部分,你在子组件里再取viewInsets.bottom会得到偏小的值。需要原始值时用MediaQuery.viewInsetsOf在Scaffold外层取一次传下去,或者用MediaQuery.removeViewInsets处理。这层关系不看源码基本想不到。
6.4 Navigator 相关报错的快速定位
Navigator operation requested with a context that does not include a Navigator这条,出现原因几乎都是 context 不在 Navigator 的子树里。最常见的写法错误是在MaterialApp之上取 context,或者在一个已经被弹出栈的页面里用它的 context 做跳转。
判断方法:把 context 传进Navigator.of(context)之前想一想,这个 context 对应的 Element 是否挂在某个 Navigator 下面。对于在异步回调里的跳转,我现在的做法是提前存好NavigatorState,或者用一个全局的navigatorKey,两种都比在回调里现取 context 安全。
还有一种情况容易忽略:showDialog和showModalBottomSheet内部也有自己的 Navigator(由MaterialApp在根上提供),但如果你在 dialog 内部做页面跳转,得用 dialog 自己的 context,而跳转的目标页面会压在整个应用的路由栈上,不在 dialog 的层级里。这种嵌套我用得比较少,一般会先关掉弹层再跳页,逻辑更清晰。
转场动画方面,如果想统一给所有页面加同一个动画,别在每个MaterialPageRoute里改,用一个自定义的PageRouteBuilder封装成函数,然后在onGenerateRoute里统一返回。这样既不用改调用处,也保证了所有页面的动画表现一致。
提示:以上排序和排查路径来源于我自己项目中的实际操作,具体表现会随 Flutter 版本和平台有所差异,遇到问题时优先看完整的报错堆栈,它会明确告诉你是哪个 ParentDataWidget 和哪个 RenderObject 对不上。
最后说个个人习惯上的小事。这四类内容——层叠布局、定位、尺寸取值、路由跳转——本质上都在处理"边界":元素之间的边界、屏幕与内容的边界、页面与页面之间的边界。我在写这类代码时有个固定动作:先在纸上画出层级,把每一层用一句话说清楚它的职责,再动手写 Widget 树。这一步花的时间通常不超过五分钟,但能省掉后面反复调位置、反复查遮挡的时间。架构清爽了,后面加动画、加交互才不会互相牵制。