ReactNative + Firestoreで簡単な無限スクロールページ

Firestoreで多量のデータを作り、ReactNativeのコンポーネントを使って以下のような簡単な無限スクロールを実装してみます。
<img width="321" alt="スクリーンショット 2019-10-21 11.13.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/b1d20c94-06ff-b693-da19-503fc30524af.png">

# テストデータの作成
[前の記事](https://qiita.com/mildsummer/items/4d08591f719476c7da72)の続きになります。
Cloud Functionsを使って、認証ユーザーが作成された時に多量のデータをユーザーに紐付けて作成してみます。
今回は`author`（ユーザーのuid）、`text`、`order`というフィールドをもつ`posts`コレクションを追加しました。

```javascript:functions/index.js（部分）
const db = admin.firestore();

exports.createUser = functions.auth.user().onCreate((user) => {
  const SIZE = 100;
  const batch = db.batch();
  const userRef = db.collection('/users').doc(user.uid);
  batch.set(
    userRef,
    {
      email: user.email,
      postsSize: SIZE
    }
  );
  for (let i = 0; i < SIZE; i++) {
    batch.set(
      userRef.collection('/posts').doc(),
      {
        text: 'test',
        order: i
      }
    );
  }
  batch.commit();
});
```

データベースのセキュリティルールはこのように設定しておきます。

```text
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;
      match /posts/{post} {
        allow read, write: if request.auth.uid == userId;
      }
    }
  }
}
```

100個なのでそんなに多量ではないですが、`Firestore.batch`を使って、`users`の中のサブコレクションとして一括書き込みをするようにしました。
Firestoreでは低コストでドキュメント数をカウントする関数が無いようなので、`posts`の数を`users`のフィールドとして保持しておいて、追加の度にインクリメントするようなのを想定しています。
これをデプロイし、ユーザー登録してみます。
<img width="1154" alt="expo-auth-practice_–_Database_–_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/667e1b63-6129-642c-6c39-a4e419374b72.png">
ユーザー情報に`posts`コレクションが作られ、テストデータが保存されました。

では、一覧画面を作ります。

```javascript:List.js
import React, { Component } from 'react';
import { View, FlatList, ActivityIndicator } from 'react-native';
import { ListItem } from 'react-native-elements';
import { connect } from 'react-redux';
import { getPosts } from '../redux';

class List extends Component {
  constructor(props) {
    super(props);
    this.startLoading = this.startLoading.bind(this);
    this.state = {
      isLoading: false
    };
  }

  componentDidMount() {
    const { posts } = this.props;
    if (!posts) {
      this.startLoading();
    }
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.posts !== this.props.posts) {
      this.setState({
        isLoading: false
      })
    }
  }

  startLoading() {
    const { posts, getPosts } = this.props;
    this.setState({
      isLoading: true
    }, () => {
      getPosts(20, (posts && posts.length) ? posts[posts.length - 1] : null);
    });
  }

  render() {
    const { dbUser, posts } = this.props;
    const { isLoading } = this.state;
    const isMore = posts && posts.length < dbUser.postsSize;
    const isLoadingEnabled = !isLoading && isMore;
    const activityIndicator = isMore ? (
      <ActivityIndicator
        animating
        size="large"
        style={{
          marginTop: 16,
          marginBottom: 16
        }}
      />
    ) : null;
    return (
      <View
        style={{
          flex: 1,
          height: '100%'
        }}
      >
        {posts ? (
          <FlatList
            data={posts}
            keyExtractor={(item) => (item.id)}
            renderItem={({ item }) => (
              <ListItem
                title={item.data().order.toString()}
                subtitle={item.data().text}
                bottomDivider
              />
            )}
            onEndReached={isLoadingEnabled ? this.startLoading : null}
            onEndReachedThreshold={0}
            ListFooterComponent={activityIndicator}
          />
        ) : null}
      </View>
    );
  }
}

const mapStateToProps = state => ({
  dbUser: state.user.dbData,
  posts: state.user.posts
});

const mapDispatchToProps = {
  getPosts
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(List);
```
ReduxのstoreからDBのユーザー情報、DBのテストデータを取って来るとして、コンポーネントが初期化されたらデータの取得を開始するアクションを呼ぶようにします。

一覧画面の描画にはreact-nativeの`FlatList`とreact-native-elementsの`ListItem`を使ってみます。

`FlatList`の`onEndReached`にリストの一番下までスクロールした時のトリガー関数を指定できるので、そのタイミングでもデータ取得を開始するようにします。ローディングの状態変化やそれ以上読み込み可能かどうかの判断など、実装パターンは大体何を使うのでも同じだと思うので、好きな書き方でよさそうです。

`onEndReached`が呼ばれるタイミングは`onEndReachedThreshold`で変えることができ、リストの一番下からの距離を、リストの見える範囲の高さ（全体の高さではなく）を1として指定できます。デフォルトは0.5ですが今回は0を指定し、一番下まで完全にスクロールしてから読み込むようにしてみました。

`getPosts`アクションはこのようにしました。

```javascript:redux.js（部分）
export const getPosts = (size, startAfter) => (dispatch) => {
  const user = store.getState().user.data;
  if (user) {
    const userRef = db.collection('/users').doc(user.uid);
    userRef.get()
      .then((userSnapshot) => {
        dispatch({
          type: 'SUCCESS_GET_USER',
          data: userSnapshot.data()
        });
        let postsRef = userRef.collection('/posts')
          .orderBy('order')
          .limit(size);
        if (startAfter) {
          postsRef = postsRef.startAfter(startAfter);
        }
        return postsRef.get();
      })
      .then((querySnapshot) => {
        dispatch({
          type: 'SUCCESS_GET_POSTS',
          posts: querySnapshot.docs
        });
      })
      .catch(({ message }) => {
        Alert.alert(message);
      });
  }
};
```

読み込む個数と、最後に読み込んだドキュメント（`DocumentSnapshot`）を指定して取得を開始します。
`Query`の`orderBy`、`limit`、`startAfter`を使って整列、個数指定、位置指定を行っています。
サブコレクションに対する`Query`は`DocumentReference`にさらに`collection`メソッドを使用することで取得できます。
これでFirestoreでのページング処理ができました。

# Github
サンプルソースはこちらです
https://github.com/mildsummer/expo-auth-practice
https://github.com/mildsummer/expo-auth-practice-firebase