本篇文章主要解決兩個問題:
- 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]">完成</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
}
效果如下:
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/programming/react-native-webView-editor-issue