Flutter 获取 iPhone 灵动岛预留区位置:MediaQuery 与 PlatformView 实战
iPhone 的灵动岛(Dynamic Island)从 iPhone 14 Pro 系列开始引入,到 iPhone 15 全系标配,再到 iPhone 16 系列进一步收窄边框,这块"药丸形"的顶部预留区已经成了 iOS 端 UI 适配绕不开的一道坎。Flutter 作为跨平台框架,默认的 SafeArea 和 MediaQuery.padding 只能告诉你"顶部有多少安全距离",但拿不到这块预留区的精确位置和尺寸——也就是说,你知道要躲开,却不知道躲开的边界到底在哪。这个问题在需要把自定义组件(比如搜索框、状态标签、悬浮按钮)贴近灵动岛摆放时尤其致命,位置偏几个像素,视觉上就会显得"贴脸"或者"悬空"。
这篇内容围绕 Flutter 获取 iPhone Duo 预留区位置 这个具体问题展开,把 MediaQuery、SafeArea、ViewPadding、PlatformView 这几条路都走一遍,讲清楚每种方案的适用边界和实测表现。适合已经能跑通 Flutter 基础项目、正在做 iOS 端精细 UI 适配的开发者,也适合想搞清楚 Flutter 布局系统在异形屏上到底怎么算的进阶读者。下面所有代码和参数都基于 Flutter 3.16+ 和 iOS 17/18 真机实测,模拟器上的数值会有偏差,这点后面会专门说。
1. 灵动岛预留区到底是个什么东西
1.1 从物理结构到逻辑坐标的映射关系
灵动岛在物理上是一块挖孔区域,里面塞了前置摄像头和 Face ID 传感器。iOS 系统在渲染时,会把这块区域从"可用显示区域"里扣掉,然后通过 UIWindow.safeAreaInsets 把扣掉的边距告诉上层应用。Flutter 引擎在 iOS 端启动时,会读取这个 safeAreaInsets,再结合屏幕的 scale(Retina 倍率)和 devicePixelRatio,换算成 Dart 层的逻辑像素,最终暴露在 MediaQuery.of(context).padding 和 MediaQuery.of(context).viewPadding 里。
这里有个关键区别很多人会搞混:padding 是已经被其他 widget 消费过的安全区,viewPadding 是原始的、未被消费的安全区。如果你在 Scaffold 的 body 里读 padding,拿到的可能是被 AppBar 吃掉之后的剩余值;而 viewPadding 始终反映屏幕物理层面的预留区,不受 widget 树影响。要获取灵动岛的原始位置,必须用 viewPadding,这是第一个容易踩的坑。
实测在 iPhone 15 Pro(灵动岛机型)上,竖屏状态下 viewPadding.top 约为 59.0 逻辑像素,而 iPhone 13(刘海屏)约为 47.0,iPhone SE(无刘海)为 20.0。这个 59 就是灵动岛底部到屏幕顶部的距离,也就是你内容区必须让开的高度。
1.2 为什么 SafeArea 不够用
SafeArea 是个好东西,它会自动帮你把子 widget 往下推 padding.top 的距离。但它的问题在于它只给边距,不给位置。你没法通过 SafeArea 知道灵动岛的左右边界在哪、宽度是多少、圆角半径多大。当你需要把一个组件水平居中于灵动岛正下方,或者让某个元素和灵动岛左右对齐时,SafeArea 就无能为力了。
更麻烦的是横屏场景。iPhone 15 Pro 横屏时,灵动岛跑到屏幕左侧,viewPadding.left 会变成约 59.0,而 top 变成 0。如果你的布局代码写死了 top,横屏直接错位。所以获取预留区位置这件事,本质上是获取一个四方向的边距集合,而不是单一数值。
| 机型 | 竖屏 top | 竖屏 bottom | 横屏 left | 横屏 right |
|---|---|---|---|---|
| iPhone 15 Pro | 59.0 | 34.0 | 59.0 | 59.0 |
| iPhone 14 Pro | 59.0 | 34.0 | 59.0 | 59.0 |
| iPhone 13 | 47.0 | 34.0 | 0 | 0 |
| iPhone SE 3 | 20.0 | 0 | 0 | 0 |
注意:上表数值来自真机实测,系统版本 iOS 17.4。模拟器上灵动岛机型的
viewPadding.top有时会返回 47.0 而非 59.0,这是模拟器渲染层的已知偏差,UI 适配必须以真机为准。
2. 用 MediaQuery 拿到预留区的完整数据
2.1 viewPadding 与 padding 的取值时机
前面说了要用 viewPadding,但还有个时机问题。MediaQuery.of(context) 是依赖 InheritedWidget 的,它会在 MediaQuery 数据变化时触发重建。如果你在 initState 里读,拿到的可能是初始值;如果屏幕旋转、键盘弹出,值会变,但 initState 不会重跑。所以读取预留区必须在 build 方法里,或者用 MediaQuery.of(context) 配合 didChangeDependencies。
这里有个细节:viewPadding 返回的是 EdgeInsets 对象,它的 top、bottom、left、right 都是 double。但灵动岛的实际形状是圆角药丸,viewPadding 只给了矩形边距,圆角信息拿不到。如果你要做"贴着灵动岛圆角"的效果,只能靠经验值估算——灵动岛宽度约 126 逻辑像素,高度约 37 逻辑像素,圆角半径约 18.5。这些值不是官方 API 给的,是从真机截图里量出来的,不同机型会有 1-2 像素浮动。
2.2 横竖屏切换时的数值变化
横屏是很多人翻车的地方。iPhone 15 Pro 横屏后,灵动岛在左侧,此时:
viewPadding.top= 0viewPadding.left= 59.0viewPadding.right= 59.0(对称,因为右侧也有圆角安全区)viewPadding.bottom= 21.0
注意 bottom 从竖屏的 34.0 变成了 21.0,因为横屏时底部 Home Indicator 的占用变小了。如果你的布局里写死了 bottom: 34,横屏就会多出一块空白。
我实测下来,横屏时最稳妥的做法是用 SafeArea 包一层,然后在内部用 LayoutBuilder 拿约束,而不是手动算边距。因为横屏下左右都有预留区,手动算容易漏掉一侧。
2.3 一个完整的预留区信息封装
把上面这些逻辑封装成一个工具类,用起来会顺手很多:
这个类的好处是把"判断机型"和"取边距"两件事合并了,业务代码里直接 DeviceReservedArea.of(context).isDynamicIsland 就能分支处理。islandCenter 那个方法需要屏幕宽度,实际项目里应该从 mq.size.width 取,我这里写死只是为了说明计算逻辑。
3. 当 MediaQuery 拿不到精确位置时的替代方案
3.1 PlatformView 读取原生 safeAreaInsets
有些极端场景下,MediaQuery 给的值不够用。比如你想知道灵动岛的精确 frame(而不只是边距),或者你的 Flutter 页面嵌在原生 UIViewController 里,MediaQuery 被外层容器改写过。这时候只能走 PlatformView,让原生代码把 UIWindow.safeAreaInsets 和 UIView.safeAreaLayoutGuide 的值传过来。
原生侧(iOS)代码大概长这样:
Dart 侧调用:
注意:
window.safeAreaInsets返回的是**点(point)**单位,和 Flutter 的逻辑像素是 1:1 对应的,不需要再除以devicePixelRatio。这一点和UIScreen.main.bounds不一样,后者是点单位但需要和 scale 配合理解。
3.2 什么时候该用 PlatformView,什么时候不该用
我的经验是:90% 的场景用 MediaQuery.viewPadding 就够了,只有以下三种情况才需要上 PlatformView:
- 需要灵动岛的精确 frame(x, y, width, height),而不只是边距;
- Flutter 页面被嵌在原生容器里,
MediaQuery被外层SafeArea或自定义MediaQuery改写过; - 需要监听
safeAreaInsets的实时变化(比如分屏、画中画场景),而 Flutter 的MediaQuery更新有延迟。
PlatformView 的代价是通信开销和平台耦合。每次调用都要走 MethodChannel,虽然单次开销不大(实测约 0.1-0.3ms),但如果你在 build 里频繁调用,累积起来会影响滚动帧率。所以原生值应该在 initState 或 didChangeDependencies 里取一次,缓存起来,而不是每次 build 都问一遍。
3.3 用 CustomPainter 画出预留区边界做调试
调试阶段,我习惯用一个 CustomPainter 把预留区画出来,直观看到边界在哪:
把它套在 Stack 最上层,跑起来就能看到红色半透明区域覆盖了灵动岛和 Home Indicator。这个方法在适配初期特别有用,能帮你快速确认"我以为的预留区"和"实际的预留区"是否一致。
4. 把预留区位置用起来的几个真实场景
4.1 自定义顶部搜索框贴合灵动岛
很多 App 会把搜索框放在顶部,灵动岛机型上如果直接贴顶,搜索框会被灵动岛挡住一半。正确做法是让搜索框的顶部对齐灵动岛底部,也就是 top = viewPadding.top。但如果你想让搜索框垂直居中于灵动岛和屏幕顶部之间,就需要 top = viewPadding.top / 2 - searchBoxHeight / 2。
这里 +8.0 是我个人的经验值。灵动岛底部到搜索框之间如果完全贴死,视觉上会显得很挤;留 8-12 像素的呼吸空间,观感最舒服。这个值没有标准答案,取决于你的设计规范,但不要留超过 16 像素,否则会显得搜索框"飘"在半空。
4.2 灵动岛下方的状态标签定位
有些 App 会在灵动岛正下方放一个"直播中""录音中"之类的状态标签,要求水平居中且紧贴灵动岛。这时候需要用到灵动岛的宽度信息。前面说过 MediaQuery 不给宽度,但我们可以用屏幕宽度减去左右边距来推算——不过灵动岛是居中的,所以直接用 screenWidth / 2 作为中心点即可。
用 Center 而不是手动算 left,是因为灵动岛本身是水平居中的,Center 能自动对齐,省去计算。这个技巧在横屏下也成立——横屏时灵动岛在左侧,Center 就不适用了,需要改成 Align(alignment: Alignment.centerLeft) 并加上 insets.left 的偏移。
4.3 全屏视频播放器的避让策略
全屏视频是个特殊场景:你既想让视频铺满屏幕(包括灵动岛区域),又不想让控制按钮被灵动岛挡住。这时候的策略是视频层不管预留区,控制层用 SafeArea 包起来。
SafeArea 默认会避让四边,但视频控制按钮通常只需要避让顶部和底部,左右可以贴边。可以用 SafeArea(top: true, bottom: true, left: false, right: false) 精确控制。这个组合我在多个视频类项目里用过,实测下来是最稳的——视频画面完整,控制按钮也不会被灵动岛或 Home Indicator 挡住。
5. 实测中遇到的坑和排查思路
5.1 模拟器数值和真机不一致
这是最常见的坑。我在 iPhone 15 Pro 模拟器上跑,viewPadding.top 返回 47.0,但真机上是 59.0。差了 12 像素,如果按模拟器数值做适配,真机上搜索框会被灵动岛挡住一截。
排查思路:永远以真机为准。模拟器的渲染层对灵动岛的模拟不完整,safeAreaInsets 的计算路径和真机不同。如果你手头没有真机,可以先用 59.0 这个经验值做适配,然后在真机上微调。另外,Xcode 的模拟器可以选择"Dynamic Island"机型,但即使选了,数值也可能不准。
提示:判断当前是否在模拟器上,可以用
Platform.environment['SIMULATOR_DEVICE_NAME'] != null(需要dart:io)。如果是模拟器,UI 适配可以放宽要求,但不要依赖它的数值做最终决策。
5.2 键盘弹出导致 viewPadding 突变
iOS 上键盘弹出时,MediaQuery.viewInsets.bottom 会变成键盘高度,但 viewPadding.bottom 不变。不过有些第三方输入法(比如某些带工具栏的输入法)会改变 safeAreaInsets,导致 viewPadding 也跟着变。这时候如果你的布局依赖 viewPadding.bottom,会出现跳动。
解决办法是区分 viewInsets 和 viewPadding:键盘相关用 viewInsets,硬件预留区用 viewPadding。两者不要混用。如果确实需要同时考虑,用 max(viewInsets.bottom, viewPadding.bottom) 取较大值。
5.3 嵌套 Scaffold 导致的 padding 消费问题
如果你的页面结构是 Scaffold 里套 Scaffold,或者 Scaffold 里套 SafeArea 再套 Scaffold,MediaQuery.padding 会被逐层消费,最终在深层 widget 里读到的 padding.top 可能是 0。这时候必须用 viewPadding,因为它是原始值,不会被消费。
我踩过一次这个坑:在一个 TabBarView 的某个 tab 里读 padding.top,返回 0,导致顶部内容被灵动岛挡住。排查了半天才发现是外层 Scaffold 的 AppBar 已经把 padding.top 消费掉了。改成 viewPadding.top 后问题解决。
5.4 热重载后预留区数值不更新
Flutter 的热重载(hot reload)不会重新读取 MediaQuery 的初始值。如果你在开发过程中旋转了屏幕,然后热重载,viewPadding 可能还是旋转前的值。这时候需要热重启(hot restart),或者手动触发一次 MediaQuery 重建。
这个坑不影响生产环境,但开发阶段很烦人。我的习惯是:涉及预留区适配的代码,改完后直接热重启,不要图省事用热重载。
6. 一套可复用的预留区适配方案
6.1 封装成 Widget 而不是工具方法
前面给的 DeviceReservedArea 是个工具类,但实际项目里我更推荐封装成 InheritedWidget,这样整个 widget 树都能拿到预留区信息,而且只在数据变化时重建依赖它的 widget。
在 MaterialApp 的 builder 里包一层:
这样业务代码里直接 ReservedAreaScope.of(context).isDynamicIsland 就行,不用每次都 MediaQuery.of。而且 InheritedWidget 的依赖机制能保证只在预留区变化时重建,性能更好。
6.2 不同机型的适配参数表
把常见机型的预留区参数整理成表,适配时直接查表,比每次跑真机快得多:
| 机型 | 竖屏 top | 竖屏 bottom | 灵动岛宽度 | 灵动岛高度 |
|---|---|---|---|---|
| iPhone 16 Pro Max | 62.0 | 34.0 | 126 | 37 |
| iPhone 16 Pro | 59.0 | 34.0 | 126 | 37 |
| iPhone 15 Pro Max | 59.0 | 34.0 | 126 | 37 |
| iPhone 15 | 59.0 | 34.0 | 126 | 37 |
| iPhone 14 Pro | 59.0 | 34.0 | 126 | 37 |
| iPhone 13 | 47.0 | 34.0 | - | - |
| iPhone SE 3 | 20.0 | 0 | - | - |
注意:灵动岛宽度和高度不是官方 API 提供的,是从真机截图中量出来的经验值。不同系统版本可能有 1-2 像素差异,做像素级对齐时需要真机验证。
6.3 适配检查清单
上线前,用这份清单过一遍:
- 竖屏下顶部内容是否避开了
viewPadding.top; - 横屏下左侧内容是否避开了
viewPadding.left; - 底部按钮是否避开了
viewPadding.bottom(Home Indicator); - 键盘弹出时布局是否正常(用
viewInsets而非viewPadding); - 嵌套
Scaffold场景下是否用了viewPadding而非padding; - 灵动岛机型和非灵动岛机型是否都测过;
- 模拟器和真机数值差异是否已确认(以真机为准)。
这份清单我每次做 iOS 适配都会过一遍,能挡掉 90% 的预留区相关问题。剩下的 10% 通常是设计稿本身就没考虑灵动岛,那就需要和设计沟通调整了。
6.4 一个容易忽略的细节:状态栏样式
灵动岛机型的顶部区域是黑色的(因为灵动岛本身是黑色挖孔),如果你的 App 用了浅色状态栏图标,在灵动岛区域会看不清。SystemUiOverlayStyle 需要根据背景色动态设置:
iOS 上 statusBarBrightness 才是控制状态栏图标颜色的,statusBarIconBrightness 在 iOS 上不生效(那是 Android 的参数)。这个坑我踩过,调了半天没反应,后来查文档才发现 iOS 只认 statusBarBrightness。Brightness.light 表示状态栏背景是浅色,图标用深色;Brightness.dark 反之。
7. 关于预留区适配的一点个人体会
做 iOS 适配这些年,我最大的感受是:预留区不是一个"值",而是一个"动态约束"。它随设备、方向、系统版本、甚至输入法状态变化。任何把它当成固定常量的代码,早晚会出问题。MediaQuery.viewPadding 是 Flutter 给的最直接的接口,但它只解决了"边距"问题,没解决"形状"和"位置"问题。真正要做像素级对齐,还是得靠真机实测加经验值兜底。
另外,灵动岛这块区域苹果是允许开发者做交互的(比如 Live Activity 就渲染在灵动岛周围),但 Flutter 侧目前没有官方 API 直接操作灵动岛。如果你的需求涉及灵动岛本身的交互,那已经超出 Flutter 的能力范围,需要走原生 ActivityKit,Flutter 只负责通过 MethodChannel 传数据。这个方向我在另一个项目里试过,链路比较长,但跑通之后效果不错,后续有机会再单独展开聊。
最后分享一个调试小技巧:在 main.dart 里加一段全局的预留区日志,每次启动都打印出来,适配阶段能省很多事。
这段日志在真机上一跑,所有关键数值一目了然。适配新机型时,先跑一遍看数值,再决定布局参数,比盲猜快得多。