React Native開発者必見!FlatList#viewabilityConfigCallbackPairsでUXを向上させる方法

2025-05-31

React NativeにおけるFlatList#viewabilityConfigCallbackPairsについて

FlatListは、React Nativeで大量のデータを効率的にリスト表示するためのコンポーネントです。スクロールに応じて要素のレンダリングや破棄を行うことで、メモリ使用量を抑え、パフォーマンスを向上させます。

viewabilityConfigCallbackPairsプロパティは、FlatListの**「表示されている要素」に関する詳細な制御と、それらの要素の表示状態が変化した際のコールバック**を設定するための強力な機能です。

これは配列の配列として定義され、各内部配列は以下の2つの要素から構成されます。

  1. viewabilityConfig: object 表示状態を判定するための設定オブジェクトです。例えば、「要素がどれくらい画面に表示されたら『表示された』とみなすか」といった閾値を設定できます。主なプロパティには以下のようなものがあります。

    • minimumViewTime: number (ミリ秒) 要素が「表示された」とみなされるまでに、最低限表示されていなければならない時間です。
    • itemVisiblePercentThreshold: number (0-100) 要素の何パーセントが画面に表示されたら「表示された」とみなすか、という閾値です。例えば、50と設定すると、要素の半分が画面に表示されればコールバックが発火します。
    • waitForInteraction: boolean ユーザーがスクロールするなどの操作を行うまで、表示判定を待つかどうかを制御します。
    • viewAreaCoveragePercentThreshold: number (0-100) itemVisiblePercentThresholdと似ていますが、これは要素の表示領域全体に対する割合で判定します。
  2. onViewableItemsChanged: (info: {changed: Array<ViewToken>, viewableItems: Array<ViewToken>}) => void viewabilityConfigで設定された条件に基づいて、要素の表示状態が変化した際に呼び出されるコールバック関数です。このコールバックには、以下の情報を持つオブジェクトが引数として渡されます。

    • changed: 表示状態が変化したアイテムのリストです。各ViewTokenには、そのアイテムが新しく表示されたのか(isViewable: true)、それとも表示されなくなったのか(isViewable: false)の情報が含まれます。
    • viewableItems: 現在画面に表示されていると判定されたすべてのアイテムのリストです。

なぜviewabilityConfigCallbackPairsを使うのか?

このプロパティは、以下のような高度なユースケースで特に役立ちます。

  • パフォーマンス最適化: 不要なレンダリングや処理を、要素が実際に表示されるまで遅延させる。
  • UI/UXの改善: 特定のセクションが画面に表示されたら、そのセクションに関連するUI要素をハイライト表示する。
  • Lazy Loading: 特定のコンポーネントやデータが画面に表示されそうになったら、事前にデータを読み込む(プリフェッチ)。
  • 広告のトラッキング: 広告が実際にユーザーの画面に表示されたことを追跡し、インプレッション数をカウントする。
  • 動画の自動再生/停止: ユーザーが動画コンテンツまでスクロールしたら自動的に再生を開始し、画面外にスクロールしたら停止する。
import React, { useRef } from 'react';
import { FlatList, View, Text, Dimensions } from 'react-native';

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

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

function MyFlatList() {
  const onViewableItemsChanged = useRef(({ changed, viewableItems }) => {
    console.log('--- 表示状態が変化したアイテム ---');
    changed.forEach(item => {
      console.log(`ID: ${item.item.id}, 表示状態: ${item.isViewable ? '表示された' : '表示されなくなった'}`);
    });

    console.log('--- 現在表示されているアイテム ---');
    viewableItems.forEach(item => {
      console.log(`ID: ${item.item.id}`);
    });
  }).current;

  // 表示判定の設定
  const viewabilityConfig = {
    itemVisiblePercentThreshold: 50, // アイテムの50%が表示されたらコールバックを発火
    minimumViewTime: 500, // 最低500ミリ秒表示されていなければならない
  };

  const viewabilityConfigCallbackPairs = useRef([
    { viewabilityConfig, onViewableItemsChanged }
  ]).current;

  const renderItem = ({ item }) => (
    <View style={{ height: height * 0.3, justifyContent: 'center', alignItems: 'center', borderWidth: 1, borderColor: 'gray', marginVertical: 5 }}>
      <Text style={{ fontSize: 24 }}>{item.title}</Text>
    </View>
  );

  return (
    <FlatList
      data={DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id}
      viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
      // onViewableItemsChanged={onViewableItemsChanged} // 古い形式ではこのように直接渡していました
      // viewabilityConfig={viewabilityConfig} // これも古い形式
    />
  );
}

export default MyFlatList;
  • useRefを使ってonViewableItemsChangedviewabilityConfigCallbackPairsをメモ化しているのは、FlatListが頻繁に再レンダリングされる際に、これらのオブジェクトが不必要に再作成されるのを防ぐためです。これにより、パフォーマンスの向上と予期せぬコールバックの再発火を防ぐことができます。
  • 以前はonViewableItemsChangedviewabilityConfigという別々のプロパティがありましたが、FlatListのバージョンアップに伴い、より柔軟な制御のためにviewabilityConfigCallbackPairsが導入されました。これにより、複数の異なる表示判定ロジックとコールバックのペアを同時に設定することが可能になりました。


"Changing onViewableItemsChanged on the fly is not supported" エラー

これは最もよく遭遇するエラーの一つです。onViewableItemsChangedコールバック関数やviewabilityConfigオブジェクトが、コンポーネントの再レンダリング時に新しく作成されてしまうと発生します。React Nativeは、これらのプロパティがレンダリングサイクル中に変更されることを許可していません。

原因

  • viewabilityConfigオブジェクトも同様に、再レンダリング時に新しく作成される。
  • onViewableItemsChanged関数が、コンポーネントの内部で直接定義されており、コンポーネントが再レンダリングされるたびに新しい関数インスタンスが作成される。

トラブルシューティング/解決策

  • useRefまたはuseCallbackで関数と設定をメモ化する
    onViewableItemsChangedコールバック関数とviewabilityConfigオブジェクトを、useRefまたはuseCallback(関数用)とuseMemo(オブジェクト用)を使ってメモ化し、コンポーネリングが再レンダリングされても同じインスタンスが使用されるようにします。

    import React, { useRef, useCallback, useMemo } from 'react';
    import { FlatList, View, Text } from 'react-native';
    
    function MyFlatList() {
      // onViewableItemsChanged を useCallback でメモ化
      const onViewableItemsChanged = useCallback(({ changed, viewableItems }) => {
        // ロジック
        console.log('表示状態が変化しました:', changed);
      }, []); // 依存配列を空にすることで、関数インスタンスが作成時に一度だけ生成される
    
      // viewabilityConfig を useMemo でメモ化
      const viewabilityConfig = useMemo(() => ({
        itemVisiblePercentThreshold: 50,
        minimumViewTime: 500,
      }), []); // 依存配列を空にすることで、オブジェクトインスタンスが作成時に一度だけ生成される
    
      // viewabilityConfigCallbackPairs も useRef でメモ化
      const viewabilityConfigCallbackPairs = useRef([
        { viewabilityConfig, onViewableItemsChanged }
      ]).current;
    
      // ... FlatList のレンダリング ...
      return (
        <FlatList
          // ...他のProps...
          viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
        />
      );
    }
    

    古いonViewableItemsChangedviewabilityConfigのプロパティを直接使う場合でも、同様にuseRefuseCallback/useMemoでメモ化する必要があります。

onViewableItemsChangedが期待通りに発火しない/遅延する

コールバックが発火しない、または発火が遅れることがあります。これは主にviewabilityConfigの設定が厳しすぎる場合に起こります。

原因

  • リストの高さやアイテムの高さが適切でない
    FlatListは仮想化を行うため、高さが不適切だと表示領域の計算が狂うことがあります。
  • waitForInteractionがtrueになっている
    ユーザーがスクロールなどの操作を行うまで、表示判定が行われません。デバッグ中など、自動で発火させたい場合には注意が必要です。
  • itemVisiblePercentThresholdやviewAreaCoveragePercentThresholdが高すぎる
    要素の大部分が画面に表示されないと判定されないため、少しでも隠れると発火しないことがあります。
  • minimumViewTimeが長すぎる
    要素が「表示された」と判定されるまでに、指定された時間(ミリ秒)だけ表示されている必要があります。この値が長すぎると、素早いスクロールではコールバックが発火しにくくなります。

トラブルシューティング/解決策

  • FlatListやリストアイテムのスタイルを確認する
    • アイテムに適切な高さが設定されているか確認します。特にflexレイアウトを使用している場合は、親コンポーネントや他の兄弟要素との兼ね合いも確認してください。
    • FlatList自体にflex: 1や適切な高さが設定されていることを確認します。
  • viewabilityConfigの値を調整する
    • minimumViewTimeを短くする(例: 0または100ミリ秒)。
    • itemVisiblePercentThresholdviewAreaCoveragePercentThresholdを小さくする(例: 110)。
    • waitForInteractionfalseに設定する。

コールバック内のステートが古い(Stale Closure)

onViewableItemsChangedコールバック内でコンポーネントのステートを参照している場合、そのステートの値がコールバックの作成時の値のままで、最新の値ではないことがあります。これはJavaScriptのクロージャとReactのレンダリングサイクルに起因する問題です。

原因

  • useRefで参照している関数が、初期レンダリング時のステートをキャプチャしてしまっている場合。
  • useCallbackを使用し、依存配列にステート変数を追加し忘れた場合。

トラブルシューティング/解決策

  • useRefで可変値を保持する
    ステートの値ではなく、他の変更可能な値をコールバック内で参照したいが、コールバックの再作成は避けたい場合、useRefを使って可変値を保持します。

    const myMutableValue = useRef(initialValue);
    // ...どこかで myMutableValue.current = newValue; で値を更新...
    
    const onViewableItemsChanged = useCallback(({ changed, viewableItems }) => {
      console.log('参照している値:', myMutableValue.current);
    }, []); // 依存配列は空
    
  • ステート更新関数を使用する (Function Update Form)
    onViewableItemsChanged内でステートを更新する場合、直接ステートの値を参照するのではなく、更新関数を使用します。これにより、コールバックが古いステートをキャプチャしていても、更新時には最新のステートに基づいて処理が行われます。

    const [viewableItemIds, setViewableItemIds] = useState([]);
    
    const onViewableItemsChanged = useCallback(({ viewableItems }) => {
      // `prevIds` は常に最新のステート値
      setViewableItemIds(prevIds => {
        const newViewableIds = viewableItems.map(item => item.item.id);
        // ロジックに基づいて新しいIDリストを返す
        return newViewableIds;
      });
    }, []); // 依存配列は空でOK、なぜなら更新関数は常に同じ参照を保つため
    
  • useCallbackの依存配列に全てのステート変数を含める
    onViewableItemsChanged関数が依存するステート変数やプロップスを、useCallbackの第二引数の配列(依存配列)に含めることで、それらの値が変更されたときに新しい関数インスタンスが生成され、最新のステートがキャプチャされます。

    const [count, setCount] = useState(0);
    
    const onViewableItemsChanged = useCallback(({ changed, viewableItems }) => {
      // count は最新の値になる
      console.log('現在のカウント:', count);
      // ...
    }, [count]); // count が変更されたら新しい関数が作成される
    

    ただし、これによりonViewableItemsChanged関数が頻繁に再作成され、「Changing onViewableItemsChanged on the fly is not supported」エラーが再度発生する可能性があります。このジレンマを解決するためには、以下の方法を検討します。

FlatListのパフォーマンス問題と関連する誤解

viewabilityConfigCallbackPairsは表示判定を行うため、パフォーマンスに影響を与える可能性があります。

よくある誤解と問題

  • 不要な再レンダリング
    onViewableItemsChangedがステートを更新することで、関連するコンポーネントが不要に再レンダリングされ、パフォーマンスが低下することがあります。
  • onViewableItemsChanged内で重い処理を行う
    コールバックはスクロール中に頻繁に発火する可能性があるため、ここで時間のかかる処理(大量のデータ操作、複雑なコンポーネントのレンダリング、ネットワークリクエストなど)を行うと、UIのフリーズやラグが発生します。

トラブルシューティング/解決策

  • デバッグモードでのパフォーマンス低下
    React Nativeのデバッグモード(Chrome Debuggerなど)では、パフォーマンスが大幅に低下することがよくあります。実機でのリリースビルド(Release Mode)でパフォーマンスを評価することが重要です。
  • initialNumToRender, maxToRenderPerBatch, windowSizeなどのプロパティを調整する
    これらのFlatListのパフォーマンス関連プロパティを調整することで、レンダリングされるアイテムの数を制御し、表示判定の負荷を軽減できる場合があります。
  • React.memoやPureComponentでアイテムコンポーネントを最適化する
    renderItemで描画される個々のリストアイテムが、不要なプロップスの変更で再レンダリングされないように、React.memo(関数コンポーネント)やPureComponent(クラスコンポーネント)でラップすることを検討します。これにより、FlatListの仮想化の恩恵を最大限に受けられます。
  • コールバック内の処理を最小限にする
    onViewableItemsChangedでは、表示状態の判定と、必要最低限のステート更新のみを行うようにします。重い処理は、そのステート変更をトリガーとして、useEffectなどで非同期に実行することを検討します。

直接viewabilityConfigCallbackPairsのエラーではありませんが、FlatList全般の共通エラーとして、keyExtractorが適切に設定されていないと、リストアイテムの再レンダリングや表示状態の追跡に問題が生じることがあります。

原因

  • keyExtractorがそもそも設定されていない(デフォルトではindexが使われ、これは推奨されない)。
  • keyExtractorがユニークなキーを返さない。

トラブルシューティング/解決策

  • 各リストアイテムにユニークなキーを設定する
    data配列の各要素には、idのようなユニークなプロパティがあるはずです。それをkeyExtractorで返すようにします。

    <FlatList
      data={DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id} // item.id がユニークであることを確認
      // ...
    />
    


FlatList#viewabilityConfigCallbackPairsは、FlatList内の要素の表示状態(ビューアビリティ)を詳細に監視し、その状態が変化した際に特定の処理を実行するための強力な機能です。ここでは、いくつかの一般的なユースケースに焦点を当ててコード例を見ていきます。

基本的な表示・非表示のログ出力

最も基本的な例として、アイテムが画面に表示されたり非表示になったりしたときにコンソールにログを出力する例です。

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

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

// ダミーデータ
const DATA = Array.from({ length: 30 }, (_, i) => ({
  id: String(i),
  title: `アイテム ${i + 1}`,
  description: `これはアイテム ${i + 1} の詳細です。`,
}));

function BasicViewabilityExample() {
  // `onViewableItemsChanged` コールバック関数をメモ化
  // 依存配列が空なので、コンポーネントのマウント時に一度だけ作成される
  const onViewableItemsChanged = useCallback(({ changed, viewableItems }) => {
    console.log('--- 表示状態が変化したアイテム (changed) ---');
    changed.forEach(item => {
      console.log(`ID: ${item.item.id}, Viewable: ${item.isViewable}`);
    });

    // 現在表示されているすべてのアイテムをログに出力
    // console.log('--- 現在表示されているアイテム (viewableItems) ---');
    // viewableItems.forEach(item => {
    //   console.log(`ID: ${item.item.id}`);
    // });
  }, []);

  // `viewabilityConfig` オブジェクトをメモ化
  // アイテムの50%が表示され、かつ最低100ms表示されたら発火
  const viewabilityConfig = useMemo(() => ({
    itemVisiblePercentThreshold: 50, // アイテムの50%が表示されたら
    minimumViewTime: 100, // 最低100ミリ秒表示されたら
    // waitForInteraction: false, // デフォルトはfalseなので不要だが明示的に書いても良い
  }), []);

  // `viewabilityConfigCallbackPairs` を useRef でメモ化
  // これにより、FlatList に渡される配列インスタンスが常に同じになる
  const viewabilityConfigCallbackPairs = useRef([
    { viewabilityConfig, onViewableItemsChanged }
  ]).current;

  // 各リストアイテムのレンダリング
  const renderItem = useCallback(({ item }) => (
    <View style={styles.itemContainer}>
      <Text style={styles.itemTitle}>{item.title}</Text>
      <Text style={styles.itemDescription}>{item.description}</Text>
    </View>
  ), []); // item が変更されない限り再レンダリング不要なので useCallback でラップ

  return (
    <FlatList
      data={DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id}
      viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
      // FlatList 自体の高さ設定
      style={styles.flatList}
      contentContainerStyle={styles.contentContainer}
    />
  );
}

const styles = StyleSheet.create({
  flatList: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  contentContainer: {
    paddingVertical: 10,
  },
  itemContainer: {
    height: screenHeight * 0.25, // 画面の高さの25%を各アイテムの高さとする
    backgroundColor: 'white',
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
    justifyContent: 'center',
    alignItems: 'center',
  },
  itemTitle: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 5,
  },
  itemDescription: {
    fontSize: 14,
    color: '#666',
  },
});

export default BasicViewabilityExample;

解説

  • onViewableItemsChangedコールバックは、表示状態が変化したchangedアイテムと、現在表示されているviewableItemsのリストを受け取ります。この例ではchangedリストを使って、どのアイテムが表示されたり非表示になったかをログ出力しています。
  • viewabilityConfigでは、アイテムが50%画面に表示され、かつ最低100ミリ秒その状態が継続した場合にコールバックが発火するよう設定しています。
  • useRef, useCallback, useMemo を使用して、onViewableItemsChanged 関数と viewabilityConfig オブジェクトのインスタンスが再レンダリング時に変わらないようにしています。これにより、「Changing onViewableItemsChanged on the fly is not supported」というエラーを防ぎます。

表示された動画の自動再生/停止

ユーザーが動画アイテムまでスクロールしたら自動的に再生を開始し、画面外にスクロールしたら停止する例です。

import React, { useRef, useState, useCallback, useMemo } from 'react';
import { FlatList, View, Text, Dimensions, StyleSheet, Button } from 'react-native';
// 動画ライブラリのモック (実際には react-native-video などを使用)
const VideoPlayer = ({ videoId, isPlaying }) => {
  console.log(`Video ${videoId}: ${isPlaying ? '再生中' : '停止中'}`);
  return (
    <View style={styles.videoPlayer}>
      <Text style={styles.videoText}>{videoId}</Text>
      <Text style={styles.videoStatus}>{isPlaying ? ' 再生中' : ' 停止中'}</Text>
    </View>
  );
};

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

const VIDEO_DATA = Array.from({ length: 10 }, (_, i) => ({
  id: `video-${i + 1}`,
  title: `動画 ${i + 1}`,
}));

function VideoAutoPlayExample() {
  // 現在再生中の動画IDを保持するステート
  // onMomentumScrollEnd などで初期化することも考慮に入れる
  const [playingVideoId, setPlayingVideoId] = useState(null);

  // onMomentumScrollEnd または onScrollEndDrag での利用を想定した useRef
  const lastViewableItemsRef = useRef([]);

  // `onViewableItemsChanged` コールバック
  const onViewableItemsChanged = useCallback(({ changed, viewableItems }) => {
    lastViewableItemsRef.current = viewableItems; // 最新の表示アイテムを保存

    // 現在再生中の動画を特定し、状態を更新する
    const currentViewableVideo = viewableItems.find(item => item.item.id.startsWith('video-'));

    if (currentViewableVideo && currentViewableVideo.item.id !== playingVideoId) {
      // 新しい動画が表示されたら再生
      setPlayingVideoId(currentViewableVideo.item.id);
    } else if (!currentViewableVideo && playingVideoId) {
      // 表示中の動画がなくなり、かつ何か再生中だった場合は停止
      setPlayingVideoId(null);
    }
  }, [playingVideoId]); // playingVideoId が変更されたら新しいコールバックインスタンスを生成

  // ビューアビリティ設定
  // 画面中央付近に動画が完全に表示されたら発火するよう厳しめに設定
  const videoViewabilityConfig = useMemo(() => ({
    itemVisiblePercentThreshold: 90, // アイテムの90%以上が表示されたら
    minimumViewTime: 200, // 最低200ms表示
    waitForInteraction: false, // ユーザー操作を待たない
  }), []);

  const viewabilityConfigCallbackPairs = useRef([
    { viewabilityConfig: videoViewabilityConfig, onViewableItemsChanged }
  ]).current;

  const renderItem = useCallback(({ item }) => (
    <View style={styles.videoItemContainer}>
      <Text style={styles.videoTitle}>{item.title}</Text>
      <VideoPlayer
        videoId={item.id}
        isPlaying={playingVideoId === item.id} // 現在のアイテムが再生中か判定
      />
    </View>
  ), [playingVideoId]); // playingVideoId が変更されたら、該当アイテムが再レンダリングされる

  return (
    <FlatList
      data={VIDEO_DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id}
      viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
      style={styles.flatList}
      contentContainerStyle={styles.contentContainer}
      // スクロール停止時にのみ再生を切り替える場合は以下のプロパティも検討
      // onMomentumScrollEnd={() => {
      //   const currentViewableVideo = lastViewableItemsRef.current.find(item => item.item.id.startsWith('video-'));
      //   if (currentViewableVideo && currentViewableVideo.item.id !== playingVideoId) {
      //     setPlayingVideoId(currentViewableVideo.item.id);
      //   } else if (!currentViewableVideo && playingVideoId) {
      //     setPlayingVideoId(null);
      //   }
      // }}
    />
  );
}

const styles = StyleSheet.create({
  flatList: {
    flex: 1,
    backgroundColor: '#e0f7fa',
  },
  contentContainer: {
    paddingVertical: 10,
  },
  videoItemContainer: {
    height: screenHeight * 0.6, // 各動画アイテムは画面の60%の高さ
    backgroundColor: '#fff',
    marginVertical: 10,
    marginHorizontal: 16,
    borderRadius: 10,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.2,
    shadowRadius: 6,
    elevation: 5,
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden', // VideoPlayer の表示を確実に
  },
  videoTitle: {
    fontSize: 22,
    fontWeight: 'bold',
    marginBottom: 10,
    color: '#333',
  },
  videoPlayer: {
    width: '90%',
    height: '70%',
    backgroundColor: '#263238',
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
  },
  videoText: {
    fontSize: 28,
    fontWeight: 'bold',
    color: '#eceff1',
  },
  videoStatus: {
    fontSize: 16,
    marginTop: 5,
    color: '#a7ffeb',
  }
});

export default VideoAutoPlayExample;

解説

  • viewabilityConfig は、動画がほぼ完全に画面に表示された場合にのみ再生を開始するように、itemVisiblePercentThreshold: 90 と厳しめに設定しています。
  • renderItemplayingVideoId の変更に依存するため、useCallback の依存配列に playingVideoId を含めています。これにより、再生状態が変化した動画アイテムのみが再レンダリングされ、効率的です。
  • 新しい動画が表示された場合はそのIDを playingVideoId に設定し、動画が画面から外れた場合は playingVideoIdnull に設定します。
  • onViewableItemsChanged コールバック内で、viewableItems リストをフィルタリングし、現在画面に表示されている動画アイテムを特定します。
  • playingVideoId ステートで現在再生中の動画のIDを管理します。

複数の異なる表示判定ロジックとコールバック

viewabilityConfigCallbackPairs は配列なので、複数の異なる表示判定ルールとそれに対応するコールバックを設定できます。例えば、ある条件で広告のインプレッションを計測し、別の条件でアイテムのデータをプリフェッチする、といった使い方が考えられます。

この例では、以下の2つのルールを設定します。

  1. 「短時間でも表示されたら」 ログ出力
  2. 「半分以上表示されたら」 特定のステートを更新
import React, { useRef, useState, useCallback, useMemo } from 'react';
import { FlatList, View, Text, Dimensions, StyleSheet } from 'react-native';

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

const MIXED_DATA = Array.from({ length: 40 }, (_, i) => ({
  id: `item-${i + 1}`,
  type: i % 5 === 0 ? 'AD' : 'NORMAL', // 5つに1つを広告としてマーク
  content: `コンテンツ ${i + 1}`,
}));

function MultipleViewabilityExample() {
  const [fullyViewedItems, setFullyViewedItems] = useState(new Set()); // 完全に表示されたアイテムのIDを追跡

  // --- 1つ目のコールバック: 短時間表示されたアイテムのログ ---
  const onAnyItemViewed = useCallback(({ changed }) => {
    changed.forEach(item => {
      if (item.isViewable) {
        console.log(`[Any Viewable] ID: ${item.item.id} が表示されました。`);
      } else {
        console.log(`[Any Viewable] ID: ${item.item.id} が非表示になりました。`);
      }
    });
  }, []);

  // --- 2つ目のコールバック: 半分以上表示されたアイテムのステート更新 ---
  const onHalfItemViewed = useCallback(({ changed }) => {
    // 古いステートに依存する更新なので、関数形式のステート更新を使う
    setFullyViewedItems(prevSet => {
      const newSet = new Set(prevSet);
      changed.forEach(item => {
        if (item.isViewable) {
          newSet.add(item.item.id);
        } else {
          newSet.delete(item.item.id);
        }
      });
      return newSet;
    });
  }, []); // 依存配列は空でOK、setFullyViewedItems は stable な参照のため

  // --- 1つ目の viewabilityConfig: 1%でも見えたら発火 ---
  const configAnyViewable = useMemo(() => ({
    itemVisiblePercentThreshold: 1, // アイテムの1%が表示されたら
    minimumViewTime: 50, // 最低50ミリ秒
  }), []);

  // --- 2つ目の viewabilityConfig: 50%以上見えたら発火 ---
  const configHalfViewable = useMemo(() => ({
    itemVisiblePercentThreshold: 50, // アイテムの50%が表示されたら
    minimumViewTime: 200, // 最低200ミリ秒
  }), []);

  // 2つのペアを設定
  const viewabilityConfigCallbackPairs = useRef([
    { viewabilityConfig: configAnyViewable, onViewableItemsChanged: onAnyItemViewed },
    { viewabilityConfig: configHalfViewable, onViewableItemsChanged: onHalfItemViewed },
  ]).current;

  const renderItem = useCallback(({ item }) => (
    <View
      style={[
        styles.mixedItemContainer,
        { backgroundColor: item.type === 'AD' ? '#ffe0b2' : '#e3f2fd' }, // 広告と通常で色分け
        fullyViewedItems.has(item.id) && styles.fullyViewedItem, // 完全に表示されたらスタイル変更
      ]}
    >
      <Text style={styles.mixedItemTitle}>{item.content}</Text>
      {item.type === 'AD' && <Text style={styles.adLabel}>広告</Text>}
      {fullyViewedItems.has(item.id) && (
        <Text style={styles.statusText}>完全に表示中!</Text>
      )}
    </View>
  ), [fullyViewedItems]); // fullyViewedItems が変更されたら再レンダリング

  return (
    <FlatList
      data={MIXED_DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id}
      viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
      style={styles.flatList}
      contentContainerStyle={styles.contentContainer}
    />
  );
}

const styles = StyleSheet.create({
  flatList: {
    flex: 1,
    backgroundColor: '#f9f9f9',
  },
  contentContainer: {
    paddingVertical: 10,
  },
  mixedItemContainer: {
    height: screenHeight * 0.2, // 各アイテムは画面の20%の高さ
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
    elevation: 2,
    justifyContent: 'center',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#ddd',
  },
  mixedItemTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 5,
  },
  adLabel: {
    fontSize: 12,
    fontWeight: 'bold',
    color: 'red',
    position: 'absolute',
    top: 5,
    right: 10,
  },
  fullyViewedItem: {
    borderColor: '#4caf50', // 完全に表示されたら緑色のボーダー
    borderWidth: 2,
  },
  statusText: {
    marginTop: 5,
    fontSize: 12,
    color: '#4caf50',
    fontWeight: 'bold',
  }
});

export default MultipleViewabilityExample;
  • fullyViewedItems のステート更新には、関数形式の setFullyViewedItems(prevSet => ...) を使用しています。これにより、onHalfItemViewed コールバックが古い fullyViewedItems の値をクロージャとして保持していても、常に最新のステートに基づいて Set を更新できます。
  • configHalfViewableonHalfItemViewed のペアは、アイテムの半分以上(50%)が表示された場合に、fullyViewedItems ステートを更新します。このステートは、アイテムのスタイル変更に利用されています。
  • configAnyViewableonAnyItemViewed のペアは、アイテムが少しでも(1%)表示されればログを出力します。これは例えば、ユーザーが特定のアイテムを「見た」という単純な計測に使えます。
  • viewabilityConfigCallbackPairs 配列に2つの異なる { viewabilityConfig, onViewableItemsChanged } ペアを設定しています。


onScroll イベントと NativeEvent を利用する

最も基本的な代替手段であり、スクロールイベントを監視して手動で要素の位置と表示状態を計算する方法です。