React Native for Web で Touchable 系コンポーネントを ScrollView の中で使うと暴発するのを抑える
課題
ボタンを並べる際は次のように FlatList や SectionList を用いて書くのが RNfW での常套手段です。
import React from 'react' import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native' const styles = StyleSheet.create({ container: { height: 120, justifyContent: 'center', alignItems: 'center', borderWidth: 2, borderColor: 'gray', borderRadius: 8, }, }) interface ButtonProps { label: string } function Button(props: ButtonProps) { const onPress = React.useCallback(() => { alert(props.label) }, [props]) return ( <TouchableOpacity onPress={onPress} style={styles.container}> <Text>{props.label}</Text> </TouchableOpacity> ) } interface ButtonsProps { data: Array<string> } function Buttons(props: ButtonsProps) { return ( <FlatList data={props.data} renderItem={({ item }) => <Button label={item} />} keyExtractor={item => item} /> ) } const DATA = ['foo', 'bar', 'buz', 'hoge', 'moge', 'blar', 'poo', 'end'] export default function App() { return <Buttons data={DATA} /> }
が、こう書くとモバイルデバイス上で意図しない挙動となります。スクロール用に適当に掴んだボタンがスクロール後に暴発してしまいます。
見やすくコンポーネント分割したものをアップロードしていますので手元で動かしたい方は have-troubles
ブランチで動かしてみてください。
ちなみに FlatList / SectionList は ScrollView を継承していますが、 ScrollView がこの問題の原因のようです。同様の現象が発生します。
解決策
React Native の Touchable 系コンポーネントは onPressIn
と onPressOut
という props を持っています。それぞれ指をコンポーネントに触れたときと離したときに実行される関数を指定するものです。
これらを使って、次のように書くことが可能です。
import React from 'react' import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native' const styles = StyleSheet.create({ container: { height: 120, justifyContent: 'center', alignItems: 'center', borderWidth: 2, borderColor: 'gray', borderRadius: 8, }, }) interface ButtonProps { label: string getOffset: () => number } function Button(props: ButtonProps) { const n = React.useRef(0) // ② const onPressIn = React.useCallback(() => { n.current = props.getOffset() }, [props]) // ③ const onPressOut = React.useCallback(() => { const delta = Math.abs(props.getOffset() - n.current) if (0 < delta) { return } alert(props.label) }, [props]) return ( {/* ④ */} <TouchableOpacity onPressIn={onPressIn} onPressOut={onPressOut} delayPressOut={100} style={styles.container}> <Text>{props.label}</Text> </TouchableOpacity> ) } interface ButtonsProps { data: Array<string> } function Buttons(props: ButtonsProps) { // ① const getOffset = React.useCallback(() => window.scrollY, []) return ( <FlatList data={props.data} renderItem={({ item }) => <Button label={item} getOffset={getOffset} />} keyExtractor={item => item} /> ) } const DATA = ['foo', 'bar', 'buz', 'hoge', 'moge', 'blar', 'poo', 'end'] export default function App() { return <Buttons data={DATA} /> }
変更点は①でスクロールのオフセットを取得する関数を定義していること、②と③で onPressIn
と onPressOut
用のコールバック関数を定義していること、それらを④で指定し、 delayPressOut
に値を設定していることです。
やっていることはボタンを掴まれた際にスクロール位置を記録し、指が離れた際に少しでもスクロールしていたらボタンを発火させない、という処理を追加しているだけです。その際、一瞬のフリックで暴発しないよう、 delayPressOut
を使って onPressOut
が発火される時間を 100msec 遅らせています。
まとめ
onPressIn
と onPressOut
は React Native でボタンをカスタマイズしたい際によく使う props です。 Web でも有用なので、頭の片隅においておくと良いでしょう。