もう迷わない!React Native FlatList#onEndReachedの完全攻略ガイド
具体的には、以下のような状況で役立ちます。
- 「もっと見る」ボタンの代替
ユーザーが手動で「もっと見る」ボタンをクリックする代わりに、リストをスクロールするだけで次のコンテンツが自動的に読み込まれるようにしたい場合。 - 無限スクロール(Infinite Scrolling)
リストの最後までスクロールされたときに、さらに多くのデータをサーバーからロードして表示する、といった機能を実現する際に非常に重要です。
仕組み
onEndReached
プロパティには、リストの末尾にスクロールしたときに呼び出される関数を渡します。この関数が呼び出されるタイミングは、onEndReachedThreshold
プロパティによって調整できます。
onEndReachedThreshold
: これはFlatList
の終わりからどのくらいの距離(ビューポートの割合)でonEndReached
が呼び出されるかを指定する数値です。たとえば、0.5
と設定すると、リストの終わりからリストの高さの半分に達したときにonEndReached
がトリガーされます。デフォルト値は0
で、これはリストの終わりまで完全にスクロールされたときにのみトリガーされることを意味します。
使用例
以下に簡単な使用例を示します。
import React, { useState, useEffect } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';
const DATA = Array.from({ length: 20 }, (_, i) => ({ id: String(i), title: `Item ${i + 1}` }));
const MyFlatList = () => {
const [data, setData] = useState(DATA);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const loadMoreData = () => {
if (loading) return; // 既に読み込み中の場合は何もしない
setLoading(true);
// 実際にはAPIからデータをフェッチする
setTimeout(() => {
const newData = Array.from({ length: 10 }, (_, i) => ({
id: String(data.length + i),
title: `More Item ${data.length + i + 1}`,
}));
setData(prevData => [...prevData, ...newData]);
setLoading(false);
setPage(prevPage => prevPage + 1);
console.log('データを追加しました。現在のアイテム数:', data.length + newData.length);
}, 1500); // 1.5秒の遅延をシミュレート
};
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>読み込み中...</Text>
</View>
);
};
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
)}
onEndReached={loadMoreData} // リストの末尾に到達したときにloadMoreDataを呼び出す
onEndReachedThreshold={0.5} // リストの終わりから0.5の距離でonEndReachedをトリガー
ListFooterComponent={renderFooter} // リストのフッターにローディングインジケータを表示
/>
);
};
const styles = StyleSheet.create({
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
});
export default MyFlatList;
この例では、FlatList
がスクロールされて末尾に近づくと (onEndReachedThreshold={0.5}
)、loadMoreData
関数が呼び出されます。この関数は、新しいデータをシミュレートして FlatList
のデータに追加し、ローディング状態を管理しています。
- onEndReachedThreshold の調整
アプリケーションのユーザーエクスペリエンスに合わせて、適切なonEndReachedThreshold
の値を設定することが重要です。早すぎるとユーザーがまだ読み込みが必要だと感じていないのにデータが読み込まれ始め、遅すぎるとユーザーがデータの読み込みを待つことになります。 - データソースの管理
データを追加する際は、新しいデータと既存のデータを正しく結合するように注意してください。 - 重複呼び出しの防止
onEndReached
は、ユーザーのスクロール速度によっては複数回トリガーされる可能性があります。loading
フラグなどを使用して、一度のスクロールで何度もデータがフェッチされないように制御することが重要です。上記の例ではif (loading) return;
で対応しています。
onEndReached が初回レンダリング時に呼び出される
問題
リストが画面に表示された直後、ユーザーがスクロールしていないにもかかわらず onEndReached
がトリガーされてしまうことがあります。特に、リストのコンテンツが画面の高さよりも短い場合に発生しやすいです。
原因
FlatList
は、リストがレンダリングされた時点で、コンテンツの終わりが onEndReachedThreshold
で設定された距離内にあると判断すると、onEndReached
をトリガーします。リストのアイテム数が少ない場合や、リスト自体に十分な高さがない場合に、この状態が発生します。
解決策
-
ListFooterComponent でのローディング表示
リストのフッターにローディングインジケータを表示する際、ローディング中でない場合はnull
を返すようにすることで、リストの高さが変動し、onEndReached
が不必要にトリガーされるのを防ぐことができます。 -
初期レンダリング時の条件分岐
初回ロード時とそれ以降のロード時で処理を分けることも検討できます。例えば、page
番号を追跡し、page
が1
の場合はonEndReached
をトリガーしない、といった方法です。 -
onEndReachedCalledDuringMomentum フラグの使用
最も一般的な解決策です。スクロールが開始されたときにフラグを立て、onEndReached
が呼び出されたときにこのフラグを確認し、既にデータがロードされている場合は処理をスキップするようにします。const [loading, setLoading] = useState(false); const onEndReachedCalledDuringMomentum = useRef(true); // useRef を使用 const handleLoadMore = () => { if (!onEndReachedCalledDuringMomentum.current && !loading) { setLoading(true); // データのフェッチ処理 setTimeout(() => { // データ追加 setLoading(false); onEndReachedCalledDuringMomentum.current = true; // 読み込み完了後にリセット }, 1500); } }; return ( <FlatList data={data} // ... 他のプロパティ onEndReached={handleLoadMore} onEndReachedThreshold={0.5} onMomentumScrollBegin={() => { // スクロールが開始されたときにフラグをリセット onEndReachedCalledDuringMomentum.current = false; }} /> );
onEndReached が複数回呼び出される(無限ループ)
問題
ユーザーが素早くスクロールしたり、ネットワークの状態によっては、onEndReached
が意図せず何度も呼び出されてしまい、データが重複してフェッチされたり、無限ループに陥ることがあります。
原因
- FlatList の高さの変化
ListFooterComponent
などでローディング表示の有無によってリストの高さが変動すると、FlatList
のレイアウトが再計算され、それがonEndReached
の再トリガーにつながることがあります。 - データのフェッチ中にローディング状態が正しく管理されていない
データフェッチの処理中にloading
フラグなどがtrue
になっておらず、続けてonEndReached
がトリガーされてしまう。 - onEndReachedThreshold の設定が低すぎる
値が0
や非常に小さい場合、わずかなスクロールでもトリガーされることがあります。
解決策
-
ListFooterComponent の表示制御
ローディングインジケータをフッターに表示する場合、ローディング中でないときは完全に非表示にする(null
を返す)のではなく、opacity: 0
などで高さを維持したまま見えなくすることで、レイアウトの変動を防ぎ、不必要なトリガーを減らすことができます。 -
onEndReachedThreshold の調整
適切な値を設定します。0.5
など、リストの半分くらいまでスクロールされたらトリガーされるように設定すると、ユーザー体験とパフォーマンスのバランスが取れることが多いです。 -
loading フラグによる制御
最も重要です。データフェッチを開始する際にloading
フラグをtrue
に設定し、フェッチが完了したらfalse
に戻します。onEndReached
関数内でこのloading
フラグがtrue
の場合は、データのフェッチ処理をスキップします。const [loading, setLoading] = useState(false); const handleLoadMore = () => { if (loading) return; // 既に読み込み中の場合は何もしない setLoading(true); // データのフェッチ処理 // ... setLoading(false); };
onEndReached が全く呼び出されない
問題
リストの最後までスクロールしても、onEndReached
が一度もトリガーされない。
原因
- data プロパティの更新が正しく行われていない
新しいデータがFlatList
のdata
プロパティに正しく追加されていない場合、リストの長さが変わらないため、末尾に到達したと認識されません。 - FlatList に高さが設定されていない
FlatList
が親コンポーネントによって高さを制限されていない場合(例:flex: 1
が設定されていないなど)、リストの実際の高さを認識できず、スクロールイベントが正しく発生しないことがあります。 - FlatList が ScrollView の内部にある
FlatList
は自身のスクロール機能を持っているため、親のScrollView
の中にネストされていると、FlatList
のスクロールイベントが正しく伝わらず、onEndReached
が機能しないことがあります。
解決策
-
data プロパティのイミュータブルな更新
data
配列を更新する際は、新しい配列として設定する必要があります(例:[...prevData, ...newData]
)。既存の配列を直接変更する(push
など)と、React が変更を検知できず、UIが更新されないことがあります。 -
FlatList に適切な高さを設定する
親のView
にflex: 1
を設定するか、FlatList
自体に固定のheight
を設定して、FlatList
がスクロール可能な領域を認識できるようにします。<View style={{ flex: 1 }}> {/* FlatList を View で囲み、flex: 1 を与える */} <FlatList // ... /> </View>
-
FlatList を ScrollView の中にネストしない
これが最も重要な点です。もしそうしている場合は、ScrollView
を削除し、FlatList
を独立して使用するように変更します。
Android と iOS で挙動が異なる
問題
onEndReached
の挙動が Android と iOS で微妙に異なることがある。例えば、Android ではスムーズに動作するが、iOS では初回ロード時にトリガーされるなど。
原因
プラットフォーム間のスクロールイベントの処理やレンダリングの細かな違いが影響している可能性があります。
解決策
- FlashList の検討
FlashList
(Shopify 製) は、より高性能なリストコンポーネントであり、FlatList
の問題を解決するのに役立つ場合があります。ただし、API は似ていますが、内部実装が異なるため、移行には注意が必要です。 - onEndReachedThreshold の微調整
各プラットフォームでテストを行い、最適なonEndReachedThreshold
の値を設定します。 - onEndReachedCalledDuringMomentum フラグの使用
上記の解決策は、プラットフォーム間の挙動の差異を吸収するのに有効です。
- React Native のバージョン
使用している React Native のバージョンが古い場合、バグが修正されている可能性もあるため、最新バージョンへのアップデートを検討します。 - 最小限の再現コード
問題が発生した場合、不要なコードを削ぎ落とし、問題が再現する最小限のコードを作成します。これにより、問題の特定と解決が容易になります。 - デバッグログの活用
onEndReached
関数内でconsole.log()
を使用し、いつ、どのような条件で関数が呼び出されているかを確認します。distanceFromEnd
の値などもログ出力すると役立ちます。
基本的な無限スクロールの実装
これは、最も基本的な onEndReached
の使用例です。リストの最後までスクロールすると、新しいデータが追加されます。
import React, { useState, useEffect } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';
const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;
// 初期データを作成する関数
const generateInitialData = () => {
return Array.from({ length: INITIAL_DATA_COUNT }, (_, i) => ({
id: String(i),
title: `初期アイテム ${i + 1}`,
}));
};
const BasicInfiniteScroll = () => {
const [data, setData] = useState(generateInitialData());
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); // さらに読み込むデータがあるか
// データをロードする関数
const loadMoreData = () => {
if (loading || !hasMore) {
// 既に読み込み中か、もうデータがない場合は何もしない
return;
}
setLoading(true);
// 実際のアプリケーションではAPIを呼び出してデータをフェッチします
// ここではsetTimeoutで非同期処理をシミュレート
setTimeout(() => {
const newStartingIndex = data.length;
const newItems = Array.from({ length: LOAD_MORE_COUNT }, (_, i) => ({
id: String(newStartingIndex + i),
title: `追加アイテム ${newStartingIndex + i + 1}`,
}));
setData(prevData => [...prevData, ...newItems]); // 既存データに新しいデータを追加
setLoading(false);
// 例えば、全データが100件の場合、これ以上読み込む必要がないと判断
if (data.length + newItems.length >= 50) { // 例: 最大50件のデータ
setHasMore(false);
}
console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
}, 1500); // 1.5秒の遅延をシミュレート
};
// 各リストアイテムのレンダリング
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
// リストのフッター部分のレンダリング(ローディングインジケータなど)
const renderFooter = () => {
if (!loading && hasMore) return null; // 読み込み中でなく、データがある場合は何も表示しない
if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>; // データがない場合
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>読み込み中...</Text>
</View>
);
};
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onEndReached={loadMoreData} // リストの末尾に到達したときにloadMoreDataを呼び出す
onEndReachedThreshold={0.5} // リストの終わりから0.5の距離でonEndReachedをトリガー
ListFooterComponent={renderFooter} // リストのフッターコンポーネント
/>
);
};
const styles = StyleSheet.create({
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ddd',
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
noMoreDataText: {
textAlign: 'center',
paddingVertical: 20,
color: '#888',
},
});
export default BasicInfiniteScroll;
解説
- ListFooterComponent={renderFooter}
リストの最下部にローディングインジケータや「全てのデータを表示しました」というメッセージを表示します。 - onEndReachedThreshold={0.5}
リストの末尾から半分の位置に到達したときにonEndReached
がトリガーされます。 - loadMoreData 関数
loading
またはhasMore
がfalse
の場合は、重複した読み込みや不要な読み込みを防ぐために早期リターンします。setLoading(true)
で読み込みを開始し、setTimeout
で非同期API呼び出しをシミュレートします。- 新しいデータを生成し、
setData(prevData => [...prevData, ...newItems])
を使って既存のデータに追加します。[...prevData, ...newItems]
のようにスプレッド構文を使うことで、新しい配列が作成され、React が変更を検知して UI を更新できるようになります。 setLoading(false)
で読み込みを終了します。setHasMore(false)
で、これ以上データがないことを示します。
- useState で状態管理
data
:FlatList
に表示するアイテムの配列。loading
: データ読み込み中かどうかを示すブール値。hasMore
: さらに読み込むデータがあるかどうかを示すブール値。
onEndReached の初回呼び出し問題への対処
onEndReached
がリスト表示直後に意図せず呼び出されるのを防ぐための一般的な方法です。
import React, { useState, useEffect, useRef } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';
const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;
const generateData = (start, count) => {
return Array.from({ length: count }, (_, i) => ({
id: String(start + i),
title: `アイテム ${start + i + 1}`,
}));
};
const DebouncedInfiniteScroll = () => {
const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT));
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const onEndReachedCalledDuringMomentum = useRef(true); // ★重要: useRef を使用
const handleLoadMore = () => {
// スクロールが停止してからonEndReachedが呼ばれることを期待する
// ただし、Androidでは慣性スクロール中にonEndReachedが呼ばれることがあるため、このフラグで制御
if (!onEndReachedCalledDuringMomentum.current && !loading && hasMore) {
setLoading(true);
setTimeout(() => {
const newStartingIndex = data.length;
const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
setData(prevData => [...prevData, ...newItems]);
setLoading(false);
if (data.length + newItems.length >= 50) {
setHasMore(false);
}
console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
}, 1500);
}
};
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
const renderFooter = () => {
if (!loading && hasMore) return null;
if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>読み込み中...</Text>
</View>
);
};
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
// ★重要: スクロールが開始されたときにフラグをリセット
onMomentumScrollBegin={() => {
onEndReachedCalledDuringMomentum.current = false;
}}
/>
);
};
const styles = StyleSheet.create({
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ddd',
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
noMoreDataText: {
textAlign: 'center',
paddingVertical: 20,
color: '#888',
},
});
export default DebouncedInfiniteScroll;
解説
- onMomentumScrollBegin={() => { onEndReachedCalledDuringMomentum.current = false; }}
- ユーザーがスクロールを開始し、慣性スクロールが始まる直前にこの関数が呼び出されます。
- ここで
onEndReachedCalledDuringMomentum.current
をfalse
に設定することで、「もう初回レンダリング時の不必要な呼び出しはスキップしなくて良い」 という状態にします。 - これにより、実際のユーザーのスクロールによって
onEndReached
がトリガーされた場合にのみデータがロードされるようになります。
- onEndReachedCalledDuringMomentum = useRef(true)
useRef
を使用して、コンポーネントの再レンダリング時に値が保持されるミュータブルなオブジェクトを作成します。- 初期値
true
は、「慣性スクロール中にonEndReached
が呼び出されたとみなす」という状態を表します。これにより、初回レンダリング時にはhandleLoadMore
内のif
文でスキップされます。
リストのアイテム数が少なく、画面に収まってしまう場合でも onEndReached
がトリガーされてしまうことがあります。これをより堅牢に制御したい場合の例です。
import React, { useState, useEffect, useRef } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';
const INITIAL_DATA_COUNT_SHORT = 5; // 初期のアイテム数を少なく設定
const LOAD_MORE_COUNT = 10;
const generateData = (start, count) => {
return Array.from({ length: count }, (_, i) => ({
id: String(start + i),
title: `アイテム ${start + i + 1}`,
}));
};
const { height: screenHeight } = Dimensions.get('window');
const ShortListControl = () => {
const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT_SHORT));
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const onEndReachedCalledDuringMomentum = useRef(true);
const flatListRef = useRef(null); // FlatListへの参照
const handleLoadMore = () => {
if (!onEndReachedCalledDuringMomentum.current && !loading && hasMore) {
setLoading(true);
setTimeout(() => {
const newStartingIndex = data.length;
const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
setData(prevData => [...prevData, ...newItems]);
setLoading(false);
if (data.length + newItems.length >= 50) {
setHasMore(false);
}
console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
}, 1500);
}
};
useEffect(() => {
// 初回レンダリング後に、リストの高さとコンテンツの高さを比較する
// コンテンツが画面に収まるほど短い場合にのみ、onEndReachedをトリガー
// これにより、初回ロード時でもアイテム数が少ない場合に限り自動で追加ロードされる
if (flatListRef.current && data.length > 0 && !loading) {
const timer = setTimeout(() => {
flatListRef.current.getScrollResponder().measureLayout(
flatListRef.current.getScrollResponder().scrollResponderHandle,
(x, y, width, height) => {
// FlatListの高さよりコンテンツの高さが低い場合
// (これはおおよそのチェックであり、正確なコンテンツ高さを取得するにはLayoutAnimationやonContentSizeChangeをより深く使う必要がある)
if (height >= screenHeight) { // ここでは、FlatListが画面の高さ以上あると仮定
// onEndReachedCalledDuringMomentumをtrueに戻して、初回トリガーを許可する
// もし、リストが十分に長い場合は、手動でonEndReachedを呼び出さない
onEndReachedCalledDuringMomentum.current = false;
} else {
// リストが短い場合は、onEndReachedをトリガーしてデータを追加
if (hasMore && !loading) {
console.log('リストが短いので自動的に追加データをロードします。');
handleLoadMore(); // 直接呼び出す
}
}
}
);
}, 100); // レイアウトが計算されるのを少し待つ
}
}, [data, loading, hasMore]); // データが更新されたり、ロード状態が変わったら再チェック
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
const renderFooter = () => {
if (!loading && hasMore) return null;
if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>読み込み中...</Text>
</View>
);
};
return (
<FlatList
ref={flatListRef} // FlatListへの参照を設定
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
onMomentumScrollBegin={() => {
onEndReachedCalledDuringMomentum.current = false;
}}
// onContentSizeChange={(contentWidth, contentHeight) => {
// // コンテンツの高さが変わったときに、もし画面内に収まってしまうなら
// // 再度onEndReachedをトリガーするなど、より高度な制御が可能
// // 例えば、contentHeight < screenHeight の場合に handleLoadMore を呼ぶ
// // ただし、無限ループにならないように注意が必要
// }}
/>
);
};
const styles = StyleSheet.create({
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ddd',
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
noMoreDataText: {
textAlign: 'center',
paddingVertical: 20,
color: '#888',
},
});
export default ShortListControl;
解説
- コメントアウトされた onContentSizeChange
FlatList
のコンテンツのサイズが変更されたときに呼び出されるプロパティです。これを活用して、リストのコンテンツが画面の高さより短い場合に、さらにデータを読み込む処理をトリガーすることも可能です。しかし、これも無限ループに陥らないようにloading
やhasMore
フラグで厳密に制御する必要があります。 - useEffect 内での高さチェック
useEffect
を使用して、data
が更新されるたびにリストの高さが画面に収まっているかを確認します。flatListRef.current.getScrollResponder().measureLayout(...)
を使うことで、FlatList
のスクロール可能な領域の高さ(height
)を取得できます。- もしリストの高さが画面の高さより短い場合、自動的に
handleLoadMore()
を呼び出してデータを追加します。これにより、ユーザーがスクロールしなくても画面がデータで埋められるようになります。 - 注意点
measureLayout
はレイアウトが完了した後でなければ正確な値を取得できません。setTimeout
で少し待つなどの工夫が必要です。また、コンテンツの正確な高さを取得するにはonContentSizeChange
と組み合わせて使うとより確実です。ただし、この方法は複雑になりがちで、無限ループのリスクも伴うため、慎重な実装とテストが必要です。
- flatListRef = useRef(null)
FlatList
コンポーネントに直接アクセスするためのref
を作成します。
これらの例は、FlatList#onEndReached
を効果的に使用するための基本的なパターンと、よく遭遇する問題に対する堅牢な解決策を示しています。
- リストのアイテム数が少ない場合の自動ロードは、
useEffect
やonContentSizeChange
を使った少し高度な制御が必要になります。 onEndReachedCalledDuringMomentum
フラグ (useRef
とonMomentumScrollBegin
を組み合わせる) は、初回レンダリング時の不必要なトリガーを防ぐために非常に有効です。loading
フラグでの重複呼び出し防止は必須です。
ScrollView と onScroll (手動実装)
FlatList
の代わりにネイティブの ScrollView
コンポーネントを使用し、onScroll
イベントを監視してスクロール位置を手動で計算することで、無限スクロールを実装できます。
メリット
- シンプルなリストや、仮想化が不要な場合に、コンポーネント数を減らせる。
FlatList
よりも低レベルな制御が可能。
デメリット
- 実装の複雑さ: スクロール位置の計算 (
event.nativeEvent.contentOffset.y
,event.nativeEvent.contentSize.height
,event.nativeEvent.layoutMeasurement.height
) や、データの重複ロード防止、初回ロード時の問題回避など、すべて手動で実装する必要がある。 - パフォーマンス:
FlatList
のような仮想化機能がないため、アイテム数が増えるとメモリ使用量が増え、パフォーマンスが低下する可能性がある。
コード例
import React, { useState } from 'react';
import { ScrollView, Text, View, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';
const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;
const { height: screenHeight } = Dimensions.get('window');
const generateData = (start, count) => {
return Array.from({ length: count }, (_, i) => ({
id: String(start + i),
title: `アイテム ${start + i + 1}`,
}));
};
const ScrollViewInfiniteScroll = () => {
const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT));
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const handleScroll = (event) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20; // 底部からどれくらいの距離でトリガーするか
// スクロールの終わりに近いかどうかをチェック
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
if (isCloseToBottom && !loading && hasMore) {
loadMoreData();
}
};
const loadMoreData = () => {
setLoading(true);
setTimeout(() => {
const newStartingIndex = data.length;
const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
setData(prevData => [...prevData, ...newItems]);
setLoading(false);
if (data.length + newItems.length >= 50) {
setHasMore(false);
}
console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
}, 1500);
};
const renderFooter = () => {
if (!loading && hasMore) return null;
if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>読み込み中...</Text>
</View>
);
};
return (
<ScrollView
style={styles.container}
onScroll={handleScroll}
scrollEventThrottle={16} // パフォーマンスのためにイベントの発火頻度を調整
>
{data.map((item) => (
<View key={item.id} style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
))}
{renderFooter()}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ddd',
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
noMoreDataText: {
textAlign: 'center',
paddingVertical: 20,
color: '#888',
},
});
export default ScrollViewInfiniteScroll;
VirtualizedList (より低レベルな仮想化リスト)
FlatList
は内部で VirtualizedList
を利用しています。VirtualizedList
を直接使用することで、FlatList
が提供する一部の抽象化を取り除き、より細かな制御が可能になります。これは通常、FlatList
が特定のユースケースで十分でない場合に検討されます。
メリット
- データの取得方法やビューのリサイクルロジックをより細かくカスタマイズできる。
FlatList
と同等の仮想化機能を持つ。
デメリット
FlatList
よりも実装が複雑。特にgetItem
やgetItemCount
といったメソッドの実装が必要。
これは非常に特殊なケースでしか使用されません。ほとんどの場合、FlatList
で十分です。
専用の無限スクロールライブラリやコンポーネント
コミュニティには、無限スクロールのロジックをより簡単に扱えるように設計されたライブラリが存在します。これらのライブラリは、onEndReached
の一般的な落とし穴(初回ロード、重複呼び出しなど)を内部で処理してくれる場合があります。
例: react-native-infinite-scrollview
(現在はあまりメンテナンスされていないかもしれません)
これらのライブラリは、特定のユースケースに特化しているため、プロジェクトの要件に合致すれば開発コストを削減できる可能性があります。ただし、メンテナンス状況やコミュニティの活発さを確認することが重要です。
react-query や swr などのデータフェッチライブラリとの連携
React Native の無限スクロールの課題は、単に onEndReached
をトリガーするだけでなく、その後のデータのフェッチ、キャッシュ、更新、エラーハンドリングなど、データ管理全体に関わります。react-query
や swr
のようなデータフェッチライブラリは、これらのデータ管理の側面を大幅に簡素化できます。
これらのライブラリは、無限スクロールのパターン(ページネーション、カーソルベースの取得など)を直接サポートしていることが多く、onEndReached
と組み合わせることで、より堅牢で効率的な実装が可能です。
react-query を使用した例(概念)
import React from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';
import { useInfiniteQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'; // v4以降のreact-query
const queryClient = new QueryClient();
// API呼び出しをシミュレート
const fetchItems = async ({ pageParam = 0 }) => {
console.log(`Fetching page: ${pageParam}`);
const items = Array.from({ length: 10 }, (_, i) => ({
id: `item-${pageParam * 10 + i}`,
title: `アイテム ${pageParam * 10 + i + 1}`,
}));
// 最後に到達したかどうかを判断するロジック
const hasMore = pageParam < 4; // 例: 5ページまでデータがあるとする
await new Promise(resolve => setTimeout(resolve, 1000)); // API遅延をシミュレート
return { items, nextPage: hasMore ? pageParam + 1 : undefined };
};
const InfiniteScrollWithReactQuery = () => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: fetchItems,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0,
});
const allItems = data?.pages.flatMap(page => page.items) || [];
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
<Text>初期データをロード中...</Text>
</View>
);
}
if (isError) {
return (
<View style={styles.centered}>
<Text>エラーが発生しました: {error.message}</Text>
</View>
);
}
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
const renderFooter = () => {
if (isFetchingNextPage) {
return (
<View style={styles.footer}>
<ActivityIndicator size="large" />
<Text>さらに読み込み中...</Text>
</View>
);
}
if (!hasNextPage) {
return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
}
return null;
};
return (
<FlatList
data={allItems}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage(); // next pageがある場合のみフェッチ
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
/>
);
};
const App = () => (
<QueryClientProvider client={queryClient}>
<InfiniteScrollWithReactQuery />
</QueryClientProvider>
);
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
item: {
backgroundColor: '#f9f9f9',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ddd',
},
title: {
fontSize: 18,
},
footer: {
paddingVertical: 20,
borderTopWidth: 1,
borderColor: '#ced0ce',
alignItems: 'center',
justifyContent: 'center',
},
noMoreDataText: {
textAlign: 'center',
paddingVertical: 20,
color: '#888',
},
});
export default App;
- デバッグツールの充実
React Query Devtools
を使うことで、キャッシュの状態やクエリの状況を視覚的に確認できます。 - エラーハンドリング
エラー状態を簡単に管理できます。 - 自動再フェッチ
ネットワーク接続が戻った際や、ウィンドウフォーカス時に自動でデータを更新します。 - 強力なキャッシュ機能
データを効率的にキャッシュし、オフラインアクセスや高速なUIを実現します。 - 無限スクロールのロジックを簡素化
useInfiniteQuery
フックが次ページの取得、キャッシュ、ローディング状態などを自動で管理してくれます。
- FlatList のパフォーマンス限界に直面している、または非常に特殊なリストの要件がある場合
VirtualizedList
や、FlashList
(Shopify 製) のような高性能リストコンポーネントの検討が必要になるかもしれません。 - 複雑なデータフェッチ、キャッシュ、UI同期が必要な場合
react-query
やswr
のようなデータフェッチライブラリとFlatList
を組み合わせるのが最適です。これにより、コードの保守性が向上し、多くのエッジケースに対処できます。 - 小規模でシンプル、かつ仮想化が不要なリスト
ScrollView
とonScroll
を手動で実装することも可能です。 - ほとんどのケース
FlatList
のonEndReached
を使用し、上述の「一般的なエラーとトラブルシューティング」で説明した対策(loading
フラグ、onMomentumScrollBegin
とuseRef
)を講じるのが最もバランスが取れています。