【React Native】FlatList viewabilityConfigの代替手法とパフォーマンス最適化

2025-05-31

FlatList#viewabilityConfigとは?

FlatListは、React Nativeで効率的に長いリストを表示するためのコンポーネントです。画面に表示されるアイテムだけをレンダリングすることでパフォーマンスを最適化する「仮想スクロール」の仕組みを持っています。

viewabilityConfigは、このFlatListのアイテムが「表示されている(viewable)」と判断される基準を細かく設定するためのプロパティです。具体的には、あるアイテムが画面のどの程度表示されたら「viewable」とみなすか、どれくらいの時間表示され続けたらコールバックをトリガーするかなどを定義できます。

この設定は、主にonViewableItemsChangedというコールバックプロパティと組み合わせて使用されます。onViewableItemsChangedは、viewabilityConfigで定義された条件を満たすアイテムの表示状態が変化したときに呼び出される関数です。例えば、ユーザーがスクロールして動画のアイテムが画面に表示されたら自動再生を開始する、といった動作を実装する際に非常に役立ちます。

viewabilityConfigで設定できる主なプロパティ

viewabilityConfigはオブジェクトを受け取り、以下のプロパティを設定できます。

  • waitForInteraction?: boolean

    • trueに設定すると、ユーザーがリストをスクロールするなどの操作を行うまで、onViewableItemsChangedコールバックが発火しません。
    • 通常はfalseにして、初回のレンダリング時やスクロール時に自動的に検出されるようにすることが多いです。ただし、一部の環境で初回の発火に問題がある場合、この設定やFlatListonLayoutrecordInteraction()メソッドを組み合わせることで解決できることがあります。
  • itemVisiblePercentThreshold?: number

    • viewAreaCoveragePercentThresholdと似ていますが、ビューポートがカバーする割合ではなく、アイテム自体の何パーセントが画面に表示されているかを基準にします。
    • 例えば、50を設定すると、アイテムの50%以上が画面に表示されていれば「viewable」とみなされます。
  • viewAreaCoveragePercentThreshold?: number

    • 部分的に隠れているアイテムが「viewable」とみなされるために、ビューポート(画面の表示領域)の何パーセントをカバーする必要があるか(0〜100)。
    • 完全に画面に表示されているアイテムは常に「viewable」とみなされます。
    • 0を設定すると、1ピクセルでも画面に表示されれば「viewable」とみなされます。
    • 100を設定すると、アイテムが完全に画面に表示されているか、ビューポート全体を覆う必要がある場合にのみ「viewable」とみなされます。
    • アイテムが「viewable」と判断されるために、物理的に画面に表示され続けなければならない最小時間(ミリ秒)です。
    • この値を大きく設定すると、スクロール中に一時的に画面に入っただけのアイテムは「viewable」とみなされにくくなります。動画の自動再生など、ユーザーがそのアイテムを「実際に見た」と判断したい場合に役立ちます。
import React, { useState, useRef, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';

const DATA = Array.from({ length: 50 }, (_, i) => ({ id: String(i), title: `アイテム ${i + 1}` }));

const App = () => {
  const [viewableItems, setViewableItems] = useState([]);

  // viewabilityConfigの設定
  const viewabilityConfig = useRef({
    minimumViewTime: 500, // 500ミリ秒以上表示されたらviewable
    itemVisiblePercentThreshold: 50, // アイテムの50%以上が画面に表示されたらviewable
    waitForInteraction: false, // ユーザー操作を待たずに検出
  }).current;

  // onViewableItemsChanged コールバック
  const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
    console.log("Viewable Items:", viewableItems.map(item => item.item.title));
    console.log("Changed Items:", changed.map(item => item.item.title));
    setViewableItems(viewableItems.map(item => item.item.title));
  }, []);

  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.header}>現在表示されているアイテム:</Text>
      {viewableItems.length > 0 ? (
        <Text>{viewableItems.join(', ')}</Text>
      ) : (
        <Text>スクロールしてください</Text>
      )}

      <FlatList
        data={DATA}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        style={styles.flatList}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 50,
    backgroundColor: '#f5f5f5',
  },
  header: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 10,
    marginLeft: 20,
  },
  item: {
    backgroundColor: '#fff',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
    elevation: 2,
  },
  title: {
    fontSize: 20,
  },
  flatList: {
    marginTop: 20,
  },
});

export default App;


FlatList#viewabilityConfig におけるよくあるエラーとトラブルシューティング

onViewableItemsChangedが期待通りに発火しない/一度しか発火しない

これは最もよく遭遇する問題の一つです。

  • 原因5: 初回レンダリング時にonViewableItemsChangedが発火しない
    • 一部の環境や特定の条件(特にAndroid)で、初期ロード時にonViewableItemsChangedが発火しないことがあります。
    • トラブルシューティング
      • FlatListonLayoutプロパティでrecordInteraction()を呼び出すことで、明示的にインタラクションを記録し、Viewabilityの計算をトリガーすることができます。
      import React, { useRef, useCallback } from 'react';
      import { FlatList, View, Text } from 'react-native';
      
      const MyList = () => {
        const flatListRef = useRef(null);
      
        const onViewableItemsChanged = useCallback(({ viewableItems }) => {
          console.log(viewableItems);
        }, []);
      
        return (
          <FlatList
            ref={flatListRef}
            data={[]}
            renderItem={() => <Text>Item</Text>}
            onViewableItemsChanged={onViewableItemsChanged}
            viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
            onLayout={() => {
              // 初回レンダリング時にviewabilityをトリガー
              flatListRef.current?.recordInteraction();
            }}
          />
        );
      };
      
      • あるいは、initialScrollIndex={0.01}のような小さな値で強制的に初期スクロールを発生させることで、イベントをトリガーできるという報告もあります(これはあまり推奨される解決策ではありません)。
  • 原因4: keyExtractorが適切に設定されていない
    • FlatListはアイテムの識別と最適化のためにkeyExtractorを必要とします。一意なキーが提供されない場合、仮想化のパフォーマンス問題や、onViewableItemsChangedが正しく動作しない原因となることがあります。
    • トラブルシューティング
      • keyExtractorが各アイテムに対して一意で安定した文字列を返すことを確認してください。通常はデータアイテムのIDプロパティを使用します。
  • 原因3: FlatListが他のスクロール可能なコンポーネント(例: ScrollView)の内部にある
    • FlatListを別のScrollViewの中にネストすると、FlatListの仮想化機能が正しく動作せず、すべてのアイテムが一度にレンダリングされてしまい、onViewableItemsChangedが期待通りに動作しなくなることがあります。これはFlatListのViewability検出メカニズムが、親のスクロールコンテナのサイズを正しく認識できなくなるためです。
    • トラブルシューティング
      • 基本的にFlatListScrollViewの中にネストすることは避けるべきです。リストの先頭や末尾にヘッダーやフッターを追加したい場合は、FlatListListHeaderComponentListFooterComponentプロパティを使用することを検討してください。
  • 原因2: viewabilityConfigの設定が厳しすぎる
    • minimumViewTimeが長すぎたり、itemVisiblePercentThresholdviewAreaCoveragePercentThresholdが高すぎたりすると、アイテムが「viewable」と判断される条件が厳しくなり、なかなかイベントが発火しないことがあります。
    • トラブルシューティング
      • viewabilityConfigの値を調整してみてください。特に開発中は、minimumViewTime: 0itemVisiblePercentThreshold: 1など、非常に緩い設定で試してみて、イベントが発火するかどうかを確認すると良いでしょう。
      • waitForInteraction: trueに設定している場合、ユーザーがリストをスクロールするなど、何らかの操作を行うまでイベントが発火しません。初期表示時に発火させたい場合はfalseに設定してください。
  • 原因1: onViewableItemsChanged関数の不安定性(再生成)
    • Reactの関数コンポーネント内でonViewableItemsChangedをインラインで定義したり、依存配列を適切に設定せずにuseCallbackを使用したりすると、コンポーネントが再レンダリングされるたびにこの関数が再生成されてしまいます。FlatListは、onViewableItemsChanged関数が「その場で変更される」ことをサポートしていません。
    • トラブルシューティング
      • 関数コンポーネントを使用している場合、onViewableItemsChangedコールバックをReact.useCallbackでラップし、依存配列を空[]にするか、本当に必要な依存関係のみを含めるようにしてください。これにより、関数が再レンダリング時に再生成されなくなります。
      • もしコールバック内でstateの値にアクセスする必要があるが、依存配列にそのstateを含めたくない(含めると関数が再生成されてしまう)場合は、useRefを使ってstateの最新の値を参照するようにすることで、useCallbackの依存配列を空に保つことができます。
      const myState = useRef(initialState);
      // ... state updates ...
      myState.current = newState;
      
      const onViewableItemsChanged = useCallback(({ viewableItems }) => {
        // myState.current の値を使って処理
        console.log(myState.current);
      }, []); // 依存配列は空
      

onViewableItemsChangedが頻繁に、または不必要に発火する

  • 原因2: onViewableItemsChanged内で重い処理を実行している
    • onViewableItemsChangedはスクロール中に頻繁に呼び出される可能性があるため、その内部でsetStateを頻繁に呼び出したり、時間のかかる計算を行ったりすると、JSスレッドがブロックされ、スクロールのパフォーマンスが低下する可能性があります。
    • トラブルシューティング
      • コールバック内で実行する処理を最小限に抑えてください。
      • setStateの更新をデバウンス(debounce)するか、必要な状態更新のみを行うようにロジックを最適化してください。
      • パフォーマンスが気になる場合は、InteractionManager.runAfterInteractions()を使用して、UIスレッドのアイドル時に処理を実行するように検討してください。
      • 複雑な計算が必要な場合は、Web Worker(React Nativeでは専用のライブラリが必要)などを利用してバックグラウンドで処理を行うことも視野に入れます。
  • 原因1: viewabilityConfigの設定が緩すぎる
    • minimumViewTimeが短すぎる、またはitemVisiblePercentThresholdviewAreaCoveragePercentThresholdが低すぎると、少しでも画面に入っただけで頻繁にイベントが発火してしまい、パフォーマンスに影響を与えたり、意図しない挙動を引き起こしたりすることがあります。
    • トラブルシューティング
      • アプリケーションの要件に合わせて、これらの値を調整してください。例えば、動画の自動再生であればminimumViewTimeを長めに設定し、スクロールの途中で一瞬見えただけでは再生しないようにするなど、具体的なユースケースを考慮して調整します。

表示されたアイテムのデータが古い(Stale Closure)

  • トラブルシューティング
    • 前述の「useRefを使用してstateの最新の値を参照する」方法を検討してください。これが最も一般的な解決策です。
    const visibleItemsRef = useRef([]); // 表示アイテムを保持するref
    const onViewableItemsChanged = useCallback(({ viewableItems }) => {
      // 最新のviewableItemsを使って何か処理
      visibleItemsRef.current = viewableItems.map(item => item.item.id);
      // 必要に応じて、stateを更新してUIを再レンダリングする
      // setSomeState(prev => ...);
    }, []); // 依存配列は空
    
    • あるいは、useStateの更新関数(setStateの関数形式)を使って、常に最新のstateにアクセスするようにすることも可能です。
    const [viewableItems, setViewableItems] = useState([]);
    const onViewableItemsChanged = useCallback(({ viewableItems }) => {
      setViewableItems(prevItems => {
        // prevItems は常に最新のstate
        const currentVisibleIds = viewableItems.map(item => item.item.id);
        // ... 何らかのロジック ...
        return currentVisibleIds; // 新しいstateを返す
      });
    }, []);
    
  • 原因
    onViewableItemsChangedコールバックがuseCallbackでメモ化されている場合、依存配列に含めなかった外部のstateやpropsの値が、コールバックが生成された時点の古い値のままになってしまうことがあります。これはReactのクロージャー(Closure)の特性によるものです。

Androidでのパフォーマンス問題や予期せぬ挙動

  • トラブルシューティング
    • removeClippedSubviews={true} を試す(ただし、これにはバグがある場合や意図しない表示問題を引き起こす可能性もあるため注意が必要です)。
    • windowSizeinitialNumToRendermaxToRenderPerBatch などのパフォーマンス関連のプロパティを調整して、レンダリングされるアイテムの数を制御します。
    • getItemLayoutプロパティを使用して、各アイテムの固定高さをFlatListに伝えることで、レイアウト計算のオーバーヘッドを削減し、パフォーマンスを向上させることができます。
    • renderItem内で使用するコンポーネントがReact.memoPureComponentで最適化されていることを確認します。
    • 画像を使用している場合は、react-native-fast-imageのような高速な画像ライブラリの使用を検討します。
  • 原因
    Androidでは、iOSに比べて仮想化の挙動が異なる場合があり、特に複雑なレイアウトや画像が多いリストでパフォーマンスの問題が発生しやすいです。
  • 公式ドキュメントとGitHub Issues
    React Nativeの公式ドキュメントや、FlatListに関連するGitHubのIssueを検索すると、同じ問題に直面している他の開発者の情報や、解決策が見つかることがあります。
  • React DevTools / Flipper
    これらを活用してコンポーネントの再レンダリング回数を監視したり、プロファイリングを行ったりすることで、パフォーマンスボトルネックを発見できます。
  • 開発モードとプロダクションモードの挙動の違い
    開発モードではReact Nativeのホットリロードや追加のチェックのためにパフォーマンスが低下したり、特定の挙動が異なったりすることがあります。最終的な挙動はプロダクションビルドで確認することが重要です。
  • デバッグログの活用
    onViewableItemsChangedコールバック内でconsole.logを使って、viewableItemschangedの内容、イベントが発火している頻度などを確認します。


FlatList#viewabilityConfig のプログラミング例

例1: アイテムが画面に半分以上表示されたら検出する

この例では、アイテムの50%以上が画面に表示されたときにonViewableItemsChangedが発火するように設定します。

import React, { useState, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';

const DATA = Array.from({ length: 30 }, (_, i) => ({
  id: `item-${i}`,
  title: `アイテム ${i + 1}`,
  height: 100 + (i % 3) * 20, // 高さのバリエーションを持たせる
}));

const Example1 = () => {
  const [visibleItemTitles, setVisibleItemTitles] = useState([]);

  // viewabilityConfig の設定
  const viewabilityConfig = {
    // アイテムの50%以上が画面に表示されたらViewableと判断
    itemVisiblePercentThreshold: 50,
    // viewAreaCoveragePercentThreshold: 0, // これを使う場合はitemVisiblePercentThresholdと排他的に使うか、両方考慮される
    minimumViewTime: 0, // すぐに検出
    waitForInteraction: false, // ユーザー操作を待たない
  };

  // onViewableItemsChanged コールバック
  const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
    const currentVisibleTitles = viewableItems.map(item => item.item.title);
    console.log('--- Example 1 ---');
    console.log('Viewable Items (50% threshold):', currentVisibleTitles);
    // 状態を更新してUIに表示
    setVisibleItemTitles(currentVisibleTitles);
  }, []); // 依存配列は空でOK。状態更新はsetterを使うのでクロージャ問題なし

  const renderItem = ({ item }) => (
    <View style={[styles.item, { height: item.height }]}>
      <Text style={styles.title}>{item.title}</Text>
      <Text>高さ: {item.height}px</Text>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.header}>
        アイテムが50%以上表示されたら検出:
      </Text>
      <Text style={styles.visibleStatus}>
        現在表示中: {visibleItemTitles.length > 0 ? visibleItemTitles.join(', ') : 'なし'}
      </Text>
      <FlatList
        data={DATA}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        style={styles.flatList}
      />
    </View>
  );
};

// スタイルは最後にまとめて定義

例2: アイテムが画面に200ミリ秒以上表示され続けたら検出する

この例では、minimumViewTimeを使用して、アイテムが一定時間(200ミリ秒)画面に表示され続けた場合にのみ「viewable」と判断するようにします。これにより、高速なスクロール中に一瞬だけ見えたアイテムが検出されるのを防ぐことができます。

import React, { useState, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';

// DATAは例1と同じものを使用
const DATA = Array.from({ length: 30 }, (_, i) => ({
  id: `item-${i}`,
  title: `アイテム ${i + 1}`,
  height: 100 + (i % 3) * 20,
}));

const Example2 = () => {
  const [visibleItemTitles, setVisibleItemTitles] = useState([]);

  // viewabilityConfig の設定
  const viewabilityConfig = {
    itemVisiblePercentThreshold: 1, // 1%でも表示されたらviewableの候補
    // 200ミリ秒以上表示され続けたらViewableと判断
    minimumViewTime: 200,
    waitForInteraction: false,
  };

  // onViewableItemsChanged コールバック
  const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
    const currentVisibleTitles = viewableItems.map(item => item.item.title);
    console.log('--- Example 2 ---');
    console.log('Viewable Items (minimumViewTime 200ms):', currentVisibleTitles);
    setVisibleItemTitles(currentVisibleTitles);
  }, []);

  const renderItem = ({ item }) => (
    <View style={[styles.item, { height: item.height }]}>
      <Text style={styles.title}>{item.title}</Text>
      <Text>高さ: {item.height}px</Text>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.header}>
        200ms以上表示されたら検出:
      </Text>
      <Text style={styles.visibleStatus}>
        現在表示中: {visibleItemTitles.length > 0 ? visibleItemTitles.join(', ') : 'なし'}
      </Text>
      <FlatList
        data={DATA}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        style={styles.flatList}
      />
    </View>
  );
};

例3: waitForInteractionの使用とrecordInteractionによる初期トリガー

waitForInteraction: trueに設定すると、ユーザーがリストをスクロールするなどの操作を行うまでonViewableItemsChangedは発火しません。しかし、初期表示時に発火させたい場合は、FlatListrefを使ってrecordInteraction()を呼び出すことで、明示的にViewabilityの計算をトリガーできます。

import React, { useState, useCallback, useRef, useEffect } from 'react';
import { FlatList, View, Text, StyleSheet, Button } from 'react-native';

// DATAは例1と同じものを使用
const DATA = Array.from({ length: 30 }, (_, i) => ({
  id: `item-${i}`,
  title: `アイテム ${i + 1}`,
  height: 100 + (i % 3) * 20,
}));

const Example3 = () => {
  const [visibleItemTitles, setVisibleItemTitles] = useState([]);
  const flatListRef = useRef(null); // FlatListへの参照

  // viewabilityConfig の設定
  const viewabilityConfig = {
    itemVisiblePercentThreshold: 50,
    minimumViewTime: 0,
    // ユーザーがスクロールするまでonViewableItemsChangedは発火しない
    waitForInteraction: true,
  };

  const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
    const currentVisibleTitles = viewableItems.map(item => item.item.title);
    console.log('--- Example 3 ---');
    console.log('Viewable Items (waitForInteraction):', currentVisibleTitles);
    setVisibleItemTitles(currentVisibleTitles);
  }, []);

  // コンポーネントがマウントされた後に recordInteraction を呼び出す
  useEffect(() => {
    // 開発環境のFast Refreshで複数回呼ばれる可能性があるので注意
    console.log('Component mounted, calling recordInteraction()');
    flatListRef.current?.recordInteraction();
  }, []); // 空の依存配列でマウント時のみ実行

  const renderItem = ({ item }) => (
    <View style={[styles.item, { height: item.height }]}>
      <Text style={styles.title}>{item.title}</Text>
      <Text>高さ: {item.height}px</Text>
    </View>
  );

  const handleRecordInteraction = () => {
    console.log('Manual recordInteraction() called.');
    flatListRef.current?.recordInteraction();
  };

  return (
    <View style={styles.container}>
      <Text style={styles.header}>
        `waitForInteraction`と`recordInteraction`:
      </Text>
      <Text style={styles.visibleStatus}>
        現在表示中: {visibleItemTitles.length > 0 ? visibleItemTitles.join(', ') : 'なし'}
      </Text>
      <Button title="手動でViewabilityをトリガー" onPress={handleRecordInteraction} />
      <FlatList
        ref={flatListRef} // refを設定
        data={DATA}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        style={styles.flatList}
      />
    </View>
  );
};

スタイル定義 (すべての例で共通)

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 50,
    backgroundColor: '#f0f0f0',
  },
  header: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 5,
    marginLeft: 15,
  },
  visibleStatus: {
    fontSize: 14,
    color: '#555',
    marginBottom: 15,
    marginLeft: 15,
  },
  flatList: {
    flex: 1,
  },
  item: {
    backgroundColor: '#fff',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 10,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
    elevation: 3,
    justifyContent: 'center',
    alignItems: 'center',
  },
  title: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 5,
  },
});

// 各Exampleコンポーネントをエクスポートして、App.jsなどで切り替えて実行できます
// export default Example1;
// export default Example2;
// export default Example3;

// 例えば、App.jsで以下のようにインポートして試せます
/*
import React from 'react';
import Example1 from './Example1'; // or './Example2', './Example3'

const App = () => {
  return <Example1 />;
};

export default App;
*/
  1. itemVisiblePercentThreshold: アイテムが画面にどの程度表示されたら「viewable」と見なすかを制御します。動画の自動再生など、アイテムが「きちんと見えている」と判断したい場合に役立ちます。
  2. minimumViewTime: アイテムが「viewable」と判断されるために、画面に表示され続ける最小時間を制御します。高速スクロール時の不要な検出を防ぎ、リソースの無駄遣いを避けるのに役立ちます。
  3. waitForInteractionrecordInteraction(): 初回ロード時や特定のイベント後にViewabilityの計算をトリガーしたいが、通常はユーザー操作を待つ設定にしたい場合に有効です。


ScrollViewのonScrollイベントとmeasure()メソッドを組み合わせる

最も基本的な代替手段は、ScrollViewonScrollイベントリスナーと、各アイテムコンポーネントのmeasure()メソッド(またはmeasureInWindow()measureLayout())を組み合わせて、アイテムの画面上の位置を計算する方法です。

仕組み

  1. ScrollViewのonScroll
    スクロールイベントが発生するたびに呼び出され、現在のスクロール位置(event.nativeEvent.contentOffset.yなど)を取得できます。
  2. 各アイテムのrefとonLayout
    各アイテムコンポーネントにrefを設定し、そのonLayoutイベントでアイテム自体のレイアウト情報(幅、高さ、x/y座標)を取得します。
  3. measure()
    スクロールイベントが発生した際に、各アイテムのrefを使ってmeasure()メソッドを呼び出し、そのアイテムがビューポートのどこに位置しているかを計算します。
    • measure((x, y, width, height, pageX, pageY) => { ... })
    • x, yはビューポート内での相対位置、pageX, pageYは画面全体での絶対位置です。

利点

  • ScrollViewは、要素が少量で、かつすべての要素を常にマウントしておきたい場合に適しています。
  • 非常に特定のViewabilityの定義が必要な場合に、独自の計算ロジックを実装できます。
  • FlatListを使わないため、より低レベルでリストの表示を完全に制御できます。(ただし、FlatListの仮想化によるパフォーマンス最適化は失われます。)

欠点

  • 仮想化の欠如
    FlatListのような仮想化レンダリング(画面に表示されているアイテムのみをレンダリングする)が行われないため、メモリ使用量が増加し、UIが重くなる可能性があります。これにより、非常に長いリストには適していません。
  • 複雑なロジック
    Viewabilityの条件(例:アイテムの半分が表示されたら、一定時間表示されたら)を自分で実装する必要があり、FlatList#viewabilityConfigに比べてロジックが複雑になります。
  • パフォーマンス問題
    特に長いリストの場合、onScrollイベントが非常に頻繁に発火し、各アイテムのmeasure()を呼び出すことでパフォーマンスが著しく低下する可能性があります。JavaScriptスレッドとUIスレッド間のブリッジの負荷が高くなります。
import React, { useRef, useState, useCallback } from 'react';
import { ScrollView, View, Text, StyleSheet, Dimensions } from 'react-native';

const { height: screenHeight } = Dimensions.get('window');

const DATA = Array.from({ length: 50 }, (_, i) => ({ id: `item-${i}`, title: `アイテム ${i + 1}`, height: 120 }));

const ManualViewability = () => {
  const itemRefs = useRef({}); // 各アイテムのrefを保存
  const scrollViewRef = useRef(null);
  const [visibleItems, setVisibleItems] = useState([]);

  // 各アイテムのレイアウト情報を保持する(オプション)
  // 常に最新のレイアウトをmeasureするのが確実だが、パフォーマンスのためにキャッシュも考慮
  const itemLayouts = useRef({});

  const handleScroll = useCallback(() => {
    scrollViewRef.current?.measureInWindow((x, y, width, height, pageX, pageY) => {
      const scrollViewTop = pageY; // ScrollViewの画面上での位置

      const currentlyVisible = [];
      DATA.forEach(item => {
        const itemRef = itemRefs.current[item.id];
        if (itemRef) {
          itemRef.measureInWindow((itemX, itemY, itemWidth, itemHeight, itemPageX, itemPageY) => {
            // アイテムのビューポート内での相対位置
            const itemRelativeY = itemPageY - scrollViewTop;

            // アイテムがScrollViewの表示領域内にどれだけ入っているか(例:50%以上)
            const overlap = Math.max(0, Math.min(itemRelativeY + itemHeight, height) - Math.max(itemRelativeY, 0));
            const visiblePercentage = (overlap / itemHeight) * 100;

            if (visiblePercentage >= 50) {
              currentlyVisible.push(item.title);
            }
          });
        }
      });
      // 実際にはデバウンスなどでsetStatesの呼び出しを最適化する
      // このロジックはパフォーマンスを考慮して非常に慎重に記述する必要がある
      // setVisibleItems(currentlyVisible); // 頻繁な更新は避けるべき
    });
  }, []);

  const setItemRef = useCallback((itemId, element) => {
    if (element) {
      itemRefs.current[itemId] = element;
    }
  }, []);

  const renderItem = ({ item }) => (
    <View ref={el => setItemRef(item.id, el)} style={[styles.item, { height: item.height }]}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.header}>手動Viewability検出 (ScrollView):</Text>
      <Text style={styles.visibleStatus}>
        // ここに表示中のアイテムリストを表示するロジックは省略(複雑になるため)
      </Text>
      <ScrollView
        ref={scrollViewRef}
        onScroll={handleScroll}
        scrollEventThrottle={16} // イベント発火頻度を制御 (16ms = 60fps)
        style={styles.flatList}
      >
        {DATA.map(item => renderItem({ item }))}
      </ScrollView>
    </View>
  );
};

外部の高性能リストライブラリの使用

React NativeのFlatListは標準的なリストコンポーネントですが、さらに高度なパフォーマンスや特定の機能が必要な場合、コミュニティ製のライブラリが代替手段として存在します。これらのライブラリは、viewabilityConfigと同等かそれ以上のViewability検出機能を提供していることが多いです。

  • RecyclerListView (Flipkart製)

    • FlashListと同様に、ビューの再利用に焦点を当てた高性能なリストコンポーネントです。
    • より低レベルなAPIを提供しており、FlatListよりも学習コストがかかりますが、その分柔軟性も高まります。
    • Viewabilityの検出機能も提供しています。

    利点

    • 非常に高いパフォーマンスとメモリ効率。
    • 柔軟性が高く、複雑なリストUIも構築可能。

    欠点

    • FlatListFlashListに比べてAPIが複雑で、学習コストが高い。
    • ドキュメントがやや不十分な場合がある。
  • FlashList (Shopify製)

    • FlatListのドロップイン代替を目指して開発されており、非常に高いパフォーマンスを誇ります。
    • Recycling(ビューの再利用)というAndroidのRecyclerViewに似た仕組みを採用しており、メモリ使用量とレンダリング速度が大幅に改善されています。
    • viewabilityConfigとほぼ同じプロパティやonViewableItemsChangedコールバックをサポートしており、FlatListからの移行も比較的容易です。
    • 特にデータ数が非常に多いリストや、複雑なアイテムレイアウトを持つリストで威力を発揮します。

    利点

    • FlatListよりも優れたパフォーマンス。
    • FlatListとほとんど同じAPIを持ち、移行が簡単。
    • viewabilityConfigと同等の機能が提供されている。

    欠点

    • すべてのFlatListのプロパティを完全にサポートしているわけではない場合がある。
    • まだ比較的新しいライブラリであるため、予期せぬ挙動やバグに遭遇する可能性もゼロではない。
    import React, { useState, useCallback } from 'react';
    import { View, Text, StyleSheet } from 'react-native';
    import { FlashList } from '@shopify/flash-list'; // FlashListをインポート
    
    const DATA = Array.from({ length: 1000 }, (_, i) => ({
      id: `item-${i}`,
      title: `Flashアイテム ${i + 1}`,
      height: 100 + (i % 3) * 20,
    }));
    
    const FlashListViewability = () => {
      const [visibleItemTitles, setVisibleItemTitles] = useState([]);
    
      const viewabilityConfig = {
        itemVisiblePercentThreshold: 50,
        minimumViewTime: 200,
      };
    
      const onViewableItemsChanged = useCallback(({ viewableItems }) => {
        const currentVisibleTitles = viewableItems.map(item => item.item.title);
        console.log('--- FlashList Example ---');
        console.log('FlashList Viewable Items:', currentVisibleTitles);
        setVisibleItemTitles(currentVisibleTitles);
      }, []);
    
      const renderItem = useCallback(({ item }) => (
        <View style={[styles.item, { height: item.height }]}>
          <Text style={styles.title}>{item.title}</Text>
          <Text>高さ: {item.height}px</Text>
        </View>
      ), []); // renderItemもuseCallbackでメモ化すると良い
    
      return (
        <View style={styles.container}>
          <Text style={styles.header}>FlashListによるViewability検出:</Text>
          <Text style={styles.visibleStatus}>
            現在表示中: {visibleItemTitles.length > 0 ? visibleItemTitles.join(', ') : 'なし'}
          </Text>
          <FlashList
            data={DATA}
            renderItem={renderItem}
            keyExtractor={item => item.id}
            onViewableItemsChanged={onViewableItemsChanged}
            viewabilityConfig={viewabilityConfig}
            estimatedItemSize={120} // FlashListで非常に重要
            style={styles.flatList}
          />
        </View>
      );
    };
    

IntersectionObserverのようなカスタムフック/ライブラリ(ウェブの概念を応用)

ウェブ開発では要素のViewabilityを検出する標準APIとしてIntersectionObserverがありますが、React Nativeには直接的な同等のAPIは組み込まれていません。しかし、これを模倣したライブラリやカスタムフックを作成することで、特定のコンポーネントのViewabilityを個別に検出することも可能です。

  • react-native-intersection-observer (コミュニティ製)

    • ウェブのIntersectionObserver APIにインスパイアされたライブラリで、FlatListScrollViewの子要素のViewabilityを個別に監視できます。
    • FlatListviewabilityConfigがリスト全体のアイテムを監視するのに対し、この種のライブラリは個々のコンポーネントにアタッチしてその表示状態を監視するのに適しています。

    利点

    • コンポーネントレベルでViewabilityを監視できるため、特定の要素の表示・非表示に応じたアクション(例:アニメーションの開始/停止)に便利。
    • WebのIntersectionObserverに慣れている開発者には理解しやすいAPI。

    欠点

    • FlatListの仮想化と組み合わせる場合、FlatListがアイテムをアンマウント/マウントする際に、IntersectionObserverのインスタンスも再作成される可能性がある。
    • リスト全体ではなく個々のアイテムにアタッチするため、アイテム数が非常に多い場合にオーバーヘッドが生じる可能性がある。
    • 依存関係を追加する必要がある。

コード例(概念的)

// 例えば、このようなカスタムフックを作成すると仮定(ライブラリとして提供されている場合が多い)
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { View, Text, Dimensions, findNodeHandle, ScrollView } from 'react-native';

// 簡略化されたIntersectionObserver風カスタムフックの概念
// 実際のライブラリはもっと複雑なロジックを持つ
const useInView = (ref, options = {}) => {
  const [inView, setInView] = useState(false);
  const scrollOffsetRef = useRef(0);
  const checkTimerRef = useRef(null);

  const handleScroll = useCallback((event) => {
    scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
    // デバウンスしてチェック頻度を抑える
    if (checkTimerRef.current) clearTimeout(checkTimerRef.current);
    checkTimerRef.current = setTimeout(() => {
      if (ref.current) {
        ref.current.measureLayout(
          findNodeHandle(event.target), // ScrollViewのnative handle
          (x, y, width, height) => {
            const elementTop = y;
            const elementBottom = y + height;
            const viewportTop = scrollOffsetRef.current;
            const viewportBottom = scrollOffsetRef.current + Dimensions.get('window').height;

            const isIntersecting =
              elementBottom > viewportTop && elementTop < viewportBottom;
            
            // 例: 50%以上表示されたらinViewとみなす
            const overlap = Math.max(0, Math.min(elementBottom, viewportBottom) - Math.max(elementTop, viewportTop));
            const visiblePercentage = (overlap / height) * 100;

            if (visiblePercentage >= (options.threshold || 1)) {
              setInView(true);
            } else {
              setInView(false);
            }
          },
          () => {} // error callback
        );
      }
    }, 100); // 100msごとにチェック
  }, [options.threshold]);

  return { inView, handleScroll }; // ScrollViewにアタッチするhandleScrollを返す
};

// これをFlatListやScrollViewの子コンポーネント内で使用する
const ItemWithInView = ({ item, scrollViewRef }) => {
  const itemRef = useRef(null);
  const { inView } = useInView(itemRef, { threshold: 50 }); // このthresholdはuseInView内で使う

  useEffect(() => {
    if (inView) {
      console.log(`${item.title} が表示されました!`);
      // 例: 動画の自動再生、アニメーションの開始など
    } else {
      console.log(`${item.title} が非表示になりました。`);
    }
  }, [inView, item.title]);

  return (
    <View ref={itemRef} style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
      {inView && <Text style={styles.inViewText}>[表示中]</Text>}
    </View>
  );
};

// スタイルは上記の例と同じ

// メインのFlatListまたはScrollViewコンポーネントでItemWithInViewを使用
// このアプローチでは、FlatListのonViewableItemsChangedを使う代わりに、
// 各アイテムが自身の表示状態を管理する(これはかなり重い処理になる可能性が高い)
// 実際には、カスタムフックがScrollView/FlatListのonScrollイベントを直接監視し、
// 全てのアイテムのrefから位置を計算して、まとめてViewabilityを判定するような構造になる
// 上記のuseInViewは、単純化された概念的なもので、実際の実装はもっと複雑でパフォーマンスを考慮する必要があります。

FlatList#viewabilityConfigは、React NativeでリストのViewability検出を行うための最も推奨される方法です。ほとんどのユースケースではこれで十分であり、パフォーマンスも最適化されています。

代替手段を検討するのは、以下のような場合に限られるでしょう。

  • リスト全体ではなく、特定の数個の独立したコンポーネントの画面表示状態を監視したい場合(この場合はScrollViewonScrollmeasureの組み合わせが検討されるが、前述のパフォーマンス問題に注意が必要)。
  • FlatListのパフォーマンスがアプリケーションのボトルネックになっており、FlashListRecyclerListViewのようなより高性能なリストライブラリへの移行が必要な場合。
  • 極めて特殊なViewabilityの定義が必要で、viewabilityConfigのオプションではカバーできない場合。(この場合でもFlatListonViewableItemsChangedの内部ロジックを独自にカスタマイズする方が良いことが多いです。)