ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

刘海屏适配全攻略:安全区原理与多端实现

刘海屏适配全攻略:安全区原理与多端实现 去年秋天改版项目的时候前端群里出现了一句很有意思的话“你说喜欢海我以为是我的齐刘海。”大家正在感慨产品经理又改了需求结果发现这句话其实是从一张移动端 UI 稿里截出来的。UI 设计师的本意是“用户喜欢大海”但到了前端这边看到“海”和“刘海”放在一起第一反应却是又要调刘海屏适配了。没错对于天天和移动端页面打交道的人来说“刘海”不是一个发型梗而是一个实打实的技术难点。从 iPhone X 发布开始刘海屏、挖孔屏、水滴屏轮番上阵屏幕顶部的传感器区域、底部的 Home Indicator 横条都会对页面的布局产生肉眼可见的挤压。如果适配不到位就会出现页面顶部被传感器遮挡、底部按钮被手势条挡住、横向滑动内容被圆角裁切等一系列问题。本文就围绕“红色感叹号这个‘齐刘海’”展开完整梳理移动端刘海屏适配的原理、CSS 写法、Android 端处理方式以及 H5、小程序、uni-app 中的常见适配策略。文章会包含可直接复制的代码示例、完整小案例和踩坑记录适合刚接触移动端适配的前端新人也适合正在做多端兼容的进阶开发者。1. 背景与核心概念1.1 刘海屏和安全区是什么刘海屏指的是屏幕顶部存在一块凹槽区域用来放置前置摄像头、听筒、Face ID 传感器等硬件。在 iPhone X 以及后续机型上这块凹槽是黑色的视觉效果上就像一个“刘海”。如果页面默认按照旧机型的矩形安全区域渲染那么刘海区域会直接遮挡状态栏下方的页面内容。除了顶部刘海iPhone 底部还有一条 Home Indicator 横条同样会覆盖页面底部内容。为了解决这个问题苹果在 iOS 11 中引入了 Safe Area安全区的概念。安全区是指屏幕中不受圆角、刘海、Home Indicator 影响的区域。开发者需要保证关键内容和可交互控件都处于安全区范围内。Android 端从 Android PAPI 28开始官方支持 DisplayCutout 接口用来获取屏幕上的刘海区域、挖孔区域坐标。不同厂商的机型差异较大但整体思路是一致的获取不可用区域然后预留出足够的 padding 或 margin。1.2 为什么需要前端主动适配有同学会问系统状态栏不是会自动占据顶部吗为什么还要前端适配这里需要理清一个关键点状态栏属于系统 UI但页面内容默认是在状态栏下方展示的。在 WebView 中如果页面没有开启全屏或者没有设置viewport-fitcover浏览器会主动把页面约束在安全区内内容不会跑到刘海下面。但问题在于很多移动端页面为了实现沉浸式体验会主动设置viewport-fitcover让页面延伸到整个屏幕。这时页面就不再被浏览器约束在安全区内开发者也必须自己处理安全区边界。否则就会出现下面的情况顶部标题栏被刘海遮住右侧按钮被刘海区域顶掉底部固定栏与 Home Indicator 重叠页面横屏时内容被圆角切掉所以刘海屏适配的本质是你选择了“全屏渲染”就必须自己承担安全区边界计算的责任。1.3 常见的刘海屏形态目前市面上的异形屏主要分为三类类型代表机型主要特点刘海屏iPhone X/XS/XR、华为 Mate 20部分顶部居中凹槽水滴屏华为/荣耀/OPPO/vivo 大量机型顶部居中较小的圆形凹槽挖孔屏荣耀/小米/三星部分机型左上角或居中的圆形开孔另外还有一种不太算“刘海”但同样影响布局的情况就是屏幕底部的手势条。iOS 的 Home Indicator 和 Android 全面屏手势提示条都会占用底部区域。适配时也需要一并考虑。2. 环境准备与版本说明2.1 开发环境本文示例主要以 Web 前端为主涉及 H5 页面、小程序和 uni-app 三种场景。建议环境如下操作系统Windows 10 / macOS 任意版本均可浏览器Chrome 最新版、Safari调试工具Chrome DevTools 的设备模拟器、微信开发者工具语言HTML、CSS、JavaScript框架原生 Web 示例 Vue 3 示例 微信小程序示例版本不需要完全一致下面演示的核心是 CSS 中的env()、constant()函数以及 Android 的DisplayCutout接口这些都是比较稳定的能力不依赖特定框架。2.2 待适配的设备范围适配工作开始前建议先明确测试设备清单iOSiPhone X、iPhone 11、iPhone 12/13/14 全系以及后续刘海屏机型Android带刘海或挖孔的主流机型至少覆盖 3 到 5 款工具Chrome 模拟器中的 iPhone X 预设、微信开发者工具中的 iPhone X 模拟如果团队没有这么多真机至少要保证 iOS 端覆盖到位因为 iOS 的刘海屏适配规则最严格。3. 核心适配方案拆解3.1 第一步viewport-fitcover在 H5 页面中适配刘海屏的第一个前提是设置viewport的viewport-fit属性。默认情况下viewport-fitauto页面宽度等于安全区宽度。浏览器会把页面限制在安全区内左右两侧不会延伸到屏幕边缘。当我们需要页面全屏显示时需要把viewport-fit设置为covermeta nameviewport contentwidthdevice-width, initial-scale1.0, viewport-fitcover设置之后页面会铺满整个屏幕包括刘海区域和底部 Home Indicator 区域。此时必须在 CSS 中配合env(safe-area-inset-*)使用否则内容就会被刘海遮挡。常用写法body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }关于safe-area-inset-*四个方向safe-area-inset-top顶部安全区距离刘海屏上有刘海时大于 0safe-area-inset-bottom底部安全区距离有 Home Indicator 时大于 0safe-area-inset-left左侧安全区距离横屏时如果存在圆角或刘海可能大于 0safe-area-inset-right右侧安全区距离横屏时可能大于 03.2 第二步constant() 与 env() 兼容env()是较新的 CSS 函数但 iOS 11.0 到 iOS 11.1 使用的是constant()而且constant()已经被废弃只是用来兼容老版本。更安全的写法是把constant()写在前面env()写在后面.safe-top { padding-top: constant(safe-area-inset-top); padding-top: env(safe-area-inset-top); }如果浏览器支持env()后面的声明会覆盖前面的声明如果不支持则会使用constant()。需要注意的是旧版浏览器在解析到不认识的函数时会直接丢弃这一条声明所以顺序不能反。在实际项目中更推荐使用 CSS 变量统一处理:root { --safe-top: constant(safe-area-inset-top); --safe-top: env(safe-area-inset-top); --safe-bottom: constant(safe-area-inset-bottom); --safe-bottom: env(safe-area-inset-bottom); } .page-header { padding-top: var(--safe-top); } .page-footer { padding-bottom: var(--safe-bottom); }这样后续只需要维护 CSS 变量代码会干净很多。3.3 Android 刘海屏适配Android 端从 Android 9API 28开始提供DisplayCutout接口用来获取屏幕的刘海区域。Android 应用默认的刘海屏适配方式是在非全屏状态下系统会自动把内容布局在安全区内。但对于 WebView 页面情况会复杂一些。Android WebView 本身会遵守系统安全区但部分 App 为了让 H5 页面铺满屏幕可能会在原生层面设置全屏渲染。这时 H5 页面拿不到类似于 iOSenv()的浏览器自动变量只能通过 JS 桥接获取刘海高度。常见做法是原生端在 WebView 注入一段 JavaScript把刘海高度通过window变量传递给 H5// 原生注入的代码 window.SAFE_AREA_TOP 88; window.SAFE_AREA_BOTTOM 34;H5 侧在页面加载后读取这些变量并添加对应的 CSS 类// 在页面加载完成后判断 document.addEventListener(DOMContentLoaded, () { const top window.SAFE_AREA_TOP || 0; const bottom window.SAFE_AREA_BOTTOM || 0; if (top 0) { document.documentElement.style.setProperty(--safe-top, top px); } if (bottom 0) { document.documentElement.style.setProperty(--safe-bottom, bottom px); } });对于纯 Android 原生应用可以使用WindowInsetsCompat.getDisplayCutout()来获取刘海区域。3.4 小程序和 uni-app 的适配微信小程序提供了wx.getSystemInfoSync()和wx.getMenuButtonBoundingClientRect()等接口。在小程序开发中适配刘海屏最常用的方式是获取胶囊按钮的位置然后动态计算导航栏高度。下面是一个获取导航栏高度的常用代码const systemInfo wx.getSystemInfoSync(); const menuRect wx.getMenuButtonBoundingClientRect(); const statusBarHeight systemInfo.statusBarHeight; // 状态栏高度 const menuTop menuRect.top; // 胶囊按钮顶部距离屏幕顶部距离 const menuHeight menuRect.height; // 胶囊按钮高度 // 导航栏实际高度 const navBarHeight (menuTop - statusBarHeight) * 2 menuHeight;在 uni-app 中可以直接使用官方提供的uni.getSystemInfoSync()获取safeAreaInsetsconst systemInfo uni.getSystemInfoSync(); const safeArea systemInfo.safeAreaInsets; console.log(safeArea.top); // 顶部安全距离 console.log(safeArea.bottom); // 底部安全距离需要注意safeAreaInsets在某些低版本基础库中可能不存在使用前建议做兼容判断。4. 完整实战案例一个 H5 页面适配刘海屏下面完成一个完整的适配案例。页面包含顶部标题栏、中间内容区和底部操作栏这是移动端最典型的三段式布局。4.1 创建项目结构项目结构非常简单notch-adapt-demo/ ├── index.html ├── css/ │ └── style.css └── js/ └── main.js4.2 编写 HTML!-- 文件路径index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, viewport-fitcover, user-scalableno title刘海屏适配示例/title link relstylesheet hrefcss/style.css /head body header classheader button classheader-back返回/button h1 classheader-title商品详情/h1 button classheader-more更多/button /header main classcontent section classcard h2什么是刘海屏适配/h2 p刘海屏适配的核心是安全区处理。在设置 viewport-fitcover 后页面内容会铺满整个屏幕如果不对安全区做处理刘海区域和底部手势条会遮挡页面内容。/p /section section classcard h2适配核心方案/h2 p使用 constant() 和 env() 函数读取 safe-area-inset-* 变量为页面预留安全距离。/p /section /main footer classfooter div classfooter-total合计¥ 299.00/div button classfooter-button立即购买/button /footer script srcjs/main.js/script /body /html4.3 编写 CSS这是适配的核心部分。页面使用弹性布局上下固定中间可滚动。/* 文件路径css/style.css */ :root { --safe-top: 0px; --safe-bottom: 0px; --header-height: 44px; --footer-height: 56px; } /* iOS 兼容写法 */ supports (padding-top: constant(safe-area-inset-top)) { :root { --safe-top: constant(safe-area-inset-top); --safe-bottom: constant(safe-area-inset-bottom); } } supports (padding-top: env(safe-area-inset-top)) { :root { --safe-top: env(safe-area-inset-top); --safe-bottom: env(safe-area-inset-bottom); } } * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, sans-serif; background-color: #f5f5f5; } body { display: flex; flex-direction: column; } /* 顶部导航栏 */ .header { position: fixed; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; height: calc(var(--header-height) var(--safe-top)); padding-top: var(--safe-top); padding-left: calc(16px var(--safe-left, 0px)); padding-right: calc(16px var(--safe-right, 0px)); background-color: #ffffff; border-bottom: 1px solid #ebebeb; z-index: 100; } .header-back, .header-more { min-width: 44px; height: 44px; border: none; background: transparent; font-size: 16px; color: #333333; cursor: pointer; } .header-title { font-size: 17px; font-weight: 600; color: #222222; } /* 中间内容区 */ .content { flex: 1; padding: calc(var(--header-height) var(--safe-top) 16px) 16px calc(var(--footer-height) var(--safe-bottom) 16px); overflow-y: auto; } .card { background-color: #ffffff; border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .card h2 { font-size: 18px; margin-bottom: 12px; color: #222222; } .card p { font-size: 15px; line-height: 1.7; color: #555555; } /* 底部操作栏 */ .footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; height: calc(var(--footer-height) var(--safe-bottom)); padding-bottom: var(--safe-bottom); padding-left: 16px; padding-right: 16px; background-color: #ffffff; border-top: 1px solid #ebebeb; z-index: 100; } .footer-total { font-size: 16px; color: #333333; } .footer-total::first-line { font-weight: 600; color: #ff5000; } .footer-button { height: 40px; padding: 0 24px; border: none; border-radius: 20px; background-color: #ff5000; color: #ffffff; font-size: 15px; cursor: pointer; }在这个示例中顶部导航栏和底部操作栏都使用了position: fixed并分别通过padding-top和padding-bottom撑出安全区。中间内容区的上下 padding 也把安全区计算进去了这样内容不会在滚动时被遮住。4.4 编写 JavaScriptJS 部分主要处理 Android WebView 环境下通过原生注入的变量来补充安全区尺寸。// 文件路径js/main.js (function () { // 从原生侧注入的安全区变量 const nativeSafeTop window.SAFE_AREA_TOP || 0; const nativeSafeBottom window.SAFE_AREA_BOTTOM || 0; const nativeSafeLeft window.SAFE_AREA_LEFT || 0; const nativeSafeRight window.SAFE_AREA_RIGHT || 0; const root document.documentElement; // 只有当原生传入的值大于 0 时才覆盖避免误伤 iOS 的 env() 方案 if (nativeSafeTop 0) { root.style.setProperty(--safe-top, nativeSafeTop px); } if (nativeSafeBottom 0) { root.style.setProperty(--safe-bottom, nativeSafeBottom px); } if (nativeSafeLeft 0) { root.style.setProperty(--safe-left, nativeSafeLeft px); } if (nativeSafeRight 0) { root.style.setProperty(--safe-right, nativeSafeRight px); } })();4.5 运行与验证直接在浏览器中打开index.html然后使用 Chrome DevTools 的模拟器打开开发者工具点击设备图标切换到设备模拟模式在设备列表中选择 iPhone X观察页面顶部和底部是否被刘海或 Home Indicator 遮挡预期效果顶部导航栏的返回按钮、标题、更多按钮完整可见底部操作栏的“立即购买”按钮没有被 Home Indicator 遮挡中间内容滚动时最后一条卡片可以完整滚到底部操作栏上方在真机上验证时重点对比 iOS 和 Android 的差异。部分 Android 机型的safe-area-inset-bottom可能为 0但底部仍有手势条遮挡这时可能需要结合原生注入的方案处理。5. 常见问题与排查思路5.1 页面顶部被刘海遮挡问题现象常见原因解决思路导航栏跑到刘海区域内部忘记设置 viewport-fitcover 或未使用 safe-area-inset-top检查 meta 标签添加对应的 padding-topfixed 导航栏高度异常只设置了 padding-top没有调整整体高度高度应设置为calc(基础高度 安全区高度)部分浏览器 safe-area 变量不生效浏览器版本过老使用 constant() 和 env() 双写法或通过 JS 获取排查顺序确认页面是否设置了viewport-fitcover打开浏览器开发者工具查看--safe-top是否生效确认导航栏的高度是否使用calc()正确计算如果依旧被遮挡临时给导航栏设置一个较大 padding验证安全区变量是否存在5.2 底部按钮被 Home Indicator 遮挡这种情况通常出现在 iPhone 全面屏机型上。页面底部固定栏使用了position: fixed但没有添加padding-bottom。解决方案.footer { height: calc(56px env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); }注意padding-bottom和height都计算了安全区高度否则会出现两个问题一是按钮被遮挡二是固定栏内部元素分布不均。5.3 横屏状态下左右内容被截断横屏时刘海的遮挡区域会转移到屏幕的左右两侧这时需要处理safe-area-inset-left和safe-area-inset-right。.container { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }同时建议给内容区域也加上安全区边距避免图片、滚动列表贴近圆角区域。5.4 小程序自定义导航栏位置偏移小程序中使用自定义导航栏时胶囊按钮是系统渲染的位置由微信控制。开发者需要动态计算导航栏高度。常见错误是直接使用statusBarHeight但忽略胶囊按钮的 top 和 height 之间的间距。正确的计算方式const systemInfo wx.getSystemInfoSync(); const menuRect wx.getMenuButtonBoundingClientRect(); const statusBarHeight systemInfo.statusBarHeight; const navBarHeight (menuRect.top - statusBarHeight) * 2 menuRect.height;5.5 Android WebView 中 safe-area 变量不生效很多 Android 机型在 WebView 中并不支持env(safe-area-inset-*)此时页面底部安全区无法自动计算。建议采用原生 H5 桥接方案由原生端注入安全区数值H5 侧使用 CSS 变量覆盖。另外要确认 App 的 WebView 是否开启了全屏渲染如果没有开启系统会默认处理安全区H5 侧反而不需要额外适配。6. 最佳实践与工程建议6.1 统一封装安全区工具函数在实际工程中不建议每个页面都重复写安全区获取逻辑。建议封装成工具函数统一处理 iOS 和 Android 的差异。// 文件路径utils/safe-area.js export function getSafeArea() { const safeArea { top: 0, bottom: 0, left: 0, right: 0 }; // CSS 环境变量优先 const style getComputedStyle(document.documentElement); safeArea.top parseFloat(style.getPropertyValue(--safe-top)) || 0; safeArea.bottom parseFloat(style.getPropertyValue(--safe-bottom)) || 0; safeArea.left parseFloat(style.getPropertyValue(--safe-left)) || 0; safeArea.right parseFloat(style.getPropertyValue(--safe-right)) || 0; // 原生注入兜底 safeArea.top window.SAFE_AREA_TOP || safeArea.top; safeArea.bottom window.SAFE_AREA_BOTTOM || safeArea.bottom; safeArea.left window.SAFE_AREA_LEFT || safeArea.left; safeArea.right window.SAFE_AREA_RIGHT || safeArea.right; return safeArea; }6.2 尽量减少位置固定元素刘海屏适配中最容易出问题的就是position: fixed元素。越多的 fixed 元素意味着越多的安全区处理点。一种更稳定的做法是使用 flex 布局实现上下固定、中间滚动的结构这样安全区只需要加在最外层容器上.page { display: flex; flex-direction: column; height: 100vh; padding-top: constant(safe-area-inset-top); padding-bottom: constant(safe-area-inset-bottom); padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }6.3 在视觉稿阶段提前沟通UI 设计稿通常是在安全区内绘制的但有时候设计稿的背景色会延伸到状态栏这时候如果开发直接把内容放在安全区内就不会有问题。但如果设计师希望内容铺满全屏同时又要避开刘海那么就需要明确标注安全区范围。建议在项目的设计规范中增加一条所有关键 UI 元素必须位于安全区边界内非关键背景可以延伸到屏幕边缘。6.4 真机测试清单安全区适配不能只依赖模拟器。模拟器只能模拟 iPhone 的刘海无法完整模拟所有 Android 机型的挖孔位置差异。建议准备一份真机测试清单iPhone X / XS / XR 系列iPhone 13/14 系列一台带水滴屏的华为/荣耀机型一台带挖孔屏的小米/OPPO/vivo 机型一台底部有手势条的老款 Android 机型测试时重点检查四个方向顶部、底部、左右圆角区域、横屏状态。6.5 版本兼容与兜底方案安全区适配代码必须考虑浏览器不支持的情况。常见的兜底方案是给元素设置默认 padding 值只有当env()生效时才使用环境变量.header { /* 兜底值 */ padding-top: 20px; /* 支持时覆盖 */ padding-top: constant(safe-area-inset-top); padding-top: env(safe-area-inset-top); }这种写法的好处是在不支持的浏览器中页面依然可用只是顶部间距为固定值在支持的环境中会自动切换到安全区高度。6.6 避免过度适配不是所有页面都需要全屏沉浸式效果。如果一个页面根本没有自定义顶部导航栏也没有底部固定按钮那么保持viewport-fitauto即可不需要做任何安全区处理。过度适配的典型表现是页面里大量使用env(safe-area-inset-*)导致在非刘海屏机型上出现奇怪的空白间距。解决方式是在设置 CSS 变量时增加判断supports (padding-top: env(safe-area-inset-top)) { /* 仅支持 env() 的环境才覆盖变量 */ :root { --safe-top: env(safe-area-inset-top); } }7. 总结与进阶方向回到开头那句“你说喜欢海我以为是我的齐刘海”。如果把它看作一次移动端适配的隐喻那它恰恰说明了前端开发中常见的信息错位设计师画的是理想中的沉浸式界面开发却要面对真实设备上的遮挡与挤压。但只要理清了刘海屏的适配原理掌握了安全区算法的规律这个“误解”其实很容易消除。本文主要梳理了以下关键内容刘海屏适配的核心是安全区包括safe-area-inset-top、safe-area-inset-bottom、safe-area-inset-left、safe-area-inset-rightH5 页面必须显式设置viewport-fitcover才能使用env()读取安全区变量iOS 需要同时兼容constant()和env()两种写法Android 则需要依赖原生端注入安全区数值小程序自定义导航栏需要动态计算状态栏高度和胶囊按钮位置固定元素是安全区适配的高危点建议优先使用 flex 布局替代真机测试和兜底方案是避免线上问题的最后防线下一步可以继续关注几个方向一是 CSS 新特性env()在更多浏览器上的支持情况二是 Android 各厂商的 DisplayCutout 差异三是跨端框架如 Taro、Flutter中安全区适配的封装思路。如果你正在开发移动端页面建议先把本文的 demo 在真机上跑一遍分别用 iPhone 和 Android 机型对比体验一次“安全区适配前”和“适配后”的差异。亲手踩过一次坑之后再遇到刘海屏相关需求就会变得从容很多。如果本文对你有所帮助可以收藏备用。后续遇到具体的适配问题也欢迎在评论区交流。
返回列表