17fb9ca6-0214-eb11-8da9-e4434bdf6706.png

当前app往往是native + h5的混合开发模式, 那么原生的体验不用说, 自然是极好的, 而h5总是有各种各样的兼容性问题以及一些体验问题. 今天我要来说说键盘遮挡输入框的问题, 并和大家分享我的解决方案.

首先, 需要区分iOS和Android.

  1. iOS: 我司用的是UIWebview, 经过验证, >=iOS8.0的版本都会在键盘弹起时自动调整输入框的位置, 让它始终位于可视区域内, 所以在iOS中一路绿灯.
  2. Android: 我用安卓手机看了很多h5页面的表现, 其中包括滴滴、支付宝等大型公司的一些表单页面, 当input聚焦, 键盘弹起后, 有时webview会自动调整input使其处于可视区域内; 但有时却不会调整input位置, 这样键盘就把input给遮住了, 用户想要看到自己输入的文字, 那就必须把页面往上拖动. 至此, 输入框被遮挡而引发的用户体验问题就凸显出来了.

那么, 该怎么解决呢? 我有以下几个思路:

首先我想到的肯定是scrollIntoViewIfNeeded, 以及网上的很多解决方案也是这个. 可是这个api还是别指望了, hybrid app上根本不靠谱, 大把安卓的webview都不兼容.

接下来我要开始表演了

1.不解决

把滴滴的app拿给交互看, '你看, 人家滴滴的h5也是这样子, 这个就是安卓浏览器的一个bug啊, 没法解决' --.--(噗嗤)

18fb9ca6-0214-eb11-8da9-e4434bdf6706.png
不好意思, 我被啪啪打脸了

2.让原生处理, 当键盘弹起时, 把webview往上移动键盘的高度

那么当键盘弹起的时候, app是怎么做到原生控件不被键盘遮挡的呢? 其实很简单, 原生可以监听键盘弹起的事件, 并获取键盘的高度, 将页面往上移动与键盘等高的距离.

于是, 我拍起桌子, 冲着客户端组的同事说: "你们app就该在键盘弹起的时候, 把webview往上移啊!"

19fb9ca6-0214-eb11-8da9-e4434bdf6706.png
不好意思, 我又被打了, 出血量进一步加大

说白了, 其实我们写的h5页面和native并无瓜葛, 只是因为此时在同一app内, 所以让native监听键盘弹起事件移动webview听起来还算靠谱, 但假如现在用安卓手机浏览器打开页面, 那此时键盘遮挡input的问题还是存在.

****************************************求人不如求己****************************************

解决思路: 只需要在键盘弹起的时候, 判断当前focus的input是否在可视区域内, 如果不在可视区域内, 通过某种途径算出键盘的高度, 然后将input往上移动键盘高度的距离即可.

那么该怎么计算出键盘高度呢?

1bfb9ca6-0214-eb11-8da9-e4434bdf6706.png
以下不靠谱的思路又来了

3.在页面添加一个fixed button, 当键盘弹起时, 该button会随着键盘往上移动, 这样就能算出键盘高度了

这时候, 我打算靠自己h5来实现整个功能了, 别人都是靠不住的.

然后我就想封装一个Input组件, 大概是这个样子:

<>
  

通过这个组件, 每当键盘弹起的时候, 底部fixed的div, 会紧贴着键盘往上移动, 这样子就可以通过键盘弹起前div.getBoundClientRect().top - 键盘弹起后div.getBoundClientRect().top, 算出键盘的高度.

可是仔细一想, 这样虽然能获取到键盘高度, 看着似乎也能解决键盘遮挡的问题, 可是要应用到项目中, 那岂不是要将项目里所有的<input />替换成上面封装的<Input />, 而且不仅仅是这样, <Input />所在之处, 必然添加一个无用的fixed button. 另外, 再往远了想, 如果要应用到其他项目, 难道要所有同事, 所有项目都这么干吗?

显然...我还会被打~

这方案不行, 我下一步该怎么走了呢? 我到底该怎么获取到键盘高度呢?

这时候, 我想到了jsbridge(偷笑).

4.实现一个获取键盘高度的jsbridge

1cfb9ca6-0214-eb11-8da9-e4434bdf6706.png
安卓大佬, 我又回来了, &amp;amp;amp;amp;amp;amp;amp;amp;amp;#39;求人不如求己, 这话我没说过~~ 所以你能帮我实现一个获取键盘高度的jsbridge吗? 我知道这个方案不通用, 可是咱们好歹先搞个保底方案啊!&amp;amp;amp;amp;amp;amp;amp;amp;amp;#39;

过了几分钟, 安卓大佬就按照我的意愿给我写了个api, 舒服~~. 于是代码是这样的:

handleFocus = () => {
  bridge
    .getKeyboardHeight()
    .then(res => {
      window.alert(res.value);
  });
}
<input onFocus={this.handleFocus} />

大佬还很善良的将获取到的键盘高度根据dpr(device pixel ratio设备像素比), 转换成我们需要的css像素. 哈哈, 完美, 准备下班~

等等, 这有些安卓机卡, 在执行handleFocus的时候键盘还没弹起(部分机型有延迟), 所以此时app还没获取到键盘高度, 返回了0; "嗨, 好说好说, 我在加个setTimeout(, 200)". 搞定收工~

1efb9ca6-0214-eb11-8da9-e4434bdf6706.png

可是, 再想想, 真的搞定了吗, 这种方案, 自己作为一名有追求的开发, 这么丑陋且不通用的解决方案你会接受吗? 答案显然是无法接受的...

因为我们不仅仅要依靠native, 而且所有input出现的地方都要实现onFocus事件. 这显然是不合理的, 因为真的不够通用, 那么我们到底有没有一个方案, 只需要写一次即可处理项目里的所有input呢?

-----------------------------------------分割线-------------------------------------------

以下将会是正经的解决方案思路:

1.首先, 曾经在网上看到过这么一段代码, 这里给了我一部分思路:

window.addEventListener('resize', () => {     
    if (document.activeElement.tagName == 'INPUT') {         
        //延迟出现是因为有些 Android 手机键盘出现的比较慢
         window.setTimeout(() => {             
            document.activeElement.scrollIntoViewIfNeeded();
         }, 100);
     }
});
/* 当然这段代码是行不通的, 因为scrollIntoViewIfNeeded的兼容性问题 */

在这里我们能提取出一个思路, 键盘弹起会触发window.onresize事件, 那么刚才那个疑惑(什么时候去获取键盘高度)就解决了.

而且, 在这段代码中我们还有另外一个收获, 我们可以通过document.activeElement获取当前聚焦的元素. 所以这时候我想到了下面这个方案, 暂时用伪代码表示:

window.addEventListener('resize', () => {     
    if (document.activeElement.tagName == 'INPUT') {         
        包裹document.activeElement的可滚动元素向上滚动键盘的高度
     }
});

2. 怎么获取键盘高度呢, 曾经我在网上看到过另外一段代码, 博主号称只要添加以下这行代码, 即可解决键盘遮挡输入框的问题(大家不用试了, 显然这肯定是不可能的~~).

document.body.height = window.screen.availHeight;

但是这段代码, 给了我另一个思路. "是啊, 既然当键盘弹起的时候会触发window.onresize事件, 那么视口的高度应当会减小, body的高度也应当会减小."

于是我就开始尝试了起来. 首先

window.onresize = () => {
  window.alert(window.screen.availHeight);
}

很遗憾, 经过调试发现, window.screen.availHeight的值并没有发生改变;

那么document.body.clientHeight或者offsetHeight呢?

window.onresize = () => {
  window.alert(document.body.clientHeight);
}

哇, 发现新大陆了, 思路从此打通, 经过调试, document.body.clientHeight的值发生了改变~~

1ffb9ca6-0214-eb11-8da9-e4434bdf6706.png

到这, 其实整体思路已经打通, 但是还有一个小点, 就是我们真的需要把input往上移动与键盘等高的距离吗? 其实我心里有点小担忧, 我怕会出现这种情况: input往上移动键盘的高度后, 会超出视口的顶部可是区域(也就是移过头了)--这个我不是很确定, 仅仅只是一种担忧, 所有我采取了稳妥的解决方案, 将input移动至视口可视区域的底部. 那么此时要移动多少距离呢? 我画了一幅图:

20fb9ca6-0214-eb11-8da9-e4434bdf6706.png

我们先来看看左图, 通过prev = input.getElementBoundingClientRect().bottom可以获取input底部距离视口顶部的距离; 然后弹起键盘后, 我们将input移动到页面可视区域的底部, 此时cur = input.getElementBoundingClientRect().bottom === document.body.clientHeight; 所以我们可以通过prev - cur得到input需要移动的距离.

说道移动input, 要怎么移呢? 分为2种情况, 一种是流式布局, 此时我们只需要调用window.scrollTo(x, y); 第二种情况就是我们给html, body, div#root都加了height: 100%样式, 然后给每个页面的包裹元素添加{height: 100%; overflow-y: auto;}样式. 这样就需要设置pageContainer.scrollTop -= input需要移动的距离;

至此, 实现通用方案的思路已经理清了, 接下来让我们来看看代码:

首先, 我们需要使用到getBoundingClientRect这个api, 让我们来封装一个通用的函数;

function getBoundingClientRect(element) {
  if (!element) {
    return null;
  }
  if (!element.getClientRects().length) {
    return null;
  }

  return element.getBoundingClientRect();
}

其次, 需要一个通用函数来获取元素目前在垂直方向上已滚动的距离, 该函数需要区分window和普通元素;

// 获取window的偏移量, 或者元素的scroll偏移量
function getScroll(target = window, isTop = true) {
  if (typeof window === 'undefined') {
    return 0;
  }

  const prop = isTop ? 'pageYOffset' : 'pageXOffset';
  const method = isTop ? 'scrollTop' : 'scrollLeft';
  const isWindow = target === window;

  const ret = isWindow ? target[prop] : target[method];

  return ret;
}

再接着我们需要封装一个滚动元素的通用函数, 也需要区分window和普通元素;

function scrollTo(target, originScrollTop, targetScrollTop) {
  if (!target) return;
  if (!isNumber(originScrollTop) || !isNumber(targetScrollTop)) return;

  const reqAnimFrame = getRequestAnimationFrame();

    let start = null;
    // 该函数的参数: 现在距离最开始触发requestAnimationFrame callback的时间间隔, 但是它的值不为0
    const frameFunc = (timestamp) => {
      if (!start) {
        start = timestamp;
      }
      const realTimestamp = timestamp - start;// 当前时间
      const isWindow = target === window;

      if (isWindow) {
        window.scrollTo(
          window.pageXOffset,
          easeInOutCubic(realTimestamp, originScrollTop, targetScrollTop, 200),
        );
      } else {
        target.scrollTop = easeInOutCubic(realTimestamp, originScrollTop, targetScrollTop, 200);
      }

      if (realTimestamp < 200) {
        reqAnimFrame(frameFunc);
      }
    };

    reqAnimFrame(frameFunc);
}

写好滚动函数, 为了优化体验, 让滚动不显得过于突兀, 我加入了缓动函数以及帧动画

/**
 *
 * @param {*} t timestamp: 当前时间 - 动画最开始执行的那一刻
 * @param {*} b 开始状态
 * @param {*} c 结束状态
 * @param {*} d duration: 期待动画持续的时间
 */
function easeInOutCubic(t, b, c, d = 450) {
  const cc = c - b;

  t /= d / 2;
  if (t < 1) {
    return cc / 2 * t * t * t + b;
  }

  // eslint-disable-next-line
  return cc / 2 * ((t -= 2) * t * t + 2) + b;
}

function getRequestAnimationFrame() {
  return window.requestAnimationFrame
      || window.mozRequestAnimationFrame
      || window.webkitRequestAnimationFrame
      || window.msRequestAnimationFrame;
}

写好了上面的工具函数, 我们将其拼接起来

function bubbleInputIfNeeded() {
  const target = _target;
  const offsetBottom = _offsetBottom;

  // 获取文档当前聚焦的元素
  const { activeElement } = document;

  if (!['INPUT', 'TEXTAREA'].includes(activeElement.tagName.toUpperCase())) return;
  const rect = getBoundingClientRect(activeElement);

  if (!rect) return;

  // 判断当前focus的input底部是否在document.body可视区域内
  if (rect.bottom > document.body.clientHeight - offsetBottom) {
    prevBodyHeight = document.body.clientHeight;

    // window或者父元素已经滚动的距离
    originScrollTop = getScroll(target);

    /** 
     * 元素需要滚动的距离
     * ps: 在安卓手机上, 键盘弹起的时候, document.body的高度会减小为可视区域的高度
    */
    const elementNeedScroll = rect.bottom - document.body.clientHeight + offsetBottom;
    const targetScrollTop = originScrollTop + elementNeedScroll;

    scrollTo(target, originScrollTop, targetScrollTop);
  }
}

最后, 我们需要监听window的resize事件

let eventListener;
function work() {
  if (typeof window === 'undefined') return;

  if (isAndroid()) {
    window.addEventListener('resize', eventListener = bubbleInputIfNeeded.bind(this));
  }
}

好了, 一个通用的方案到此基本完成了. 当然还需要有几个注意的点:

  1. 如果页面底部存在fixed布局的按钮, 岂不是要遮住输入框? 没事的, 我添加了一个机制, 可以让使用者设置 _offsetBottom(代表输入框距离可视区域底部的高度, 这种情况下你只需设置为按钮的高度即可)
  2. 如果页面的布局十分复杂怎么办, input处于层层嵌套的div下面. 这种情况下, 那真的很抱歉, 有些方案是需要对使用者做一些约束的, 它仅仅支持我在上文提到的2中基于流式布局的情况, 参考ant·design的Anchor组件, 也只能支持流式布局的页面, 要不然如果页面层层嵌套, 当然通过循环遍历依旧能解决, 但是这性能已经差到极致了.
  3. 第三步, 我该怎么引用呢? 然后如果我在不同页面需要滚动的目标元素不同, 又该怎么处理呢? 放心, 为了方便使用, 我导出了一个对象, 以及一些方法供大家使用.
let _offsetBottom = 0;
let _target = window;

const BubbleInput = {
  setOffsetBottom(offsetBottom) {
    _offsetBottom = offsetBottom;
    return this;
  },
  setTarget(target) {
    _target = target;
    return this;
  },
  reset() {
    this.setOffsetBottom(0);
    this.setTarget(window);
    return this;
  },
  work,
  offWork() {
    this.reset();
    window.removeEventListener('resize', eventListener);
  },
};

let _instance = null;

function createBubbleInput() {
  if (!_instance) {
    _instance = BubbleInput;
  }

  return _instance;
}

export default createBubbleInput();

所以, 你只需这么使用, 在应用的入口文件引入BubbleInput, 调用BubbleInput.work()即可;

// src/index.js
import BubbleInput from 'bubble-input';
BubbleInput.work();
// some-page.js
import BubbleInput from 'bubble-input';
BubbleInput
  .setOffsetBottom(someButton.offsetHeight || 0)
  .setTarget(this.refs.container);

到这里, 相信有些同学肯定已经想到一个问题了, 我在pageA调用BubbleInput.setOffsetBottom(10)设置了offsetBottom, 那如果切换页面到了pageB, 由于BubbleInput是单例的, 所以在pageA设置的offsetBottom也会影响到pageB. 于是我尝试寻找通用的解决方案, 在BubbleInput内部实现监听浏览器url的变化, 调用BubbleInput.reset(). 但是结果是遗憾的:

1.首先我考虑的是监听浏览器的popstate事件, 但是很遗憾History.pushState()和History.replaceState()并不会触发popstate事件.

window.addEventListener('popstate', function(event) {
  // 以下代码不会执行
  console.log(event);
});

2.然后我考虑尝试从我们公司的技术栈的角度出发解决问题, react-router3.x, react-router4.x, 确实有api.

// react-router3 我们通过browserHistory.listen()来添加url变化的监听事件
import { browserHistory } from 'react-router';
browserHistory.listen(() => {
  BubbleInput.reset();
});
// react-router4 我们知道在页面中, 我们可以通过props获取history
class Page extends React.Component {
  componentDidMount() {
    this.props.history.listen(() => {
      BubbleInput.reset();
    });
  }
}

很显然, 虽然以上2种方案能解决问题, 但是不能够达到适配不同框架的效果, 甚至如果我是用了Vue

最终, 方案我倒是有一个, 可是我不敢用(重写window.history.pushState/replaceState);

var overwrite = function(type) {
   var fn = history[type];
   return function() {
       var ret = fn.apply(this, arguments);
       var e = new Event(type);
       e.arguments = arguments;
       window.dispatchEvent(e);

       BubbleInput.reset();

       return ret;
   };
};
 history.pushState = overwrite('pushState');
 history.replaceState = overwrite('replaceState');

综合考虑, 最后我妥协了, 希望使用者能帮我完成最后一件事(在需要重置的时候由你手动调用), 比如:

componentWillUnmount() {
  BubbleInput.reset();
}

21fb9ca6-0214-eb11-8da9-e4434bdf6706.png
好了, 这下真的结束了~~感谢坚持看到底的朋友
XuZhongqiang/bubble-input​github.com
23fb9ca6-0214-eb11-8da9-e4434bdf6706.png

大佬们走过路过点个赞吧, 可以的话帮我点个star.

TODO:

  1. 发布npm包
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐