软键盘常见问题(二)

274 阅读3分钟

前端 H5 软键盘常见问题及解决方案

1. 键盘弹起导致页面布局错乱 / 整体上推

问题:iOS Safari 中键盘弹起时,页面(body 或容器)会被整体上推,收起后页面可能无法回弹,出现底部留白。

解决方案:监听 visualViewport API 动态调整布局

// 利用 visualViewport 获取真实可视区域
function initVisualViewport() {
  if (!window.visualViewport) return;

  function onResize() {
    const { height } = window.visualViewport;
    document.documentElement.style.setProperty('--vh', `${height * 0.01}px`);
  }

  window.visualViewport.addEventListener('resize', onResize);
  window.visualViewport.addEventListener('scroll', onResize);
  onResize();
}

initVisualViewport();
/* 根元素使用动态视口单位 */
:root {
  --vh: 1vh;
}

.container {
  height: 100vh;
  height: calc(var(--vh, 1vh) * 100);
  /* 兼容不支持 visualViewport 的环境 */
}

也可以使用 window.visualViewport.offsetTop 判断是否被键盘遮挡,并手动 scrollTo


2. iOS 键盘收起后页面不回弹(留白问题)

问题:iOS 12+ Safari 中,输入框失焦后页面底部出现空白区域,滚动条位置异常。

解决方案一:滚动到顶部 / blur 时强制滚动

function fixIOSBlank() {
  const inputs = document.querySelectorAll('input, textarea');

  inputs.forEach(input => {
    input.addEventListener('blur', () => {
      // 延迟执行,等键盘收起动画结束
      setTimeout(() => {
        // 方案 A:滚动到顶部
        window.scrollTo(0, 0);
        document.body.scrollTop = 0;
        document.documentElement.scrollTop = 0;

        // 方案 B(可选):滚动到当前焦点元素
        // const active = document.activeElement;
        // if (active && active.scrollIntoView) {
        //   active.scrollIntoView({ behavior: 'smooth' });
        // }
      }, 100);
    });
  });
}

解决方案二:聚焦时延迟滚入可视区

document.querySelectorAll('input, textarea').forEach(el => {
  el.addEventListener('focus', () => {
    // 等待键盘弹起动画后,再把输入框滚入可视区
    setTimeout(() => {
      el.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }, 300);
  });
});

preventScrollfocus() 的参数,不是 scrollIntoView() 的参数;如果要避免浏览器自动滚动,可以先 el.focus({ preventScroll: true }),再手动控制滚动位置。


3. fixed 定位元素在键盘弹起时表现异常

问题:键盘弹起后,position: fixed 的元素(如底部工具栏)可能表现异常:

  • iOS 上视觉视口变化后,fixed 元素可能看起来像被页面滚动带走
  • Android 上 fixed 元素可能被键盘遮挡或被推到键盘上方

解决方案:根据场景使用 absolutefixed,并用 visualViewport 动态修正底部距离

<div class="page-wrapper">
  <div class="content">...</div>
  <div class="bottom-bar">底部栏</div>
</div>
.page-wrapper {
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: calc(var(--vh, 1vh) * 100);
}

.bottom-bar {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
}
// 实时更新 bottom-bar 位置
function getKeyboardHeight() {
  if (!window.visualViewport) return 0;

  const { height, offsetTop } = window.visualViewport;
  return Math.max(0, window.innerHeight - height - offsetTop);
}

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', () => {
    document.querySelector('.bottom-bar').style.bottom = `${getKeyboardHeight()}px`;
  });
}

4. Android windowSoftInputMode 的影响

问题:在 Android WebView 或混合 App 中,键盘弹起行为受 windowSoftInputMode 控制,H5 侧无法直接干预但需要适配。

模式行为
adjustResizeActivity 大小调整,visualViewport 会变化(推荐)
adjustPan页面平移,H5 布局不变
adjustNothing不做任何调整,输入框可能被遮挡

H5 侧适配方案:无论原生配置哪种模式,都用 visualViewport 做 fallback:

function getKeyboardHeight() {
  if (!window.visualViewport) return 0;

  const { height, offsetTop } = window.visualViewport;
  return Math.max(0, window.innerHeight - height - offsetTop);
}

function isKeyboardOpen() {
  return getKeyboardHeight() > 100; // 阈值
}

5. 输入框被键盘遮挡(不自动滚动)

问题:某些 Android 浏览器和 WKWebView 中,键盘弹起时输入框不会自动滚入可视区域。

解决方案:scrollIntoView + 延迟

function ensureVisible(input) {
  if (!input) return;

  // iOS 需要延迟等待键盘动画
  const delay = /iPhone|iPad|iPod/.test(navigator.userAgent) ? 300 : 100;

  setTimeout(() => {
    input.scrollIntoView({
      block: 'center',
      behavior: 'smooth'
    });
  }, delay);
}

// 绑定到所有输入框
document.querySelectorAll('input, textarea').forEach(el => {
  el.addEventListener('focus', () => ensureVisible(el));
});

6. iOS input 框获得焦点时页面缩放

问题:iOS 上 <input>textareaselectfont-size 小于 16px 时,页面可能会自动缩放。

解决方案:强制设置 font-size >= 16px

input, textarea, select {
  font-size: 16px !important;
}

如果设计稿字号必须小于 16px,可以通过 transform: scale() 缩小容器来视觉欺骗:

.input-wrapper {
  transform: scale(0.875); /* 14px 视觉效果 */
  transform-origin: left center;
}

.input-wrapper input {
  font-size: 16px; /* 实际 16px,不会被缩放 */
}

7. 第三方键盘(搜狗 / 百度)导致的兼容问题

问题:第三方输入法键盘高度与系统键盘不一致,且可能多次触发 resize 事件。

解决方案:防抖 + 动态计算

function createKeyboardHandler(callback) {
  let lastHeight = 0;
  let timer = null;

  return function handler() {
    clearTimeout(timer);
    timer = setTimeout(() => {
      if (!window.visualViewport) return;

      const { height, offsetTop } = window.visualViewport;
      const keyboardHeight = Math.max(0, window.innerHeight - height - offsetTop);

      // 只有高度变化超过阈值才视为有效变化(过滤第三方键盘抖动)
      if (Math.abs(keyboardHeight - lastHeight) > 50) {
        lastHeight = keyboardHeight;
        callback({
          isOpen: keyboardHeight > 100,
          height: keyboardHeight
        });
      }
    }, 150); // 防抖 150ms
  };
}

// 使用
const onKeyboardChange = createKeyboardHandler(({ isOpen, height }) => {
  console.log('键盘状态:', isOpen ? '打开' : '关闭', '高度:', height);
  document.querySelector('.bottom-bar').style.transform =
    isOpen ? `translateY(-${height}px)` : 'translateY(0)';
});

window.visualViewport?.addEventListener('resize', onKeyboardChange);

8. 不要依赖 interactive-widget=resizes-content 解决 iOS 软键盘问题

interactive-widget 不是 CSS 属性,而是 viewport meta 的配置项,用来声明虚拟键盘出现时视口如何变化:

<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">

但截至目前,iOS Safari / WebKit 仍未稳定支持该配置,不能作为 iOS 16+ / Safari 16+ 的软键盘兼容方案。也就是说,下面这种 CSS 写法是无效的:

html {
  interactive-widget: resizes-content;
}

实际项目中应继续以 visualViewport 为主,配合 focus / blurscrollIntoView、安全区和业务容器滚动策略做兜底。interactive-widget=resizes-content 可以作为支持该能力的 Chrome / Firefox 浏览器的渐进增强,但不要把它当成 iOS 方案。


9. Chat 场景 —— 输入框 + 底部栏随键盘上移

这是最常见的复杂场景,一个完整的封装方案:

<div class="chat-page">
  <div class="chat-messages" id="chatMessages">
    <!-- 消息列表 -->
  </div>
  <div class="chat-input-bar" id="chatInputBar">
    <input type="text" id="chatInput" placeholder="输入消息..." />
    <button id="sendBtn">发送</button>
  </div>
</div>
:root {
  --keyboard-height: 0px;
  --vh: 1vh;
}

.chat-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: calc(var(--vh, 1vh) * 100);
  overflow: hidden;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  padding-bottom: calc(var(--keyboard-height) + 60px);
  /* 留出底部输入栏 + 键盘的空间 */
}

.chat-input-bar {
  position: fixed;
  bottom: var(--keyboard-height);
  left: 0;
  right: 0;
  display: flex;
  padding: 8px 16px;
  background: #fff;
  border-top: 1px solid #eee;
  transition: bottom 0.25s ease;
}

.chat-input-bar input {
  flex: 1;
  height: 40px;
  font-size: 16px; /* 防止 iOS 缩放 */
  border: 1px solid #ddd;
  border-radius: 20px;
  padding: 0 16px;
}
class KeyboardManager {
  constructor() {
    this.keyboardHeight = 0;
    this.isOpen = false;
    this.init();
  }

  init() {
    // 方案 1:visualViewport(现代浏览器)
    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', () => this.onViewportResize());
      window.visualViewport.addEventListener('scroll', () => this.onViewportResize());
      this.updateVH();
    }

    // 方案 2:原生交互(WebView 场景)
    this.setupNativeBridge();

    // 方案 3:focus/blur fallback
    this.setupFocusFallback();
  }

  updateVH() {
    const vh = window.visualViewport.height * 0.01;
    document.documentElement.style.setProperty('--vh', `${vh}px`);
  }

  onViewportResize() {
    this.updateVH();

    const { height, offsetTop } = window.visualViewport;
    const keyboardHeight = Math.max(0, window.innerHeight - height - offsetTop);
    const isOpen = keyboardHeight > 100;

    if (keyboardHeight !== this.keyboardHeight || isOpen !== this.isOpen) {
      this.keyboardHeight = keyboardHeight;
      this.isOpen = isOpen;

      document.documentElement.style.setProperty(
        '--keyboard-height',
        `${isOpen ? keyboardHeight : 0}px`
      );

      this.onKeyboardChange?.(isOpen, keyboardHeight);
    }
  }

  setupNativeBridge() {
    // 与原生 WebView 通信获取键盘高度(适用于混合 App)
    // Android: 通过 JSBridge 接收 keyboardWillShow / keyboardWillHide
    window.onKeyboardShow = (height) => {
      this.keyboardHeight = height;
      this.isOpen = true;
      document.documentElement.style.setProperty('--keyboard-height', `${height}px`);
    };

    window.onKeyboardHide = () => {
      this.keyboardHeight = 0;
      this.isOpen = false;
      document.documentElement.style.setProperty('--keyboard-height', '0px');
    };
  }

  setupFocusFallback() {
    // visualViewport 不可用时的降级方案
    document.addEventListener('focusin', (e) => {
      if (e.target.matches('input, textarea')) {
        setTimeout(() => {
          e.target.scrollIntoView({ block: 'center', behavior: 'smooth' });
        }, 300);
      }
    });

    document.addEventListener('focusout', () => {
      // iOS 键盘收起后页面回弹
      setTimeout(() => {
        window.scrollTo(0, Math.max(0, document.body.scrollTop));
      }, 100);
    });
  }
}

// 使用
const kb = new KeyboardManager();

kb.onKeyboardChange = (isOpen, height) => {
  // 键盘状态变化时的业务逻辑
  const messages = document.getElementById('chatMessages');

  if (isOpen) {
    // 键盘弹起,滚动消息列表到底部
    setTimeout(() => {
      messages.scrollTop = messages.scrollHeight;
    }, 250);
  }
};

总结:问题速查表

问题核心方案关键 API
页面上推 / 留白动态视口单位 --vhvisualViewport.height
fixed 失效改用 absolute + 动态 bottomvisualViewport
输入框被遮挡scrollIntoView + 延迟Element API
iOS 页面缩放font-size >= 16pxCSS
键盘高度获取不准防抖 + 阈值过滤visualViewport
第三方键盘抖动防抖 + 变化量阈值JS debounce
Android WebView原生桥接 + visualViewport 兜底JSBridge
支持 interactive-widget 的浏览器viewport meta 渐进增强,不作为 iOS 方案<meta name="viewport">

核心思路:不依赖 window.innerHeight 的变化,而是使用 visualViewport API 获取真实可视区域,结合 CSS 变量动态更新布局,同时在旧浏览器做 focus/blur 事件的降级处理。