移动端网页避免键盘弹出后页面向上滚动的完美解决方案

437 阅读3分钟

为了使 Paper 的富文本编辑达到原生应用级别的用户体验,我肝了 Flutter engine 源码。

在上一篇文章《Flutter web 集成 iframe 后输入框聚焦弹出键盘的界面滚动问题处理》中,我曾提到 Flutter web 页面中的键盘弹出并不会导致页面向上滚动,能达到原生应用的用户体验。当时并没有找到相关实现代码。后来重新看 Flutter engine 的 TextEditing 相关源码时发现了其实现原理,

/// IOS/Safari behaviour for text editing.
///
/// In iOS, the virtual keyboard might shifts the screen up to make input
/// visible depending on the location of the focused input element.
///
/// Due to this [initializeElementPlacement] and [updateElementPlacement]
/// strategies are different.
///
/// [disable] is also different since the [_positionInputElementTimer]
/// also needs to be cleaned.
///
/// inputmodeAttribute needs to be set for mobile devices. Due to this
/// [initializeTextEditing] is different.
class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
  @override
  void initializeElementPlacement() {
    /// Position the element outside of the page before focusing on it. This is
    /// useful for not triggering a scroll when iOS virtual keyboard is
    /// coming up.
    activeDomElement.style.transform = 'translate(-9999px, -9999px)';

    _canPosition = false;
  }
}

注释中已经说明,在 iOS Safari 中,虚拟键盘会根据焦点元素的位置来上移屏幕来保证焦点元素的可见性。其中的解决方案非常巧妙,既然输入元素在底部的时候获得焦点弹出键盘会为了保证可见性而导致页面上移,那我们就把输入元素放到页面最上面的位置,它不就不会上移了吗。知道了这个原理,我们来设计一个实现流程。

  1. 把真实的输入元素禁止聚焦
  2. 监听真实输入元素的点击事件
  3. 点击后动态创建一个假输入元素,设置位置到屏幕最上方,并且要加入 dom 中
  4. 调用假输入元素的 focus 方法(该操作是为了唤出键盘)
  5. 手动将真实输入元素滚动进可视区域(如果需要的话)并允许真实输入元素聚焦
  6. 调用真实输入元素的 focus 方法
  7. 销毁假的输入元素

实现的关键代码如下,

const EditablePlugin = (props: {
  scroller: RefObject<HTMLDivElement>
  container: RefObject<HTMLDivElement>
  editable?: boolean
}) => {
  const [editor] = useLexicalComposerContext()
  const input = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (!('ontouchstart' in window)) {
      editor.setEditable(!!props.editable)
      return
    }

    // 1. 禁止输入元素聚焦
    editor.setEditable(false)

    if (!props.editable) {
      return
    }

    const scroller = props.scroller.current
    const container = props.container.current
    const dom = editor.getRootElement()

    if (!scroller || !container || !dom) {
      return
    }

    let editorHasFocus = false

    const onFocus = () => {
      editorHasFocus = true
      editor.setEditable(true)
    }

    const onBlur = () => {
      editorHasFocus = false
      editor.setEditable(false)
    }

    // 2. 监听输入元素的点击事件
    const onClick = (e: MouseEvent) => {
      if (editorHasFocus) {
        return
      }

      // 4. 聚焦到假的输入元素来唤出键盘
      input.current?.focus()

      // 5. 允许真实输入元素聚焦,并根据自己需求将其滚动进入视野
      editor.setEditable(true)
      const range = getMouseEventCaretRange(e)
      const selection = window.getSelection()
      selection?.removeAllRanges()
      selection?.addRange(range!)
      editor.dispatchCommand(CLICK_COMMAND, e)

      setTimeout(() => {
        // 6. 聚焦到真实的输入元素
        editor.focus()
        
        // 以下代码是为了将真实输入元素的点击位置滚动进入视野的操作
        const rect = range!.getBoundingClientRect()
        const div = document.createElement('div')
        div.style.width = rect.width + 'px'
        div.style.height = rect.height + 'px'
        div.style.position = 'absolute'
        div.style.left = rect.x + scroller.scrollLeft + 'px'
        div.style.top = rect.y + scroller.scrollTop + 'px'
        container.appendChild(div)

        setTimeout(() => {
          div.scrollIntoView({ block: 'nearest', behavior: 'smooth' })

          setTimeout(() => {
            div.remove()
          }, 1000)
        }, 500)
      })
    }

    dom?.addEventListener('focus', onFocus)
    dom?.addEventListener('blur', onBlur)
    dom?.addEventListener('click', onClick)

    return () => {
      dom?.removeEventListener('focus', onFocus)
      dom?.removeEventListener('blur', onBlur)
      dom?.removeEventListener('click', onClick)
    }
  }, [editor, props.editable])

  return <_FakeInput ref={input} />
}

以上代码在我的笔记应用 Paper 中的富文本编辑器中。最终能达到非常好的用户体验,有兴趣的可以体验一下。