本篇文章主要解决两个问题:
- 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 にキーボードの幅と高さを渡し、その後 Web でツールバーの位置を制御できます。
欠点:イベントコールバックはリアルタイムではなく、前者よりは良いですが。
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
console.log('キーボードの高さが変わりました', e.endCoordinates.height)
})
以上の提案:キーボードが呼び出されたときにキーボードにぴったり合わず、アニメーションの過渡がうまくつながりません。もしエディタコンテナがスクロールできる場合、位置の計算が難しくなります。また、JS アニメーションがカクつきます。
後者は RN を借りる必要があると感じましたが、意味がないように思えました。その後、WebView を魔改造してネイティブの AccessoryView を実現しようとも考えましたが、行き詰まってしまいました。
RN 実装#
UI 描画とブリッジ#
その後、react-native-pell-rich-editor というライブラリを見つけ、ソースコードを学びました。ツールバーは React Native で描画され、ブリッジを介して Web エディタと通信しています。
これは確かに良い方法ですが、当初は WebView で呼び出されたキーボードが RN でどのように認識され、ツールバーが端に寄せられるかを考えていませんでした。実際には、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 でツールバーを描画し、Web と通信しました。ここでは、FlatList の特性 keyboardShouldPersistTaps="always"
を使用して、ツールバーをクリックしたときにキーボードが消えないように実現します。
<FlatList
horizontal
keyboardShouldPersistTaps="always" // クリック時にキーボードが消えない
keyExtractor={(item) => item.action}
data={toolbarData}
alwaysBounceHorizontal={false}
showsHorizontalScrollIndicator={false}
renderItem={({ item }) => (
// 実装
)}
/>
その後、ブリッジを構築する必要があります。ここでは、より良い 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 側でアクションリストを定義します:
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 はツールバーの高さ
}),
)
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 ボタンも実装されています。これはキーボードを閉じるために使用できます。ここでの実装は少し工夫しています。
<>
{/* 直接 WebView のキーボードを閉じることができないため、RN の入力を使って閉じる */}
<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 はパディングで、カスタマイズ可能な高さ
behavior: 'smooth',
})
}
}
return () => {
window.onresize = null
}
効果は以下の通りです:
この文は Mix Space によって xLog に同期更新されました。
元のリンクは https://innei.in/posts/programming/react-native-webView-editor-issue