关于 React Native 的 WebView 编辑器问题记录

2024 年 1 月 19 日 星期五(已编辑)
/
748
13
AI 生成的摘要
这篇文章主要解决了WebView编辑器在吸附键盘和焦点元素被虚拟键盘遮挡两个问题。作者最初尝试使用Web和React Native WebView来实现文本编辑器。然而,使用Web实现时无法实时获取虚拟键盘的高度,而使用React Native中的Keyboard事件并不实时。作者后来发现可以使用react-native-pell-rich-editor库来在RN中绘制工具栏,并通过Bridge与Web编辑器通信。作者还解决了工具栏消失的过渡动画、聚焦元素被遮挡的问题,并提供了相应的代码示例。总的来说,通过上述的解决方案,作者成功地实现了在React Native中使用WebView的文本编辑器。
这篇文章上次修改于 2024 年 1 月 19 日 星期五,可能部分内容已经不适用,如有疑问可询问作者。

关于 React Native 的 WebView 编辑器问题记录

本篇文章主要解决两个问题:

  • WebView 编辑器 Toolbar 的吸附键盘
  • WebView 编辑器焦点元素被虚拟键盘遮挡
Important
这篇文章可能只适用于 React Native ~0.72

背景

最近在写 React Native,需要实现一个文本编辑器。而现在成熟的文本编辑器都是 Web 的,在原生或者是 React Native 这类跨端的框架成熟的富文本编辑器都是比较少见的。所以我们使用 Web + React Native WebView 去实现这个组件。

在需求中,我们主要实现这样的布局。上面是整个编辑器,底下是 AccessoryView + Keyboard。

思考过程

AccessoryView

AccessoryView 在 React Native 提供了相应的组件。组件叫 InputAccessoryView 但是有个局限是这个组件只适用于 React Native 的 TextInput。在 WebView 中无法使用。

使用 Web 实现

  • 在 Web 中,可以使用 VisualViewport 去监听虚拟键盘是否被唤出,并且获取到虚拟键盘的宽高。然后去定位工具栏的位置。

弊端:不是实时的状态。无法立即获取到键盘的高度。

useLayoutEffect(() => {
    window.addEventListener('resize', () => {
      detectKeyboard()
    })
  }, [])
  const [keyboardHeight, setKeyboardHeight] = useState(0)
  const timerRef = useRef<any>()
  const detectKeyboard = () => {
    clearTimeout(timerRef.current)
    timerRef.current = setTimeout(() => {
      if (!window.visualViewport) {
        return
      }

      setKeyboardHeight(
        window.innerHeight -
          window.visualViewport.height +
          window.visualViewport.offsetTop,
      )
    }, 300)
  }
  • 可以在 React Native 中通过 Keyboard 事件传递给 Web Keyboard 的宽高,然后在 Web 控制工具栏的位置。

弊端:事件回调不实时,相比前者好些。

  Keyboard.addListener('keyboardWillChangeFrame', (e) => {
      console.log('键盘高度变化到', e.endCoordinates.height)
})

以上的方案:在键盘唤出时无法贴合键盘,动画过度无法衔接。如果编辑器容器能滚动的话,位置不好计算。并且 JS 动画卡。

后者既然要借助 RN 感觉是没有意义了。后来甚至想魔改 WebView 来实现原生的 AccessoryView。属于钻进死胡同了。

RN 实现

UI 绘制与架桥

后来看到了 react-native-pell-rich-editor 这个库,学习了一下源码。发现是 Toolbar 就是用 React Native 绘制,然后用 Bridge 和 Web Editor 通信。

这确实是个好办法,但是当初没想到在 WebView 中唤起的键盘如何在 RN 中被识别然后让 Toolbar 贴边。事实是我想多了,原来用 KeyboardAvoidingView 就可以了,KeyboardAvoidingView 也能识别 WebView 中的键盘。


const Render = () => {
  return (
    <View className="flex-1">
      <View className="mt-20 flex-1 bg-yellow-50">
        <TiptapWebView />
      </View>

      <KeyboardAvoidingView behavior="padding">
        <View className="h-12 bg-black" />
      </KeyboardAvoidingView>
    </View>
  )
}

之后我们用 RN 绘制 Toolbar 然后与 Web 通信。这里我们可以用 FlatList 的特征 keyboardShouldPersistTaps="always" 来实现,点击 Toolbar 时,键盘不会消失。

   <FlatList
          horizontal
          keyboardShouldPersistTaps="always" // 点击 action 键盘不消失
          keyExtractor={(item) => item.action}
          data={toolbarData}
          alwaysBounceHorizontal={false}
          showsHorizontalScrollIndicator={false}
          renderItem={({ item }) => (
                     // impl
          )}
/>

之后我们需要架桥,这里为了更好的 TypeScript。我在 Web 和 RN 两侧分别进行声明接口。

在 RN 侧:

import WebView from 'react-native-webview'

export interface TiptapWebViewMethods {
  blur(): void
  bold(): void
  italic(): void
  underline(): void
  strike(): void
}

export const callTiptapWebViewMethod = async (
  webviewRef: React.RefObject<WebView>,
  method: keyof TiptapWebViewMethods,
  ...args: any[]
) => {
  if (webviewRef.current) {
    const result = await webviewRef.current.injectJavaScript(`
      window.tiptap.${method}(${JSON.stringify(args)})
    `)
    return result
  }
}

定义编辑器的操作。

在 Web 侧进行实现:

import type { Editor } from '@tiptap/core'

declare const window: any

const FLAG_ONCE_KEY = Symbol()
export const registerGlobalMethods = (editor: Editor) => {
  if (window[FLAG_ONCE_KEY]) return
  window.tiptap = {
    blur() {
      editor.chain().blur().run()
    },
    bold() {
      editor.chain().toggleBold().run()
    },
    italic() {
      editor.chain().toggleItalic().run()
    },
    underline() {
      editor.chain().toggleUnderline().run()
    },
    strike() {
      editor.chain().toggleStrike().run()
    },
  }

  window[FLAG_ONCE_KEY] = true
}

在 RN 侧定义 action 列表:


const toolbarItems = ({
  editor,
}: {
  editor: React.RefObject<WebView<unknown>>
}): ToolbarItem[] => [
  {
    action: 'bold',
    onClick() {
      callTiptapWebViewMethod(editor, 'bold')
    },
    icon: <Icon name="bold" size={24} />,
    pr: 16,
  },

  {
    action: 'italic',
    onClick() {
      callTiptapWebViewMethod(editor, 'italic')
    },
    icon: <Icon name="italic" size={24} />,
    pr: 16,
  },
  {
    action: 'hyphen',
    onClick() {
      callTiptapWebViewMethod(editor, 'strike')
    },
    icon: <Icon name="hyphen-s" size={24} />,
    pr: 16,
  },
  {
    action: 'underline',
    onClick() {
      callTiptapWebViewMethod(editor, 'underline')
    },
    icon: <Icon name="hyphen-u" size={24} />,
    pr: 16,
  },

  {
    action: 'photo',
    onClick() {
      // TODO
    },
    icon: <Icon name="photo" size={24} />,
    pr: 16,
    spacer: true,
  },
]

// FlatList
const toolbarData = useMemo(
  () =>
    toolbarItems({
      editor: webviewRef,
    }),
  [],
)

 <FlatList
  horizontal
  keyboardShouldPersistTaps="always"
  keyExtractor={(item) => item.action}
  data={toolbarData}
  alwaysBounceHorizontal={false}
  showsHorizontalScrollIndicator={false}
  renderItem={({ item }) => (
    <>
      <View className="h-full items-center justify-center">
        <UnstyledButton onPress={item.onClick}>{item.icon}</UnstyledButton>
      </View>
      {!!item.pr && <View style={{ width: item.pr }} />}
      {item.spacer && <View className="flex-shrink flex-grow" />}
    </>
  )}
/>

过度衔接

现在再做一下当键盘消失的时候,工具栏也要消失。这里我们可以做一个动画衔接。由于不是原生的 AccessoryView 所以是无法与整个键盘的动画融合的。我这里用了一个两段动画。

useLayoutEffect(() => {
    const subscriptions = [] as EmitterSubscription[]

    subscriptions.push(
      Keyboard.addListener('keyboardWillShow', () => {
        animatedTranslateYValue.setValue(0)
      }),
      Keyboard.addListener('keyboardWillHide', () => {
        Animated.spring(animatedTranslateYValue, {
          toValue: 44,
          useNativeDriver: true,

          bounciness: 0,
        }).start()
        callTiptapWebViewMethod(webviewRef, 'blur')
      }),
      bus.on(EventMap.showToolbar, () => {
        animatedTranslateYValue.setValue(0)
      }),
      bus.on(EventMap.hideToolbar, () => {
        animatedTranslateYValue.setValue(44) // 44 是 toolbar 高度
      }),
    )

    return () => {
      subscriptions.forEach((sub) => sub.remove())
    }
}, [])

<Animated.View
  className={cn(
    'absolute bottom-0 left-0 right-0 h-[44] flex-row px-6',
    className,
  )}
  style={{
    backgroundColor: Colors.theme.hoverFill,
    transform: [
      {
        translateY: animatedTranslateYValue,
      },
    ],
  }}
>
  <FlatList
    horizontal
    keyboardShouldPersistTaps="always"
    keyExtractor={(item) => item.action}
    data={toolbarData}
    alwaysBounceHorizontal={false}
    showsHorizontalScrollIndicator={false}
    renderItem={({ item }) => (
      <>
        <View className="h-full items-center justify-center">
          <UnstyledButton onPress={item.onClick}>{item.icon}</UnstyledButton>
        </View>
        {!!item.pr && <View style={{ width: item.pr }} />}
        {item.spacer && <View className="flex-shrink flex-grow" />}
      </>
    )}
  />
  </View>
</Animated.View>

效果如下:

对了上图还实现了 Done 的按钮。可以用于 Dismiss Keyboard。这里实现有点耍小聪明。

<>
  {/* 由于 不能直接 dimiss webview 的 keyboard, 用一个 rn input 模拟关闭 */}
  <TextInput ref={fakeInputRef} className="hidden" />

  <View className="h-full justify-center">
    <UnstyledButton
      onPress={() => {
        requestAnimationFrame(() => {
          fakeInputRef.current?.focus()

          Keyboard.dismiss()
        })
      }}
    >
      <Text className="font-bold text-[#007AFF]">Done</Text>
    </UnstyledButton>
  </View>
</>

焦点与触底遮挡问题

在 WebView 中,没有现成的 KeyboardAvoidView 可供使用。那么在长内容的编辑场景下,编辑区在键盘范围内,键盘唤出导致编辑内容被遮挡。

现在我们要处理这个问题。在开始之前,下面的图解可以更好的帮助理解。

这时候有两种情况,我们需要处理一种。

前者不需要处理。但是需要判断当前焦点元素是属于前者还是后者。

后者的处理思路是,计算变化后视窗高度,和焦点元素坐标是否在被遮挡范围内。

计算过程是这样的。

如果是前者,那么焦点元素的 getBoundingClientRect().y + rect.height < currentWindowHeight

如果说后者,则需要计算整个滚动容器需要上面滚动多少距离。

这个距离,可以通过焦点元素的 y 减去当前视窗高度。图中的绿线减去蓝线的距离。

代码参考如下:

window.onresize = () => {
       const editor = editorRef.current
       const currentHeight = window.innerHeight

       if (currentHeight < maxWindowHeight) {
         let currentDom = editor?.view.domAtPos(editor.state.selection.from)
           ?.node as HTMLElement | Text

         if (!currentDom) {
           return
         }

         currentDom instanceof Text && (currentDom = currentDom.parentElement!)
         const rect = (currentDom as HTMLElement).getBoundingClientRect()
         const { y: currentNodeY, height: nodeHeight } = rect

         if (currentHeight > currentNodeY + nodeHeight) return

         const axleDelta = currentNodeY - currentHeight

         wrapperRef.current?.scrollTo({
           top: axleDelta + wrapperRef.current.scrollTop + nodeHeight + 50, // 50 是一个 padding,可自定义高度
           behavior: 'smooth',
         })
       }
   }

 return () => {
   window.onresize = null
 }

效果如下:

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...