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

原文约7600字,阅读约需19分钟。发表于:

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/programming/react-native-webView-editor-issue 本篇文章主要解决两个问题:WebView 编辑器 Toolbar 的吸附键盘WebView 编辑器焦点元素被虚拟键盘遮挡这个内容只能在原文中查看哦 背景最近在写 React Native,需要实现一个文本编辑器。而现在成熟的文本编辑器都是 Web 的,在原生或者是 React Native 这类跨端的框架成熟的富文本编辑器都是比较少见的。所以我们使用 Web + React Native WebView 去实现这个组件。在需求中,我们主要实现这样的布局。上面是整个编辑器,底下是 AccessoryView + Keyboard。思考过程AccessoryViewAccessoryView 在 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 }效果如下: 看完了?说点什么呢

本文介绍了在React Native中实现WebView编辑器的两个问题:工具栏与键盘的吸附以及键盘遮挡焦点元素的解决方案。通过使用React Native WebView和Bridge与Web Editor进行通信,实现了工具栏贴边和处理焦点元素被遮挡的问题。同时,还介绍了实现细节和代码示例。

关于 React Native 的 WebView 编辑器问题记录
相关推荐 去reddit讨论