React Native リストの一番上の更に上までスクロールしたら最新のリストをロードする

例えばTwitterのスマホアプリ版などで使われている、リストを一番上の更に上までグイッとスクロールするとリロードをかけてくれるアレ
いろんなアプリでよく見るので、作り方を残しておくと誰かの役に立つかと思ったので書いておきます。
Webに比べてNativeのそういう記事は少ないなと思ったので

## 技術キーワード
- React Native
- Typescript

あとおまけ的に以下も　本題とはそんなに関係ありません。

- Redux
- redux-observable
- redux-actions
- tailwind-rn

## デモ
![ImageList_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/4966c453-82a2-138c-0644-f9687e0081b5.gif)
画像が入れ替わらないのでわかりづらいですが、上スワイプしてリストの一番上を超えるとローディングのクルクルが出ています。
ちゃんとサーバーにリクエストをかけていて、表示画像が更新されています。
（サーバーが同じ画像のリストを返してくるので表示が変わらない）

## Viewの実装
画像のリストの一番上のそのさらに上にスクロールしたときに画像の再ロードをかけるコンポーネント ImageListを作成します。
ロジックを担当するContainer, Viewを担当するPresentationalに分けて実装していきましょう。
ContainerとPresentationalは以下のような構造で１つのディレクトリ中で作成します。

ImageList
  ┣ index.tsx　　　　　// Container
  └  ImageList.tsx 　　　// Presentational

### Presentationalの実装
まずはわかりやすいPresentationalのコード
styleにはtailwindを使用していますが、ロジックには関係ないのでお好みのCSSフレームワークをご利用ください。


ImageList/index.tsx

```typescript
import React from 'react';
import {
  ScrollView,
  ActivityIndicator,
  View,
  Image,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native';
import tailwind from 'tailwind-rn';

// storeに保持される画像リストの型
export type Images = {
  loaded: boolean;
  loading: boolean;
  images: [
    {
      id: string;
      imgUrl: string;
    },
  ];
};

type Props = {
  onScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
  images: Images;
};

const ImageList: React.FC<Props> = ({ images, onScrollEndDrag }) => {
  return (
    <>
      {/* ロード中はクルクルを表示 */}
      {images.loading ? (
        <View style={tailwind('p-4')}>
          <ActivityIndicator size="large" color="#53b0c9" />
        </View>
      ) : null}

      {/* ScrollViewのonScrollEndDragにイベントハンドラを登録する */}
      <ScrollView onScrollEndDrag={onScrollEndDrag} scrollEventThrottle={16}>
        <View style={tailwind('flex flex-row flex-wrap')}>
          {images.images.map((image) => {
            return (
              <View key={image.id} style={tailwind('w-1/3 h-24 p-1')}>
                <Image
                  source={{ uri: image.imgUrl }}
                  style={tailwind(`w-full h-full`)}
                />
              </View>
            );
          })}
        </View>
      </ScrollView>
    </>
  );
};

export default ImageList;
```
Images型には画像のデータの他にloading, loadedと行ったAPI通信に関するメタデータを持たせてあります。
loading=trueであればクルクルことActivityIndicatorを表示します。

一番のポイントはScrollViewのonScrollEndDragにイベントハンドラを登録していること。
このイベントハンドラはPropsから渡されるもので、ロジックはContainerの方に書きます。

ActivityIndicatorやScrollViewといったCore Componentsについては公式ドキュメントを参照して使い方を確認しましょう
[React Native公式: ActivityIndicator][1]
[React Native公式: ScrollView ScrollEndDragイベント][2]


### Containerの実装
Containerのコードです。一番上のスクロールの更に上を検知して再ロードをかけるロジックはここに書きます。

ImageList/index.tsx

```javascript
import React, { useEffect } from 'react';
import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { useSelector, useDispatch } from 'react-redux';
import { getImages } from '../../../redux/images';
import Presentational from './ImageList';

const ImageList: React.FC = () => {
  const dispatch = useDispatch();

  // 最初に表示する画像のロード
  useEffect(() => {
    dispatch(getImages());
  }, []);
  const images = useSelector((state) => state.images);

  // スクロールのイベントハンドラ
  const onScrollEndDrag = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    if (e.nativeEvent.contentOffset.y < 0) { //スクロール最上部のさらに上までスクロールされた時
      dispatch(getImages()); // 画像の再ロード
    }
  };
  return <Presentational onScrollEndDrag={onScrollEndDrag} images={images} />;
};

export default ImageList;
```
一番気になるところはScrollの「最上部の更に上」の取得の部分だと思います。
[公式ドキュメント][2]にはScrollViewのScrollEventは以下のような型だと書いてあります。

```javascript
{
  nativeEvent: {
    contentInset: {bottom, left, right, top},
    contentOffset: {x, y},
    contentSize: {height, width},
    layoutMeasurement: {height, width},
    zoomScale
  }
}
```

このイベントがNativeSyntheticEventにラップされて来ますので、e.nativeEvent.contentOffset.yでyのスクロール位置が取れます。
これが0未満あれば一番上のその更に上に到達しています。最上部より上はyがマイナスの値になります。
ここで、重要なのはScrollイベントではなくScrollEndDragイベントを利用することです。

デフォルトでスムーススクロールが効いているので、上スワイプして指を話すとスーッっと慣性でスクロールしていきます。onScrollではこの慣性でもy<0に到達して再ロードが走ってしまうことになるので、**「指を離した瞬間にy<0である」を判定するonScrollEndDragが有効**です。

最上部の更に上にスクロールされた瞬間を検知できれば、あとはそのタイミングで画像のロード処理を走らせれば良いです。
reduxを使用しているので、今回はdispatch(getImages())と書いています。reduxでなければ、ここに直接リクエスト処理を書いても良いでしょう。

以上、これで上のデモと同じ動きをする画像リストコンポーネントの実装が完了です。

## おまけ: Redux周りの処理
記事タイトル通りの機能の実装の解説であればここまでで十分とは思いますが、Redux初心者の方などはloadingのクルクルを出すための状態管理などの部分も見たいかもしれないと思ったので、一応Redux周りの処理も書いておきます。

```typescript
import { createAction, handleActions, Action } from 'redux-actions';
import { ofType, ActionsObservable } from 'redux-observable';
import { map, mergeMap } from 'rxjs/operators';
import { AxiosResponse } from 'axios';
import axiosManager from '../modules/axios';
import { Images } from './path/to/ImageList/ImageList';
import { AppConst } from '../modules/AppConst';

const GET_IMAGES_REQUEST = 'GET_IMAGES_REQUEST';
const GET_IMAGES_REQUEST_SUCCESS = 'GET_IMAGES_REQUEST_SUCCESS';

export const getImages = createAction(GET_IMAGES_REQUEST);
export const getImagesSuccess = createAction(GET_IMAGES_REQUEST_SUCCESS);

const INITIAL_STATE: Images = {
  loaded: false,
  loading: true,
  images: [],
};
const axios = axiosManager.getOrCreateAxios();

// redux-observableのEpic
// GET_IMAGES_REQUESTのActionがdispatchされた時に処理される
// APIの画像取得エンドポイントにget requestを発行
// responseのデータをPayloadにしてActionをdispatch
export const getImagesEpic = (action$: ActionsObservable<Action<any>>) =>
  action$.pipe(
    ofType(GET_IMAGES_REQUEST),
    mergeMap(() => axios.get(`${AppConst.BASE_URL}/getImages`)),
    map((response: AxiosResponse<any>) => getImagesSuccess(response.data)),
  );

// redux-actionsのreducer
export default handleActions<Images, any>(
  {
    [GET_IMAGES_REQUEST]: (state) => ({
      // request開始時のアクション ここでloadingをtrueにする
      ...state,
      loaded: false,
      loading: true,
    }),
    [GET_IMAGES_REQUEST_SUCCESS]: (state, { payload }: Action<CatImage[]>) => {
      // request成功時のアクション storeにデータを保存sてloadingをfalseにする
      return {
        loaded: true,
        loading: false,
        images: [...payload],
      };
    },
  },
  INITIAL_STATE,
);
```
Viewの解説で作ったImageListコンポーネントのデータになるImagesは上記のようなstate, actionになっています。
画像リストの一番上までスワイプされた時にgetImages()がdispatchされ、GET_IMAGES_REQUESTに対応するreducerの処理が走ります。ここでloadingがtrueになり、クルクルが表示されます。

このreducerの処理後はredux-observableの処理に移ります。
redux-observableのEpicの動作についてはこちらの記事で解説していますので、参考にしてください。
[redux-observable Epicがどう動くかを理解する][3]

getImagesEpicの処理はコメントに記した通りです。APIにgetリクエストを発行して、レスポンスのデータをpayloadにしてGET_IMAGE_SUCCESSのアクションをdispatchします。
最後にGET_IMAGE_SUCCESSのreducerが動き、APIから取得したデータをimagesに詰めてloadingをfalseにします。
これによってクルクルのアニメーションが止まり、再ロード後の画像がリストに表示されるようになります。

## 終わりに
react-nativeはWebで検索してもサンプルコードが多くないので、この記事が少しでも他の方の助けになれば良いなと思います。
誤り、改善ポイントなどあればコメントをお願い致します。

[1]:https://reactnative.dev/docs/activityindicator#docsNav]
[2]:https://reactnative.dev/docs/scrollview#onscrollenddrag]
[3]:https://qiita.com/Kontam/items/30a8b4806bf76fcaffc3