横竖屏适配实战:从原理到高效解决方案
横竖屏切换,这破事又来了
上周上线前最后一天,测试甩给我一个bug:iPhone上横屏的时候页面布局炸了。输入框被键盘顶上去之后,收起键盘页面回不来了,留了一大片空白,按钮点不了。我第一反应是“不会吧,viewport不是早就处理好了吗”,结果真就栽在这上面。
项目是个纯H5活动页,部署在某个合作方的App内嵌WebView里,没有使用任何框架(连jQuery都没上),就原生JavaScript加一点CSS。本来以为这种小页面不会出啥幺蛾子,结果横竖屏适配这种老问题,每次换设备、换容器都能给你整出新花样。
先试了meta viewport那套老办法
第一反应就是去锁viewport:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
但这玩意儿只能控制初始缩放,对iOS Safari那个“横屏时自动缩放”的行为压根没用。而且我们这页面需要用户能上下滚动,直接锁死scale反而让正常浏览变得难受——别看文档说能解决问题,实际在微信、钉钉这些容器里表现不一,有的干脆无视你的maximum-scale。
后来我又试了监听orientationchange事件,手动触发scrollTo(0, 0):
window.addEventListener('orientationchange', () => {
setTimeout(() => {
window.scrollTo(0, 0);
}, 300);
});
看起来有点用,但时机太难把握。300ms是猜的,有时候不够,页面还没重绘完;设长了用户又感觉卡顿。更离谱的是Android和iOS触发顺序还不一样,Android可能先resize后orientation,iOS反过来。折腾了半天发现这个方案根本不可靠。
真正起作用的是这个CSS trick
后来翻到一条冷门建议:用vh单位做高度限制时要小心,因为iOS Safari在横竖屏切换后,100vh会错得离谱——它取的是竖屏时的视口高度,横过来还是按那个算,导致底部留白巨多。
这里我踩了个坑:之前整个页面最外层用了
.container {
height: 100vh;
overflow-y: auto;
}
以为这样就能撑满屏幕,结果横屏一转,100vh变成了竖屏的高(比如667px),而横屏宽度都800+了,明显不够用。更糟的是键盘弹起再收掉,浏览器没触发完整的layout更新,vh值就一直卡住不动。
解决方案是放弃vh,改用dvh(dynamic viewport height):
.container {
height: 100dvh;
overflow-y: auto;
}
这东西兼容性其实还可以,iOS 16+ 和 Android Chrome 100+ 都支持了。关键是在键盘弹出时,dvh会动态调整,不会像vh那样冻结。实测下来横屏切换+键盘收放都稳了。
但问题没完——老版本iOS不支持dvh怎么办?得降级处理:
/* 兼容写法 */
.container {
height: 100vh; /* fallback */
height: 100dvh;
overflow-y: auto;
}
这样现代浏览器用dvh,旧的继续用vh,至少不至于完全崩。
JS还得补一刀:强制重排
即使上了dvh,某些情况下(尤其是从键盘收回后)页面还是卡在错误位置。这时候得手动触发一次重排来“唤醒”浏览器的layout机制。
我的做法是在orientationchange和focusout事件里加一段“抖一下”的逻辑:
function triggerRelayout() {
const el = document.body;
const restoreStyle = el.style.cssText;
el.style.display = 'none';
el.offsetHeight; // 强制重排
el.style.cssText = restoreStyle;
}
window.addEventListener('orientationchange', () => {
setTimeout(triggerRelayout, 150);
});
// 输入框失焦时也可能需要重排
document.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('focusout', () => {
setTimeout(triggerRelayout, 150);
});
});
这段代码看起来很hack,但亲测有效。原理是通过临时隐藏元素触发浏览器同步计算布局,从而让dvh重新取值。虽然有点暴力,但在这种边缘场景下比等系统自己修复快得多。
这里注意我踩过好几次坑:不能直接操作目标元素(比如container),必须动body或根元素才容易触发全局layout;另外offsetHeight那一行不能少,否则异步了就没效果。
还有个小问题:安卓机软键盘行为不一致
改完之后iOS基本稳了,但同事反馈华为某机型横屏时输入框依然会被顶飞。查了一下发现是因为安卓WebView对visualViewport的支持更好,于是补了个判断:
if ('visualViewport' in window) {
visualViewport.addEventListener('resize', () => {
document.body.style.height = ${visualViewport.height}px;
});
}
配合CSS:
body {
height: 100vh; /* 默认 */
overflow: hidden;
}
.container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow-y: auto;
}
这套组合拳下来,安卓也能响应软键盘的动态高度变化。不过要注意visualViewport的height在键盘收起动画过程中是连续变化的,可能会引起频繁重绘,所以只在确定是安卓环境时才开。
怎么判断安卓?很简单:
const isAndroid = /android/i.test(navigator.userAgent);
虽然UA可以伪造,但在这个业务场景里足够用了。
最终方案总结
现在我的横竖屏适配策略是三层保障:
- CSS层优先使用
100dvh,降级到100vh - 支持
visualViewport的设备(主要是安卓)用JS动态更新body高度 - 所有设备在横屏切换和输入框失焦后强制触发一次重排
完整代码差不多长这样:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
.container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.content {
min-height: 100dvh;
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<!-- 页面内容 -->
<input type="text" placeholder="试试横竖屏切换">
<textarea placeholder="再试试键盘弹出"></textarea>
</div>
</div>
<script>
// 强制重排函数
function triggerRelayout() {
const el = document.body;
const restoreStyle = el.style.cssText;
el.style.display = 'none';
el.offsetHeight;
el.style.cssText = restoreStyle;
}
// 视觉视口适配(主要用于安卓)
if ('visualViewport' in window) {
visualViewport.addEventListener('resize', () => {
document.body.style.height = ${visualViewport.height}px;
});
}
// 横屏切换后重排
window.addEventListener('orientationchange', () => {
setTimeout(triggerRelayout, 150);
});
// 输入框失焦后尝试修复布局
document.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('focusout', () => {
setTimeout(triggerRelayout, 150);
});
});
</script>
</body>
</html>
还剩一点小毛病,但不影响上线
改完后测试跑了十几台设备,大部分没问题。只有小米某个MIUI版本在横屏切换时会有短暂闪烁,应该是display:none引起的重绘抖动。暂时没想到优雅解法,考虑换成transform: scale(0)也许能缓解,但风险更高,这次先不上了。
还有一个问题是dvh在部分低版本Chrome中表现为vh,也就是不会动态调整,但这属于渐进增强范畴,至少页面不会崩,能用。
总的来说,这波优化没做到完美,但至少把致命问题都挡住了。这类底层渲染问题,往往没有银弹,只能靠组合拳 + 实机测试来兜底。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论