【React Native】onViewableItemsChangedで実現する無限スクロールとパフォーマンス最適化

2025-05-31

FlatList コンポーネントの onViewableItemsChanged プロパティは、画面に表示されているアイテムの状態が変化した際に呼び出される関数です。具体的には、以下のいずれかの状況が発生したときにこの関数が実行されます。

  • 画面に表示されていたアイテムが非表示になった場合
  • 新しいアイテムが画面に表示された場合

この関数は、引数としてオブジェクトを受け取ります。このオブジェクトには、以下の2つの重要なプロパティが含まれています。

  1. viewableItems: 現在画面に表示されているアイテムの情報の配列です。各アイテムの情報は、以下のプロパティを持つオブジェクトとして表現されます。

    • item: リストのデータとして渡された個々のアイテムのオブジェクトです。
    • index: そのアイテムがリストのデータ配列内で持つインデックスです。
    • key: そのアイテムに割り当てられたキーです。
    • isViewable: そのアイテムが現在画面に完全に、または部分的に表示されているかどうかを示す真偽値です。(通常は true です)
    • section: SectionList コンポーネントで使用される場合に、そのアイテムが属するセクションの情報です。(FlatList の場合は通常 undefined です)
  2. changed: 直前の表示状態から変化があったアイテムの情報の配列です。各アイテムの情報は、viewableItems の要素と同様の構造を持っています。この配列には、新たに表示されたアイテムと、非表示になったアイテムの情報が含まれます。

onViewableItemsChanged の主な用途

  • アニメーションの制御
    特定のアイテムが表示されたときにアニメーションを開始したり、非表示になったときにアニメーションを終了したりできます。
  • 表示状態の追跡
    ユーザーがどのアイテムを閲覧したかを追跡し、分析やUIの更新に利用できます。
  • 再生/一時停止の制御
    動画や音声のリストで、現在表示されているアイテムの再生を開始し、非表示になったアイテムの再生を一時停止するなどの制御が可能です。
  • 遅延ローディング (Lazy Loading)
    画面に新しいアイテムが表示されたときに、そのアイテムに関連する追加のデータを非同期でロードする処理をトリガーできます。

使用例

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

const data = Array.from({ length: 50 }, (_, index) => ({ id: index.toString(), title: `Item ${index + 1}` }));

const App = () => {
  const [viewableItemsInfo, setViewableItemsInfo] = useState([]);

  const onViewableItemsChanged = ({ viewableItems, changed }) => {
    setViewableItemsInfo(viewableItems);
    console.log('Visible items changed:', changed.map(v => v.item.title));
  };

  return (
    <View>
      <FlatList
        data={data}
        renderItem={({ item }) => <Text style={{ padding: 10, borderBottomWidth: 1 }}>{item.title}</Text>}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={{
          itemVisiblePercentThreshold: 50, // アイテムの50%以上が表示されたら表示されたとみなす
        }}
      />
      <Text style={{ marginTop: 20 }}>
        現在表示中のアイテム: {viewableItemsInfo.map(item => item.item.title).join(', ')}
      </Text>
    </View>
  );
};

export default App;

この例では、onViewableItemsChanged 関数が呼び出されるたびに、現在表示されているアイテムの情報 (viewableItems) を useState で管理し、コンソールに変化のあったアイテムのタイトル (changed) を出力しています。また、viewabilityConfig プロパティで、アイテムがどの程度表示されたら「表示された」とみなすかの閾値を設定しています。



よくあるエラーとトラブルシューティング

    • 原因
      • viewabilityConfig が適切に設定されていない。特に itemVisiblePercentThreshold の値が極端に大きい(例えば 100 より大きい)場合、アイテムが完全に表示されないとイベントが発生しないことがあります。
      • viewabilityConfigCallbackPairs を使用している場合に、設定が正しくない。
      • keyExtractor が正しく実装されていないため、React がアイテムの同一性を正しく認識できず、表示状態の変化を検知できない。
      • FlatList の親コンポーネントのスタイル設定(例えば overflow: hidden など)が、表示領域の計算に影響を与えている。
    • 解決策
      • viewabilityConfigitemVisiblePercentThreshold を適切な値(0〜100)に調整する。一般的には 0 や 50 がよく使われます。
      • viewabilityConfigCallbackPairs を使用している場合は、それぞれの設定が意図通りになっているか確認する。
      • keyExtractor がリストの各アイテムに対して一意のキーを返すように正しく実装されているか確認する。
      • 親コンポーネントのスタイル設定を見直し、FlatList の表示領域が正しく計算されるように調整する。
  1. viewableItems や changed の内容が期待と異なる

    • 原因
      • viewabilityConfig の設定が、期待する表示状態の変化の閾値と合っていない。
      • リストのアイテムが頻繁に更新されるなど、表示状態が不安定な場合に、意図しないタイミングでイベントが発生したり、changed に予期しないアイテムが含まれることがある。
      • 非同期処理の結果としてリストのデータが更新される場合に、表示状態の変化が連続して発生し、onViewableItemsChanged が複数回呼び出されることがある。
    • 解決策
      • viewabilityConfig を調整し、イベントが発生する条件をより具体的に設定する。
      • リストのデータの更新処理を見直し、不要な再レンダリングや表示状態の変化を抑制する。
      • 非同期処理の結果を扱う場合は、debounce や throttle などの手法を用いて、onViewableItemsChanged の呼び出し頻度を制御することを検討する。
  2. パフォーマンスの問題

    • 原因
      • onViewableItemsChanged 内での処理が重い。特に、表示状態の変化に応じて複雑な計算やUIの更新を行うと、スクロールのパフォーマンスが低下する可能性がある。
      • 頻繁な表示状態の変化によって、onViewableItemsChanged が過剰に呼び出されている。
    • 解決策
      • onViewableItemsChanged 内の処理をできるだけ軽量化する。必要であれば、setState の呼び出しを最小限にしたり、非同期処理を活用する。
      • shouldComponentUpdateReact.memo などの最適化手法を用いて、不要な再レンダリングを避ける。
      • viewabilityConfig を調整し、イベントの発生頻度を適切に制御する。
  3. changed に非表示になったアイテムの情報が含まれない

    • 原因
      • アイテムが画面外に完全にスクロールアウトする前に、別のアイテムがレンダリングされてしまい、表示状態の変化として認識されない場合がある。
      • viewabilityConfig の設定によっては、わずかにでも画面に残っているアイテムは「非表示になった」と判定されないことがある。
    • 解決策
      • viewabilityConfigitemVisiblePercentThreshold をより小さい値に設定することを検討する。
      • 必要であれば、スクロールイベント (onScroll) を組み合わせて、より詳細な表示状態の追跡を行うことも検討する。

トラブルシューティングのヒント

  • コミュニティの活用
    Stack Overflow や GitHub の Issues などで、同様の問題に遭遇した人がいないか検索してみる。解決策が見つかるかもしれません。
  • 公式ドキュメントの再確認
    React Native の公式ドキュメントの FlatList セクションを再度確認し、onViewableItemsChangedviewabilityConfig の詳細な仕様を理解する。
  • React Developer Tools の利用
    React Developer Tools を使用して、コンポーネントのpropsやstateの変化を監視し、FlatList の再レンダリングや onViewableItemsChanged の呼び出しタイミングなどを確認する。
  • シンプルな実装から始める
    まずは最小限のコードで onViewableItemsChanged の基本的な動作を確認し、徐々に複雑な処理を追加していくことで、問題の切り分けがしやすくなります。
  • console.log の活用
    onViewableItemsChanged がいつ、どのような引数で呼び出されているかを console.log で確認し、期待される動作とのずれを特定する。viewableItemschanged の内容を詳しく調べることは非常に有効です。


例1: 現在表示されているアイテムのタイトルを追跡し、表示する

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

const data = Array.from({ length: 20 }, (_, index) => ({ id: index.toString(), title: `アイテム ${index + 1}` }));

const App = () => {
  const [visibleTitles, setVisibleTitles] = useState([]);

  const onViewableItemsChanged = ({ viewableItems }) => {
    const titles = viewableItems.map(itemInfo => itemInfo.item.title);
    setVisibleTitles(titles);
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={data}
        renderItem={({ item }) => <Text style={{ padding: 10, borderBottomWidth: 1 }}>{item.title}</Text>}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
      />
      <Text style={{ marginTop: 20, padding: 10 }}>
        現在表示中のアイテム: {visibleTitles.join(', ')}
      </Text>
    </View>
  );
};

export default App;

この例では、onViewableItemsChanged 関数内で viewableItems 配列から各アイテムの title を抽出し、useState で管理している visibleTitles を更新しています。これにより、画面に表示されているアイテムのタイトルが常に画面下部に表示されます。

例2: 画面に表示されたときにのみ処理を実行する (Lazy Loading の基礎)

新しいアイテムが画面に表示されたときに、何らかの処理(例えば、追加データのロード)を実行する基本的なパターンです。

import React, { useState, useEffect } from 'react';
import { FlatList, Text, View, ActivityIndicator } from 'react-native';

const initialData = Array.from({ length: 10 }, (_, index) => ({ id: index.toString(), title: `初期アイテム ${index + 1}`, loaded: true }));

const App = () => {
  const [data, setData] = useState(initialData);
  const [loadingMore, setLoadingMore] = useState(false);

  const loadMoreData = async (itemId) => {
    setLoadingMore(true);
    // ここで非同期処理(API呼び出しなど)を実行して追加データを取得する
    await new Promise(resolve => setTimeout(resolve, 1000)); // 1秒遅延をシミュレート
    const newItems = Array.from({ length: 5 }, (_, index) => ({
      id: `new-${itemId}-${index}`,
      title: `追加アイテム (ID: ${itemId}) ${index + 1}`,
      loaded: true,
    }));
    setData(prevData => [...prevData, ...newItems]);
    setLoadingMore(false);
  };

  const onViewableItemsChanged = ({ changed }) => {
    changed.forEach(itemInfo => {
      if (itemInfo.isViewable && !data.find(item => item.id === itemInfo.item.id).loaded) {
        // まだロードされていないアイテムが新しく表示された場合
        console.log(`アイテム "${itemInfo.item.title}" が表示されました。追加データをロードします。`);
        loadMoreData(itemInfo.item.id);
        setData(prevData =>
          prevData.map(item =>
            item.id === itemInfo.item.id ? { ...item, loaded: true } : item
          )
        );
      }
    });
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={data}
        renderItem={({ item }) => (
          <View style={{ padding: 10, borderBottomWidth: 1 }}>
            <Text>{item.title}</Text>
            {!item.loaded && <Text style={{ color: 'gray' }}>ロード中...</Text>}
          </View>
        )}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
        ListFooterComponent={loadingMore && <ActivityIndicator />}
      />
    </View>
  );
};

export default App;

この例では、changed 配列を使って、新しく画面に表示された (isViewable: true) かつまだ loaded されていないアイテムを検出し、loadMoreData 関数を呼び出して追加データをロードする処理をシミュレートしています。

例3: 特定のアイテムが表示されたときにアニメーションを開始する

画面に特定のアイテムが表示されたときに、アニメーションを開始する例です。Animated API を使用します。

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

const data = Array.from({ length: 10 }, (_, index) => ({ id: index.toString(), title: `アニメーションアイテム ${index + 1}` }));

const App = () => {
  const [animatedValues] = useState(() =>
    data.reduce((acc, item) => ({ ...acc, [item.id]: new Animated.Value(0) }), {})
  );

  const onViewableItemsChanged = ({ changed }) => {
    changed.forEach(itemInfo => {
      if (itemInfo.isViewable) {
        // 表示されたアイテムのアニメーションを開始
        Animated.timing(animatedValues[itemInfo.item.id], {
          toValue: 1,
          duration: 500,
          useNativeDriver: true,
        }).start();
      } else {
        // 非表示になったアイテムのアニメーションをリセット
        Animated.timing(animatedValues[itemInfo.item.id], {
          toValue: 0,
          duration: 0,
          useNativeDriver: true,
        }).start();
      }
    });
  };

  const renderItem = ({ item }) => {
    const opacity = animatedValues[item.id];
    return (
      <Animated.View style={{ padding: 20, borderBottomWidth: 1, opacity }}>
        <Text>{item.title}</Text>
      </Animated.View>
    );
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={data}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
      />
    </View>
  );
};

export default App;

この例では、各アイテムに対応する Animated.ValueuseState で作成し、onViewableItemsChanged でアイテムの表示状態が変化したときに、対応する Animated.Value をアニメーションさせています。

例4: viewabilityConfigCallbackPairs を使用して、表示割合に応じて異なる処理を行う

viewabilityConfigCallbackPairs を使用すると、アイテムの表示割合に応じて異なるコールバック関数を実行できます。

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

const data = Array.from({ length: 10 }, (_, index) => ({ id: index.toString(), title: `アイテム ${index + 1}` }));

const App = () => {
  const [visibleStates, setVisibleStates] = useState({});

  const onViewableItemsChanged = ({ viewableItems }) => {
    const newState = {};
    viewableItems.forEach(itemInfo => {
      newState[itemInfo.item.id] = itemInfo.isViewable ? '表示中' : '非表示';
    });
    setVisibleStates(newState);
  };

  const viewabilityConfigCallbackPairs = [
    {
      viewAreaCoveragePercentThreshold: 50,
      onViewableItemsChanged: ({ viewableItems }) => {
        viewableItems.forEach(itemInfo => {
          if (itemInfo.isViewable) {
            console.log(`"${itemInfo.item.title}" が 50% 以上表示されました。`);
          }
        });
      },
    },
    {
      viewAreaCoveragePercentThreshold: 100,
      onViewableItemsChanged: ({ viewableItems }) => {
        viewableItems.forEach(itemInfo => {
          if (itemInfo.isViewable) {
            console.log(`"${itemInfo.item.title}" が完全に表示されました!`);
          }
        });
      },
    },
  ];

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={data}
        renderItem={({ item }) => (
          <View style={{ padding: 10, borderBottomWidth: 1 }}>
            <Text>{item.title}</Text>
            <Text style={{ color: 'gray' }}>状態: {visibleStates[item.id] || '初期'}</Text>
          </View>
        )}
        keyExtractor={item => item.id}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
      />
    </View>
  );
};

export default App;

この例では、viewabilityConfigCallbackPairs に2つの設定を渡しています。1つ目はアイテムが 50% 以上表示されたときに、2つ目は完全に表示されたときにそれぞれ異なるコールバック関数が実行されます。通常の onViewableItemsChanged と組み合わせて、表示状態を追跡することも可能です。



ScrollView と onScroll イベントを使用する

FlatList の代わりに ScrollView を使用し、その onScroll イベントを監視することで、スクロール位置の変化を検知し、表示されているアイテムを間接的に判断する方法です。

  • 実装例 (概念)

    import React, { useRef, useEffect, useState } from 'react';
    import { ScrollView, View, Text, StyleSheet, findNodeHandle, Dimensions } from 'react-native';
    
    const data = Array.from({ length: 50 }, (_, index) => ({ id: index.toString(), title: `アイテム ${index + 1}` }));
    const itemHeight = 50; // 各アイテムの高さ
    
    const App = () => {
      const scrollViewRef = useRef(null);
      const [visibleItems, setVisibleItems] = useState([]);
      const [itemLayouts, setItemLayouts] = useState({});
      const { height: screenHeight } = Dimensions.get('window');
    
      useEffect(() => {
        // 各アイテムのレイアウト情報を初期化
        const layouts = {};
        data.forEach((item, index) => {
          layouts[item.id] = { y: index * itemHeight, height: itemHeight };
        });
        setItemLayouts(layouts);
      }, [data]);
    
      const handleScroll = (event) => {
        const scrollY = event.nativeEvent.contentOffset.y;
        const currentVisibleItems = [];
    
        for (const item of data) {
          const layout = itemLayouts[item.id];
          if (layout && layout.y >= scrollY - itemHeight && layout.y <= scrollY + screenHeight) {
            currentVisibleItems.push(item.title);
          }
        }
        setVisibleItems(currentVisibleItems);
      };
    
      return (
        <View style={{ flex: 1 }}>
          <ScrollView
            ref={scrollViewRef}
            onScroll={handleScroll}
            scrollEventThrottle={16} // パフォーマンス向上のため、イベントの発火頻度を調整
          >
            {data.map(item => (
              <View key={item.id} style={[styles.item, { height: itemHeight }]}>
                <Text>{item.title}</Text>
              </View>
            ))}
          </ScrollView>
          <Text style={styles.visibleItemsText}>
            現在表示中のアイテム: {visibleItems.join(', ')}
          </Text>
        </View>
      );
    };
    
    const styles = StyleSheet.create({
      item: {
        padding: 10,
        borderBottomWidth: 1,
      },
      visibleItemsText: {
        marginTop: 20,
        padding: 10,
      },
    });
    
    export default App;
    
  • 欠点
    大量のアイテムがあるリストでは、パフォーマンスが著しく低下する可能性があります。また、各アイテムのレイアウト情報を手動で管理する必要があります。

  • 利点
    FlatList のような仮想化を行わないため、全てのアイテムが常にレンダリングされており、アイテムの表示状態をより直接的に制御しやすい場合があります。

  • 仕組み
    onScroll イベントは、スクロールが発生するたびに呼び出され、現在のスクロール位置(オフセット)などの情報を提供します。この情報と、各アイテムのレイアウト情報(位置、高さなど)を組み合わせることで、どのアイテムが現在画面内に表示されているかを計算できます。

useEffect と useRef を使用して手動で表示状態を監視する

FlatList 自体は使用しますが、onViewableItemsChanged の代わりに、useEffectuseRef を組み合わせて、特定のアイテムの表示状態を手動で監視する方法です。

  • 実装例 (概念 - Intersection Observer ポリフィルが必要)

    import React, { useRef, useEffect } from 'react';
    import { FlatList, View, Text } from 'react-native';
    // Intersection Observer のポリフィルをインポート (例: 'intersection-observer')
    // import 'intersection-observer';
    
    const data = Array.from({ length: 20 }, (_, index) => ({ id: index.toString(), title: `監視アイテム ${index + 1}` }));
    
    const App = () => {
      const itemRefs = useRef({});
    
      useEffect(() => {
        const observer = new IntersectionObserver(entries => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              console.log(`"${entry.target.textContent}" が表示されました。`);
              // ここで表示されたアイテムに対する処理を行う
            } else {
              console.log(`"${entry.target.textContent}" が非表示になりました。`);
              // ここで非表示になったアイテムに対する処理を行う
            }
          });
        });
    
        for (const id in itemRefs.current) {
          observer.observe(itemRefs.current[id]);
        }
    
        return () => {
          observer.disconnect();
        };
      }, [itemRefs]);
    
      const renderItem = ({ item }) => (
        <View ref={el => (itemRefs.current[item.id] = el)} style={{ padding: 10, borderBottomWidth: 1 }}>
          <Text>{item.title}</Text>
        </View>
      );
    
      return (
        <FlatList
          data={data}
          renderItem={renderItem}
          keyExtractor={item => item.id}
        />
      );
    };
    
    export default App;
    
  • 欠点
    実装が複雑になりやすく、パフォーマンスに注意が必要です。

  • 利点
    より細かい制御が可能になる場合があります。特定の条件下でのみ表示状態を検知したい場合などに有効です。

  • 仕組み
    各アイテムの ref を取得し、スクロールイベントやレイアウトの変化を監視して、そのアイテムが画面内に表示されているかどうかを判断します。Intersection Observer API のようなブラウザのAPIをポリフィルとして使用することも考えられます。

react-native-visibility-sensor などのサードパーティライブラリを使用する

画面上の要素の可視性を追跡するための専用のサードパーティライブラリを利用する方法です。

  • 実装例 (react-native-visibility-sensor を使用)

    import React from 'react';
    import { FlatList, View, Text } from 'react-native';
    import VisibilitySensor from '@sd-digital/react-native-visibility-sensor';
    
    const data = Array.from({ length: 20 }, (_, index) => ({ id: index.toString(), title: `センサーアイテム ${index + 1}` }));
    
    const App = () => {
      const handleVisibilityChange = (isVisible, item) => {
        if (isVisible) {
          console.log(`"${item.title}" が表示されました。`);
          // 表示されたアイテムに対する処理
        } else {
          console.log(`"${item.title}" が非表示になりました。`);
          // 非表示になったアイテムに対する処理
        }
      };
    
      const renderItem = ({ item }) => (
        <VisibilitySensor
          onChange={(isVisible) => handleVisibilityChange(isVisible, item)}
          containmentSelector="window" // または FlatList の ref
          partialVisibility={true} // 部分的に表示されていれば true
        >
          <View style={{ padding: 10, borderBottomWidth: 1 }}>
            <Text>{item.title}</Text>
          </View>
        </VisibilitySensor>
      );
    
      return (
        <FlatList
          data={data}
          renderItem={renderItem}
          keyExtractor={item => item.id}
        />
      );
    };
    
    export default App;
    
  • 欠点
    外部ライブラリへの依存が増えます。

  • 利点
    実装が比較的容易で、可視性の変化を抽象化して扱うことができます。

  • 仕組み
    これらのライブラリは、内部的にスクロールイベントやレイアウト情報を監視し、指定した要素が画面に表示されているかどうかを検知してコールバックを提供します。

どの方法を選ぶべきか?

  • 特定のアイテムの表示状態を個別に監視したい場合は、useEffectuseRef を使用する方法や、react-native-visibility-sensor などのライブラリが役立ちます。ライブラリを使用すると実装が簡単になりますが、依存関係が増える点に注意が必要です。
  • アイテム数が少なく、ScrollView でもパフォーマンス上の問題がない場合や、より直接的な制御が必要な場合は、ScrollViewonScroll を検討できます。ただし、手動でのレイアウト管理や計算が必要になるため、複雑になる可能性があります。
  • 大量のアイテムを扱うリストで、パフォーマンスが重要な場合は、FlatList とその onViewableItemsChanged を使用するのが最も効率的です。FlatList は仮想化によって画面に表示されているアイテムのみをレンダリングするため、メモリ使用量とレンダリング負荷を抑えることができます。