uni-app 弹层在全面屏手机底部吃 UI 怎么办?手写一个自适配 SafePopup 组件

2026-09-08 0 665

我最近在做一款医疗预约小程序,底部弹窗是一个预约操作面板,最下面放了“确认预约”大按钮。结果用户反馈在 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 的 opacitytransform 来实现动画。对于组件来说,这部分不属于“安全区”需要讲解的范围,但为了组件能开箱即用,还是得写完。

给组件加上真正的开关动画

我们稍微修改一下 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,这样我们就能少画不少内联样式了。

如果你在项目中有更好的处理怪癖姿势,欢迎在评论区反向教我,我真的很想见识见识各家全面屏的奇葩设计。

uni-app 弹层在全面屏手机底部吃 UI 怎么办?手写一个自适配 SafePopup 组件
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 弹层在全面屏手机底部吃 UI 怎么办?手写一个自适配 SafePopup 组件 https://www.taomawang.com/web/uniapp/2728.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务