React Native for Web で Touchable 系コンポーネントを ScrollView の中で使うと暴発するのを抑える

React Native for Web において、 TouchableOpacity / TouchableHighlight などを ScrollView / FlatList / SectionList の中で使った場合、スクロール時にボタンが暴発してしまう問題に対するアンサーです。
2020.04.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

課題

ボタンを並べる際は次のように 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 系コンポーネントは onPressInonPressOut という 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} />
}

変更点は①でスクロールのオフセットを取得する関数を定義していること、②と③で onPressInonPressOut 用のコールバック関数を定義していること、それらを④で指定し、 delayPressOut に値を設定していることです。

やっていることはボタンを掴まれた際にスクロール位置を記録し、指が離れた際に少しでもスクロールしていたらボタンを発火させない、という処理を追加しているだけです。その際、一瞬のフリックで暴発しないよう、 delayPressOut を使って onPressOut が発火される時間を 100msec 遅らせています。

まとめ

onPressInonPressOut は React Native でボタンをカスタマイズしたい際によく使う props です。 Web でも有用なので、頭の片隅においておくと良いでしょう。