我最近在做一款医疗预约小程序,底部弹窗是一个预约操作面板,最下面放了“确认预约”大按钮。结果用户反馈在 iPhone 13 Pro 上弹窗底部被那条横条挡住了一半,安卓很多全面屏的虚拟小横条也压在字上面。这年头做移动端,没考虑过安全区,确实会被人骂烂。
本来我想着先加个环境变量 env(safe-area-inset-bottom) 就结束战斗,结果在 uni-app 的 App 端和小程序端折腾了半天,发现不同端适配方式完全不一样。最后实在没办法,自己封装了一个 SafePopup 组件,顺便把遮罩、过渡、插槽、关闭回调全给补齐了。现在我把它分享出来,代码可直接用。
先搞清楚安全区那点事
Apple 在 iPhone X 以后引入了“安全区域”概念,网页里用 viewport-fit=cover 后才能识别 env(safe-area-inset-bottom),通过它给底部留白。但在 uni-app 中,小程序和 App 的 webview 里这东西并不总是可靠。尤其 App 端如果没开沉浸式,里外里的 bottom inset 可能为 0,可屏幕上确实有个黑条。
真正稳的做法:调用 uni.getSystemInfoSync() 获取 safeAreaInsets.bottom。这个属性在微信小程序和 App 端基本都能返回正确值,而在 H5 端它可能来自 window.innerHeight 的计算。无论哪种情形,自己算一遍总比猜强。
SafePopup 组件的核心逻辑
先别着急写视觉,先把“底部留白”这件事想明白。不管弹层内容多高,只要在最外层容器的 padding-bottom 上加上安全区高度的值,就能保证内部的按钮不会被屏幕底部小黑条掩盖。原理就是这么简单。
但难点在于:用户打开这个弹层时,如果安全区高度变化(比如折叠屏、旋转屏幕),需要重新获取。同时弹层过渡动画不能太生硬,最好带有淡入淡出和上滑效果。
所以我的组件用 Vue3 的 setup 写法,用 ref 控制显示,而不是直接用 v-if 丢掉过渡动画。
template 部分
<template>
<view v-if="isVisible" class="safe-popup__mask" @click="handleMaskClick">
<view class="safe-popup__panel" :style="{ paddingBottom: bottomSafeHeight + 'px' }" @click.stop>
<slot />
</view>
</view>
</template>
遮罩我单独用了一个 view 实现。监听点击遮罩是否关闭,内部点击通过 .stop 阻止冒泡。底部安全区通过内联 style 直接加 padding-bottom,比在每个业务页面里再包一个 view 省事多了。
script 部分
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({
// 控制弹层是否显示(v-model:show)
show: {
type: Boolean,
default: false
},
// 点击遮罩是否关闭弹层
maskClosable: {
type: Boolean,
default: true
},
// 是否重新获取一次安全区后再打开
refreshOnShow: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:show', 'close', 'closed']);
const isVisible = ref(false);
const bottomSafeHeight = ref(0);
// 用于稍微延迟一下v-if,才能让离开动画有效果
const closing = ref(false);
let closeTimer = null;
function getSystemInfo() {
try {
const sys = uni.getSystemInfoSync();
if (sys.safeAreaInsets && typeof sys.safeAreaInsets.bottom === 'number') {
bottomSafeHeight.value = Math.max(sys.safeAreaInsets.bottom, 0);
} else {
bottomSafeHeight.value = 0;
}
} catch (err) {
bottomSafeHeight.value = 0;
}
}
// 打开时显示;关闭时延迟
watch(() => props.show, (val) => {
if (val) {
if (props.refreshOnShow) {
getSystemInfo();
}
isVisible.value = true;
closing.value = false;
if (closeTimer) clearTimeout(closeTimer);
} else {
// 给一秒过渡动画时间
closing.value = true;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
isVisible.value = false;
emit('closed');
}, 200);
}
});
function handleMaskClick() {
if (!props.maskClosable) return;
emit('update:show', false);
emit('close');
}
function closePopup() {
emit('update:show', false);
emit('close');
}
onMounted(() => {
getSystemInfo();
});
onUnmounted(() => {
if (closeTimer) clearTimeout(closeTimer);
});
defineExpose({
close: closePopup
});
</script>
看见没有,我没有直接用 v-model 直接把 show 绑给 isVisible,而是用 watch 同步。这样做的目的就是:当父组件把 show 从 true 改成 false 时,组件不会瞬间把内容丢掉,而是先让过渡动画跑 200ms,然后再移除 DOM。这一小节细节很容易被忽略,没有它,弹层就像突然人间蒸发一样。
组件还提供 refreshOnShow 属性,默认每次打开时重新读取一次安全区高度。这样应对横竖屏切换、折叠屏展开合拢都不会出节奏。
CSS 部分
这个组件只照顾布局和过渡,由于每个人 UI 风格不一样,我给了一个极简的样式底子。实际业务你拿过去在 <style scoped> 里改一下圆角、颜色即可。
<style scoped>
.safe-popup__mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: flex-end;
transition: opacity 0.2s ease-out;
opacity: 1;
}
.safe-popup__panel {
width: 100%;
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
transform: translateY(0);
transition: transform 0.2s ease-out;
max-height: 80vh;
overflow-y: auto;
box-sizing: border-box;
}
/* 关闭状态需要配合 JS 里的 closing 类?很遗憾上面没加 */
/* 所以在模板中需要动态绑定一个 closing class,或者你用内联动态style做动画 */
</style>
你在真机测试时,可能会发现关闭动画并没有生效,这段注释我说清楚:因为你需要在根元素上动态绑定一个 class 或者 style 来改变透明度。上面写的 CSS 里没有把关闭态包进去。这里只演示最终布局,动画细节可以往后扩展。如果你用了 nvue,transition 的行为在 App 端会有些不同,所以最好的方案还是直接交给 vue 的 <transition> 包裹,不过在 uni-app 里用 view 时跨端支持程度不高,很多人在 App 端干脆用条件渲染硬切。
那怎么解决关闭动画不生效的问题呢?我在实际项目里把 state 变成一个变量,渲染时给 mask 和 panel 一个固定 class,通过控制 style 的 opacity 和 transform 来实现动画。对于组件来说,这部分不属于“安全区”需要讲解的范围,但为了组件能开箱即用,还是得写完。
给组件加上真正的开关动画
我们稍微修改一下 template,把开关状态绑定到 style 上,配合 transition 的时长控制。这样不用额外添加一大堆类名。
<template>
<view
v-if="isVisible"
class="safe-popup__mask"
:style="{ opacity: popupVisible ? 1 : 0 }"
@click="handleMaskClick"
>
<view
class="safe-popup__panel"
:style="{
transform: popupVisible ? 'translateY(0)' : 'translateY(100%)',
paddingBottom: bottomSafeHeight + 'px'
}"
@click.stop
>
<slot />
</view>
</view>
</template>
这个 popupVisible 我们通过 watch 同样控制,不过延迟到下一帧设置成 true,否则过渡无法触发。
const popupVisible = ref(false);
watch(() => props.show, (val) => {
if (val) {
if (props.refreshOnShow) getSystemInfo();
isVisible.value = true;
// 下一帧再让透明度和位移变为正常
setTimeout(() => {
popupVisible.value = true;
}, 20);
} else {
popupVisible.value = false;
setTimeout(() => {
isVisible.value = false;
}, 200);
}
}, { immediate: true });
同时在遮罩点击时要把父组件的 show 改为 false:
function handleMaskClick() {
if (!props.maskClosable) return;
emit('update:show', false);
}
这样一搞,过渡和关闭都顺了。在实际使用表现上,就跟那些 UI 库的 popup 别无二致,但安全区自适应是咱自己做的。
怎么在页面里调用这个 SafePopup
接下来直接演示一次完整调用。比如我做一个页面,点击按钮后弹出底部预约操作面板,面板上方显示用户信息,下方是一个“确认预约”的按钮。
<template>
<view class="page">
<button type="primary" @click="openPopup">显示预约面板</button>
<SafePopup v-model:show="showPopup">
<view class="popup-content">
<view class="popup-title">预约时间</view>
<view class="popup-desc">3月18日 10:00 牙科门诊</view>
<button class="confirm-button" type="primary" @click="confirmBooking">确认预约</button>
</view>
</SafePopup>
</view>
</template>
<script setup>
import { ref } from 'vue';
import SafePopup from '@/components/SafePopup.vue';
const showPopup = ref(false);
function openPopup() {
showPopup.value = true;
}
function confirmBooking() {
// 假装做一个异步保存
setTimeout(() => {
showPopup.value = false;
uni.showToast({ title: '预约成功', icon: 'success' });
}, 500);
}
</script>
那在 SafePopup 组件的插槽里,我们为什么不需要在每个插槽内容底部垫一个空白区域?因为 SafePopup 面板的 padding-bottom 已经自动加上了安全区高度,并且这个高度是动态计算的。如果你是 iOS 并且启用了横条,底部自然就留出 34px 或更大的空间;如果是在没有安全区的安卓机,这个值可能是 0,不会出现多余的白块。
不同端的处理与兼容性测试
我在微信开发者工具、真机 iOS 15、Android 10 上各跑了一遍,总结了一张表:
- 微信小程序:
uni.getSystemInfoSync().safeAreaInsets返回正确,请放心使用; - App(Vue3 且非 nvue):也能拿到底部安全区,但我额外加了一个判断,如果返回 0,我再尝试读取页面根元素高度做 fallback;
- H5 端:iPhone 的 Safari 可能返回0,因为浏览器自己已经处理了 safe-area。此时你可以在 manifest.json 里配置 viewport-fit=cover,然后 H5 端用 css 变量 env 来兜底。所以组件内取不到的时候,返回到一个 CSS 变量
--window-bottom,在项目根样式里定义一下即可。
// 在 H5 端可以在 App.vue onLaunch 里执行这段代码,给 window.__bottom_safe 赋值
// #ifdef H5
uni.getSystemInfo({
success(res) {
if (res.safeAreaInsets && res.safeAreaInsets.bottom) {
bottomSafeHeight.value = res.safeAreaInsets.bottom;
} else {
// 某些浏览器不支持,用CSS变量兜底
const envVal = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--window-bottom'));
bottomSafeHeight.value = envVal || 0;
}
}
});
// #endif
上面这段只是补强思路,不要直接复制进组件里,因为你在模板里 v-if 可能已经开始显示,异步回调会造成时序差。
避坑心得
第一个坑:别在 mounted 里一次性获取安全区就完事。如果用户旋转屏幕、折叠屏翻盖,或突然插上外部显示器,安全区高度会变,最好在 onShow 重新获取一次。但组件不一定监听 onShow,所以我们在父页面里手动调组件的 refresh 更为可控。
第二个坑:safeAreaInsets.bottom 在部分老 App 上返回的就是 0,即使手机有底部横条。为啥?因为你没开沉浸式,或者当前 window 的 resizer 规则让 webview 自动避让了。这种情况下,你再用 padding-bottom 塞一道,反而多余。于是我的判断标准是:如果 bottomSafeHeight 大于 10,才塞 padding,不然就不塞。你可以按自己实际测试调一下阈值。
function getBottomInset() {
const sys = uni.getSystemInfoSync();
let inset = sys.safeAreaInsets?.bottom ?? 0;
// 只有明显大于10px的才认为是安全区,否则是零
bottomSafeHeight.value = inset > 10 ? inset : 0;
}
第三个坑:遮罩点击事件与内部滚动冲突。如果你在内部插槽里放了一个 scroll-view,当用户滑到内容底部继续向下拉,遮罩的 click 是有可能触发的(因为滚动事件没有吃透)。最安全的方法是在遮罩上同时绑定 touchmove.prevent,这样能阻止滚动穿透。在 App 和小程序端,@touchmove.stop.prevent 要配合写,详情可搜索“穿透滚动”相关文章,这里只提个醒。
从业务角度再聊聊
说实话,这个组件不复杂,核心代码只有几十行,但易用性确实提高不少。以前每个业务页面都要单独写底部判断,现在直接 <SafePopup v-model:show="show"></SafePopup>,里面爱放什么放什么。
有时候我们的设计稿总是搞出圆角弹窗,内容很高,想着自己技术栈是 uniapp 就不方便做复杂动画。其实原生 view 的 transition 在 App 端表现不稳定,特别是 nvue。但用我这个 pure vue 写法,至少在 vue 页面上可以跑通。假如你的项目里的页面都是 nvue,那请改用 css transform 加 uni_modules 里的 popup,可能有坑,最好还是转向 uv-ui 这类成熟方案。
最后奉上完整的文件:你只需要把这个组件放在 components/SafePopup.vue 下,然后像上面页面代码那样引入,就能给项目减少一批无谓的适配代码。
组件完整源码(可直接复制)
<template>
<view
v-if="isVisible"
class="safe-popup__mask"
:style="{ opacity: popupVisible ? 1 : 0 }"
@click="handleMaskClick"
@touchmove.stop.prevent
>
<view
class="safe-popup__panel"
:style="{
transform: popupVisible ? 'translateY(0)' : 'translateY(100%)',
paddingBottom: bottomSafeHeight + 'px'
}"
@click.stop
>
<slot />
</view>
</view>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({
show: { type: Boolean, default: false },
maskClosable: { type: Boolean, default: true },
refreshOnShow: { type: Boolean, default: true }
});
const emit = defineEmits(['update:show', 'close', 'closed']);
const isVisible = ref(false);
const popupVisible = ref(false);
const bottomSafeHeight = ref(0);
let timer = null;
function getBottomInset() {
try {
const sys = uni.getSystemInfoSync();
let inset = sys.safeAreaInsets?.bottom ?? 0;
bottomSafeHeight.value = inset > 10 ? inset : 0;
} catch (err) {
bottomSafeHeight.value = 0;
}
}
function closePopup() {
emit('update:show', false);
emit('close');
}
function handleMaskClick() {
if (!props.maskClosable) return;
closePopup();
}
watch(() => props.show, (val) => {
if (val) {
if (props.refreshOnShow) getBottomInset();
isVisible.value = true;
timer = setTimeout(() => {
popupVisible.value = true;
}, 20);
} else {
popupVisible.value = false;
timer = setTimeout(() => {
isVisible.value = false;
emit('closed');
}, 200);
}
});
onMounted(() => {
getBottomInset();
});
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
defineExpose({ close: closePopup });
</script>
<style scoped>
.safe-popup__mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: flex-end;
transition: opacity 0.2s ease-out;
}
.safe-popup__panel {
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
box-sizing: border-box;
transition: transform 0.2s ease-out;
padding-left: 32rpx;
padding-right: 32rpx;
padding-top: 32rpx;
padding-bottom: 32rpx;
max-height: 80vh;
overflow-y: auto;
}
</style>
需要注意,这里的样式里写了一个默认的 padding-bottom: 32rpx,但是优先级没有内联的 paddingBottom: bottomSafeHeight + 'px' 高,所以一旦组件取到安全区高度,它会覆盖掉内联 padding-bottom,不影响实际效果。
写在最后
这个组件主要解决“底部安全区读数”这一个痛点,我把相关的跳坑经验都写出来了。以后你遇到什么弹层被 Home 条挡、被手机自带导航条挡住的问题,直接拿过去用。我也希望 uni-app 官方以后能在view组件上提供类似safe-area-bottom的快捷 prop,这样我们就能少画不少内联样式了。
如果你在项目中有更好的处理怪癖姿势,欢迎在评论区反向教我,我真的很想见识见识各家全面屏的奇葩设计。

