横竖屏适配实战:从原理到高效解决方案

殿薇 Dev 优化 阅读 2,627
赞 23 收藏
二维码
手机扫码查看
反馈

横竖屏切换,这破事又来了

上周上线前最后一天,测试甩给我一个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机制。

我的做法是在orientationchangefocusout事件里加一段“抖一下”的逻辑:

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,也就是不会动态调整,但这属于渐进增强范畴,至少页面不会崩,能用。

总的来说,这波优化没做到完美,但至少把致命问题都挡住了。这类底层渲染问题,往往没有银弹,只能靠组合拳 + 实机测试来兜底。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论