React Native開発者必見!FlatListのrefreshing機能をマスターしてUXを向上させる方法

2025-05-31

FlatList#refreshing とは?

FlatListは、大量のデータを効率的に表示するためのReact Nativeのコンポーネントです。スクロール可能なリストビューを提供し、「プル・トゥ・リフレッシュ(Pull to Refresh)」という、リストを下に引っ張ってコンテンツを更新する機能もサポートしています。

この「プル・トゥ・リフレッシュ」機能を実現するために使用されるのが、refreshingプロパティです。

refreshingプロパティは、**リストが現在データを更新中であるかどうかを示すブール値(真偽値)**です。

  • refreshing={false}: リストが更新を完了していることを示します。ローディングインジケーターは非表示になります。
  • refreshing={true}: リストが現在データを取得中であることを示します。この状態のとき、リストの先頭にローディングインジケーター(スピナーなど)が表示され、ユーザーに更新処理が行われていることを視覚的に伝えます。

refreshingプロパティは、通常、コンポーネントの**状態(state)**と連携して使用されます。

  1. 状態の定義: コンポーネントのstateに、refreshingという名前のブール値(例: isRefreshing)を定義します。初期値はfalseです。

    import React, { useState, useEffect } from 'react';
    import { FlatList, Text, View, RefreshControl } from 'react-native';
    
    function MyList() {
      const [data, setData] = useState([]);
      const [isRefreshing, setIsRefreshing] = useState(false); // ★ refreshingの状態を管理
    
      // ...
    }
    
  2. FlatListへの設定: FlatListコンポーネントにrefreshingプロパティとしてこのstateの値を渡し、onRefreshプロパティには更新処理をトリガーする関数を渡します。

    <FlatList
      data={data}
      renderItem={({ item }) => <Text>{item.name}</Text>}
      keyExtractor={item => item.id.toString()}
      refreshing={isRefreshing} // ★ ここで状態を渡す
      onRefresh={handleRefresh} // ★ 更新時に呼ばれる関数
    />
    
  3. onRefresh関数の実装: onRefreshで指定した関数(例: handleRefresh)では、以下の処理を行います。

    • まず、isRefreshingのstateをtrueに設定し、ローディングインジケーターを表示させます。
    • 次に、新しいデータを取得するための非同期処理(API呼び出しなど)を実行します。
    • データの取得が完了したら、isRefreshingのstateをfalseに戻し、ローディングインジケーターを非表示にします。
    const handleRefresh = async () => {
      setIsRefreshing(true); // 更新開始をUIに伝える
    
      // ここで新しいデータを取得する処理を記述
      // 例: await fetchNewData();
      // 例: fetch('https://api.example.com/data')
      //   .then(response => response.json())
      //   .then(newData => {
      //     setData(newData);
      //     setIsRefreshing(false); // 更新完了をUIに伝える
      //   })
      //   .catch(error => {
      //     console.error(error);
      //     setIsRefreshing(false); // エラー時も更新完了をUIに伝える
      //   });
    
      // ダミーデータの例
      setTimeout(() => {
        const newData = Array.from({ length: 5 }, (_, i) => ({
          id: data.length + i,
          name: `新しいアイテム ${data.length + i}`,
        }));
        setData([...newData, ...data]); // 新しいデータを追加
        setIsRefreshing(false); // 更新完了
      }, 1500); // 1.5秒後に更新完了と仮定
    };
    

FlatListrefreshingプロパティは、「プル・トゥ・リフレッシュ」機能において、データの更新状況をUIに反映させるために非常に重要な役割を果たします。このプロパティを適切に管理することで、ユーザーに現在のリストの状態を明確に伝え、より良いユーザーエクスペリエンスを提供することができます。



FlatList#refreshing の一般的なエラーとトラブルシューティング

FlatListのプル・トゥ・リフレッシュ機能は非常に便利ですが、設定や状態管理を誤ると期待通りに動作しないことがあります。ここでは、よくある問題とその解決策を説明します。

リフレッシュインジケーターがすぐに消えてしまう、または表示されない

原因
refreshingプロパティの状態管理が正しく行われていないことが最も一般的な原因です。特に、onRefreshが呼び出されたときにrefreshingtrueに設定し、データ取得が完了した後にfalseに戻すというサイクルが守られていないと発生します。

解決策

  • 初期レンダリング時のrefreshingの値
    コンポーネントの初期レンダリング時にrefreshingtrueになっている場合、FlatListが自動的にリフレッシュインジケーターを表示しようとしますが、データ取得処理がなければすぐに消えてしまいます。初期値はfalseに設定するのが一般的です。

  • 非同期処理の完了後のfalse設定
    データ取得などの非同期処理が完了した後に、必ずrefreshingfalseに戻しているか確認してください。これを忘れると、インジケーターが表示されっぱなしになります。

  • refreshingをtrueに設定するタイミングの確認
    onRefresh関数が呼ばれた直後に、refreshingを管理しているstateをtrueに設定しているか確認してください。これにより、ユーザーが引っ張った際にインジケーターが表示され始めます。

    const [isRefreshing, setIsRefreshing] = useState(false);
    
    const handleRefresh = async () => {
      setIsRefreshing(true); // ★ ここでtrueに設定する
      // データ取得処理
      await fetchData();
      setIsRefreshing(false); // データ取得完了後にfalseに戻す
    };
    

リフレッシュインジケーターが表示されっぱなしになる(消えない)

原因
これは、前述の「非同期処理の完了後にrefreshingfalseに戻し忘れている」場合に発生します。何らかのエラーが発生してデータ取得が失敗した場合も、falseに戻す処理がスキップされ、インジケーターが残り続けることがあります。

解決策

  • finallyブロックでのfalse設定
    データ取得処理が成功しても失敗しても、最終的にrefreshingfalseにするように、try...catch...finally構文のfinallyブロックを使用することをお勧めします。

    const handleRefresh = async () => {
      setIsRefreshing(true);
      try {
        await fetchData(); // データ取得処理
      } catch (error) {
        console.error("データ取得エラー:", error);
        // エラーハンドリング
      } finally {
        setIsRefreshing(false); // ★ 成功・失敗にかかわらずfalseに戻す
      }
    };
    

onRefreshが呼ばれない

原因

  • FlatListの誤ったインポート
    まれに、react-nativeからではなく、react-native-gesture-handlerからFlatListをインポートしてしまっている場合があります。react-native-gesture-handlerFlatListは、標準のFlatListと同じonRefreshrefreshingプロパティをサポートしていない可能性があります。

  • リストの高さが足りない、またはflex: 1が設定されていない
    FlatListの親コンポーネント、またはFlatList自体に十分な高さがない場合、プル・トゥ・リフレッシュのジェスチャーが正しく認識されないことがあります。特にFlatListが画面全体を占めるべきなのに、高さが指定されていない場合に問題となります。

  • refreshingプロパティが渡されていない、またはboolean型でない
    FlatListrefreshingプロパティがboolean型であることを期待しています。undefinedなどが渡されていると、onRefreshが呼び出されないことがあります。

    // エラー例: refreshing={undefined} や refreshing={null}
    <FlatList
      refreshing={myVariable} // myVariableがundefinedやnullだと問題
      onRefresh={handleRefresh}
    />
    

解決策

  • 正しいFlatListのインポートを確認
    import { FlatList } from 'react-native'; となっていることを確認してください。

  • FlatListまたは親コンポーネントにflex: 1を設定
    FlatListが適切にスクロール可能であり、プル・トゥ・リフレッシュのジェスチャーを検出できるように、親のViewFlatList自体にflex: 1を設定して、利用可能なスペースを全て占有するようにします。

    <View style={{ flex: 1 }}>
      <FlatList
        data={data}
        // ...
        refreshing={isRefreshing}
        onRefresh={handleRefresh}
      />
    </View>
    
  • refreshingプロパティを必ず渡す
    refreshingプロパティには必ずtrueまたはfalseのブール値を渡してください。

    <FlatList
      refreshing={isRefreshing} // boolean型のisRefreshingを渡す
      onRefresh={handleRefresh}
    />
    

iOSとAndroidで挙動が異なる(特にインジケーターの表示)

原因
プラットフォーム固有のレンダリングの違いにより、インジケーターの表示タイミングやアニメーションに若干の差が出ることがあります。特に、プログラム的にrefreshingtrueにした場合に、iOSではインジケーターが表示されるがAndroidでは表示されない、といったケースが報告されています。

解決策

  • 強制的なスクロール (iOSでのみ有効な場合あり)
    プログラム的にリフレッシュを開始した場合(例: ボタンタップでリフレッシュ)、iOSでインジケーターが表示されないことがあります。この場合、FlatListscrollToOffsetメソッドを使ってわずかにスクロールさせることでインジケーターを「引き出す」ワークアラウンドが報告されています。これは、プル・トゥ・リフレッシュがユーザーのドラッグ操作によってトリガーされることを前提としているためです。

    // FlatListにrefを設定
    const flatListRef = useRef(null);
    
    const handleProgrammaticRefresh = () => {
      setIsRefreshing(true);
      if (Platform.OS === 'ios' && flatListRef.current) {
        flatListRef.current.scrollToOffset({ offset: -60, animated: true }); // インジケーターを引き出すためのオフセット
      }
      fetchData().finally(() => setIsRefreshing(false));
    };
    
    <FlatList
      ref={flatListRef}
      // ...
      refreshing={isRefreshing}
      onRefresh={handleRefresh} // ユーザーがプルしたときに呼ばれる
    />
    

    ただし、このワークアラウンドは特定のバージョンや状況でしか機能しない可能性があり、推奨される解決策ではありません。まずは上記の状態管理を正しく行うことが重要です。

  • RefreshControlの直接使用
    FlatListrefreshControlプロパティにRefreshControlコンポーネントを直接渡すことで、より細かな制御が可能になる場合があります。

    import { FlatList, RefreshControl } from 'react-native';
    
    // ...
    <FlatList
      data={data}
      // ...
      refreshControl={
        <RefreshControl
          refreshing={isRefreshing}
          onRefresh={handleRefresh}
          // Android specific: color, progressBackgroundColor, size
          // iOS specific: tintColor, title, titleColor
        />
      }
    />
    

FlatListのデータが更新されないのにインジケーターが消える

原因
これはrefreshingのstateがfalseに戻されているのに、FlatListに渡しているdataプロパティが更新されていない場合に発生します。リフレッシュインジケーターは消えますが、リストの内容は古いままで、ユーザーは混乱します。

解決策

  • データ取得後のstate更新
    onRefresh内でデータを取得した後、その新しいデータをFlatListに渡しているdataのstateに必ずセットしてください。

    const handleRefresh = async () => {
      setIsRefreshing(true);
      try {
        const newData = await fetchNewData(); // 新しいデータを取得
        setData(newData); // ★ 取得した新しいデータをstateにセット
      } catch (error) {
        console.error("データ取得エラー:", error);
      } finally {
        setIsRefreshing(false);
      }
    };
    
  • React DevToolsの使用
    React DevToolsを使って、コンポーネントのstate(特にisRefreshingdata)がどのように変化しているかをリアルタイムで確認すると、問題の特定に役立ちます。
  • console.logの使用
    onRefreshが呼ばれているか、isRefreshingのstateが期待通りに変化しているかをconsole.logで出力して確認します。


基本的なプル・トゥ・リフレッシュの実装

最も基本的な例では、refreshingという状態(state)を管理し、onRefreshプロパティでその状態を切り替える関数を指定します。

import React, { useState, useEffect, useCallback } from 'react';
import {
  FlatList,
  Text,
  View,
  StyleSheet,
  SafeAreaView,
  ActivityIndicator, // ローディングインジケーターとして使用
} from 'react-native';

// ダミーデータ
const initialData = Array.from({ length: 10 }, (_, i) => ({
  id: String(i + 1),
  title: `初期アイテム ${i + 1}`,
}));

function MyRefreshingFlatList() {
  const [data, setData] = useState(initialData);
  const [isRefreshing, setIsRefreshing] = useState(false); // ★ refreshingの状態を管理

  // データ取得のシミュレーション関数
  const fetchData = async () => {
    return new Promise(resolve => {
      setTimeout(() => {
        const newData = Array.from({ length: 5 }, (_, i) => ({
          id: String(data.length + i + 1),
          title: `新しいアイテム ${data.length + i + 1}`,
        }));
        resolve(newData);
      }, 1500); // 1.5秒間の遅延をシミュレート
    });
  };

  // プル・トゥ・リフレッシュ時に呼ばれる関数
  const handleRefresh = useCallback(async () => {
    setIsRefreshing(true); // リフレッシュ開始を示す(インジケーターが表示される)

    const newItems = await fetchData(); // 新しいデータを取得
    setData(prevData => [...newItems, ...prevData]); // 新しいデータをリストの先頭に追加

    setIsRefreshing(false); // リフレッシュ完了を示す(インジケーターが非表示になる)
  }, [data]); // dataが変更されたらhandleRefreshも再生成されるようにする

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

  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={data}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        refreshing={isRefreshing} // ★ refreshingプロパティに状態を渡す
        onRefresh={handleRefresh} // ★ プル・トゥ・リフレッシュ時に呼ばれる関数
        ListHeaderComponent={isRefreshing ? <ActivityIndicator style={{ paddingVertical: 10 }} size="large" color="#0000ff" /> : null}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: 20,
  },
  item: {
    backgroundColor: '#f9c2ff',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
  },
  title: {
    fontSize: 20,
  },
});

export default MyRefreshingFlatList;

解説

  • ListHeaderComponent: この例では、refreshingtrueの時にActivityIndicatorを表示するようにしています。FlatListrefreshingonRefreshを使うと、デフォルトでプラットフォームごとのリフレッシュインジケーターが表示されますが、このようにカスタムのローディング表示を追加することもできます。
  • handleRefresh関数
    • 最初にsetIsRefreshing(true)を呼び出し、インジケーターを表示させます。
    • fetchData()(ここではダミーの非同期処理)を実行し、新しいデータを取得します。
    • setData()で取得した新しいデータを既存のリストの先頭に追加します。
    • 最後にsetIsRefreshing(false)を呼び出し、インジケーターを非表示にします。
  • onRefresh={handleRefresh}: ユーザーがリストを下に引っ張ってリフレッシュをトリガーしたときにhandleRefresh関数が呼び出されます。
  • refreshing={isRefreshing}: FlatListisRefreshingの現在の値を渡します。この値がtrueになると、プル・トゥ・リフレッシュのローディングインジケーターが表示されます。
  • useState(false): isRefreshingというstateを定義し、初期値をfalseにしています。これは、リストが最初はリフレッシュ状態ではないことを示します。

FlatListは内部でRefreshControlコンポーネントを使用していますが、refreshControlプロパティにRefreshControlを明示的に渡すことで、より詳細なカスタマイズ(Androidでの色の変更など)が可能です。

import React, { useState, useEffect, useCallback } from 'react';
import {
  FlatList,
  Text,
  View,
  StyleSheet,
  SafeAreaView,
  RefreshControl, // RefreshControlをインポート
} from 'react-native';

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

function MyCustomRefreshingFlatList() {
  const [data, setData] = useState(initialData);
  const [isRefreshing, setIsRefreshing] = useState(false);

  const fetchData = async () => {
    return new Promise(resolve => {
      setTimeout(() => {
        const newData = Array.from({ length: 5 }, (_, i) => ({
          id: String(data.length + i + 1),
          title: `追加アイテム ${data.length + i + 1}`,
        }));
        resolve(newData);
      }, 2000); // 2秒間の遅延をシミュレート
    });
  };

  const handleRefresh = useCallback(async () => {
    setIsRefreshing(true);
    try {
      const newItems = await fetchData();
      setData(prevData => [...newItems, ...prevData]);
    } catch (error) {
      console.error("データ取得エラー:", error);
      // エラー時の処理
    } finally {
      setIsRefreshing(false); // エラーが発生しても必ずfalseに戻す
    }
  }, [data]);

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

  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={data}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        refreshControl={ // ★ refreshControlプロパティを使用
          <RefreshControl
            refreshing={isRefreshing} // refreshingの状態を渡す
            onRefresh={handleRefresh} // リフレッシュ時の処理
            tintColor="#ff0000" // iOSのインジケーターの色 (赤)
            title="データを更新中..." // iOSの表示テキスト
            titleColor="#0000ff" // iOSのテキストの色 (青)
            colors={['#9bc53d', '#f2b5d4']} // Androidのインジケーターの色 (複数の色を循環)
            progressBackgroundColor="#ffffff" // Androidの背景色
          />
        }
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: 20,
  },
  item: {
    backgroundColor: '#f9c2ff',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
  },
  title: {
    fontSize: 20,
  },
});

export default MyCustomRefreshingFlatList;
  • RefreshControlのプロパティ
    • tintColor (iOS): ローディングインジケーターの色を設定します。
    • title (iOS): ローディングインジケーターの下に表示されるテキストを設定します。
    • titleColor (iOS): titleのテキストの色を設定します。
    • colors (Android): Androidでインジケーターが回転する際に使用する色の配列を設定します。
    • progressBackgroundColor (Android): Androidでインジケーターの背景色を設定します。
  • refreshControl={<RefreshControl ... />}: FlatListrefreshControlプロパティにRefreshControlコンポーネントを直接渡しています。


ScrollView と RefreshControl を組み合わせる

FlatListは内部的にScrollViewを使用しているため、FlatListを使わずに直接ScrollViewRefreshControlを組み合わせてプル・トゥ・リフレッシュを実装することも可能です。これは、リストではないがスクロール可能なコンテンツに対してプル・トゥ・リフレッシュ機能を追加したい場合に特に役立ちます。

利点

  • RefreshControlのプロパティを直接設定できるため、より柔軟なカスタマイズが可能。
  • FlatListを使用しない場合でも、プル・トゥ・リフレッシュ機能を利用できる。

欠点

  • 大量のデータを表示する場合、FlatListのような仮想化によるパフォーマンス最適化の恩恵を受けられない。すべてのアイテムが一度にレンダリングされるため、メモリ使用量が増え、パフォーマンスが低下する可能性がある。

コード例

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

function MyRefreshingScrollView() {
  const [content, setContent] = useState('初期コンテンツ');
  const [isRefreshing, setIsRefreshing] = useState(false);

  const fetchData = async () => {
    return new Promise(resolve => {
      setTimeout(() => {
        const newContent = `更新されたコンテンツ: ${new Date().toLocaleTimeString()}`;
        resolve(newContent);
      }, 1500);
    });
  };

  const handleRefresh = useCallback(async () => {
    setIsRefreshing(true);
    const updatedContent = await fetchData();
    setContent(updatedContent);
    setIsRefreshing(false);
  }, []);

  return (
    <SafeAreaView style={styles.container}>
      <ScrollView
        contentContainerStyle={styles.scrollViewContent}
        refreshControl={
          <RefreshControl
            refreshing={isRefreshing}
            onRefresh={handleRefresh}
            tintColor="#007aff" // iOS
            colors={['#007aff']} // Android
            title="更新中..." // iOS
          />
        }
      >
        <Text style={styles.text}>{content}</Text>
        <View style={styles.spacer} /> {/* スクロール可能にするためのスペース */}
        <Text style={styles.text}>下に引っ張って更新</Text>
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  scrollViewContent: {
    flexGrow: 1, // コンテンツが少ない場合でもScrollViewが画面全体を占めるようにする
    justifyContent: 'center',
    alignItems: 'center',
    paddingVertical: 50,
  },
  text: {
    fontSize: 24,
    textAlign: 'center',
    marginBottom: 20,
  },
  spacer: {
    height: 300, // スクロール可能にするために必要な高さ
  },
});

export default MyRefreshingScrollView;

カスタムのプル・トゥ・リフレッシュコンポーネントを作成する

PanResponderreact-native-reanimatedreact-native-gesture-handlerといった低レベルのAPIやライブラリを使用して、完全にカスタムのプル・トゥ・リフレッシュUIとロジックを実装することも可能です。これは、標準のRefreshControlでは実現できないような独自のアニメーションやインタラクションが必要な場合に検討されます。

利点

  • 標準のRefreshControlの限界を超えることができる。
  • デザインとアニメーションに関して究極の柔軟性がある。

欠点

  • OSごとの挙動の違いを吸収するための考慮が必要。
  • パフォーマンス最適化やジェスチャーハンドリングを自身で管理する必要がある。
  • 実装が非常に複雑で、時間と労力がかかる。

実装の概念

  1. PanResponderまたはreact-native-gesture-handlerでジェスチャーを検出
    ユーザーがリストの先頭で下に引っ張る動きを検出します。
  2. スクロール位置の監視
    FlatListScrollViewonScrollイベントを使用して、現在のスクロール位置を監視します。リストの最上部にいるときに特定の閾値を超えて引っ張られた場合のみ、リフレッシュをトリガーします。
  3. アニメーションの制御
    ユーザーのドラッグ量に応じてカスタムのローディングUI(Lottieアニメーションなど)を動かし、リフレッシュがトリガーされたらローディング状態のアニメーションに切り替えます。Animated APIやreact-native-reanimatedを使用します。
  4. データの取得とUIの更新
    refreshingプロパティの場合と同様に、データの取得が完了したらローディングアニメーションを終了し、UIを元の状態に戻します。

(完全なコード例は非常に複雑になるため省略しますが、概念を示します)

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

const REFRESH_THRESHOLD = 100; // どれくらい引っ張るとリフレッシュをトリガーするか
const RELEASE_OFFSET = 60; // リフレッシュ中UIの表示高さ

function MyCustomPullToRefreshFlatList() {
  const [data, setData] = useState(Array.from({ length: 20 }, (_, i) => ({ id: String(i), title: `アイテム ${i}` })));
  const [isRefreshing, setIsRefreshing] = useState(false);
  const scrollY = useRef(new Animated.Value(0)).current;
  const refreshAnim = useRef(new Animated.Value(0)).current; // リフレッシュUIのアニメーション用

  // スクロールイベントを監視
  const onScroll = Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    { useNativeDriver: false } // useNativeDriver: true を推奨しますが、簡略化のためfalse
  );

  // PanResponderの設定
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onStartShouldSetPanResponderCapture: () => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => {
        // 最上部にいて、下方向にドラッグしている場合にのみPanResponderを有効にする
        return scrollY._value <= 0 && gestureState.dy > 0;
      },
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
        return scrollY._value <= 0 && gestureState.dy > 0;
      },
      onPanResponderMove: (evt, gestureState) => {
        if (scrollY._value <= 0) {
          // 下に引っ張る量に応じてリフレッシュUIを動かす
          refreshAnim.setValue(Math.min(gestureState.dy, REFRESH_THRESHOLD + 20));
        }
      },
      onPanResponderRelease: async (evt, gestureState) => {
        if (gestureState.dy > REFRESH_THRESHOLD && scrollY._value <= 0) {
          setIsRefreshing(true);
          // リフレッシュ中のUIを表示
          Animated.timing(refreshAnim, {
            toValue: RELEASE_OFFSET,
            duration: 200,
            useNativeDriver: false,
          }).start();

          // データ取得処理
          await new Promise(resolve => setTimeout(() => {
            const newItems = Array.from({ length: 3 }, (_, i) => ({
              id: `new-${Date.now()}-${i}`,
              title: `新着アイテム ${data.length + i}`,
            }));
            setData(prevData => [...newItems, ...prevData]);
            resolve();
          }, 2000));

          setIsRefreshing(false);
          // リフレッシュUIを隠す
          Animated.timing(refreshAnim, {
            toValue: 0,
            duration: 200,
            useNativeDriver: false,
          }).start();
        } else {
          // 閾値に達しなかった場合、UIを元に戻す
          Animated.timing(refreshAnim, {
            toValue: 0,
            duration: 200,
            useNativeDriver: false,
          }).start();
        }
      },
    })
  ).current;

  return (
    <View style={styles.container} {...panResponder.panHandlers}>
      <Animated.View
        style={[
          styles.refreshHeader,
          {
            height: refreshAnim, // 引っ張る量に応じて高さを変える
            opacity: refreshAnim.interpolate({
              inputRange: [0, 20, REFRESH_THRESHOLD],
              outputRange: [0, 0.5, 1],
              extrapolate: 'clamp',
            }),
          },
        ]}
      >
        {isRefreshing ? (
          <Text>更新中...</Text>
        ) : (
          <Text>↓ リフレッシュ</Text>
        )}
      </Animated.View>
      <FlatList
        data={data}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Text>{item.title}</Text>
          </View>
        )}
        keyExtractor={item => item.id}
        onScroll={onScroll}
        scrollEventThrottle={16} // スクロールイベントの頻度
        // FlatListのデフォルトのrefreshing機能は無効にするか、併用しない
        // refreshing={isRefreshing}
        // onRefresh={() => {}}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 50,
  },
  refreshHeader: {
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden', // 高さが0の時に見えないように
    backgroundColor: '#e0e0e0',
    position: 'absolute', // FlatListの上に重ねる
    top: 0,
    left: 0,
    right: 0,
    zIndex: 1, // FlatListより手前に表示
  },
  item: {
    padding: 20,
    borderBottomWidth: 1,
    borderBottomColor: '#ccc',
    backgroundColor: '#fff',
  },
});

export default MyCustomPullToRefreshFlatList;

このカスタム実装は非常に複雑であり、以下のような追加の考慮事項があります

  • プラットフォームの差異
    iOSとAndroidでジェスチャーの検出やアニメーションの挙動に微妙な違いがあるため、両プラットフォームでのテストと調整が必要です。
  • パフォーマンス
    アニメーションをスムーズにするためには、useNativeDrivertrueに設定したり、react-native-reanimatedのような高性能なアニメーションライブラリを活用したりする必要があります。
  • イベントの競合
    FlatList自身のスクロールジェスチャーとPanResponderのジェスチャーが競合しないように、onMoveShouldSetPanResponderなどの条件を慎重に設定する必要があります。
  • スクロールオフセットの管理
    FlatListScrollViewの内部スクロールオフセットとジェスチャーハンドラーのオフセットを同期させる必要があります。

ほとんどのケースでは、FlatListの標準的なrefreshingonRefreshプロパティ、またはRefreshControlを明示的に使用する方法が最も簡単で信頼性があります。

  • 独自の高度なUI/UXが必要な場合
    PanResponderreact-native-reanimatedなどを用いたカスタム実装を検討しますが、これはかなりの労力を要します。
  • FlatListではないがスクロール可能なコンテンツの場合
    ScrollViewRefreshControlの組み合わせが適しています。
  • 標準的なプル・トゥ・リフレッシュで十分な場合
    FlatListrefreshingonRefreshプロパティを使用するのが最も効率的で簡単な方法です。