UITableViewを一番下までスクロールした時に、APIを叩く

APIを叩いて、20件ずつデータを取得する方法について少し詰まったポイントがあったので、残しておきます。

## UITableViewが一番下に行ったのを検知
まず、`scrollViewDidScroll`内で、`tableView`のスクロールを検知することができ、下記のコードによって一番下に行ったのを検知することができます。
```ViewController.swift
    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        let currentOffsetY = scrollView.contentOffset.y
        let maximumOffset = scrollView.contentSize.height - scrollView.frame.height
        let distanceToBottom = maximumOffset - currentOffsetY
        if distanceToBottom < 0 && self.tableView.isDragging {
            print("一番下を検知したよー！")
            // ここで、任意の処理を記載(今回は、APIを叩く)
        }
    }
```
## APIを叩く
次に、ViewModelにAPIを叩く処理を記載します。
ここは、ライブラリなどを使用していただいても結構です。
```DataModel.swift
struct DataModel {
    let id: Int
    let title: String
    let contents: String

    init(json: [String: Any]) {
        self.id = json["id"] as? Int ?? 0
        self.title = json["title"] as? String ?? ""
        self.contents = json["contents"] as? String ?? ""
    }

    init() {
        self.id = 0
        self.name = ""
        self.contents = ""
    }
}

```

```ViewModel.swift
final class ViewModel {

    var dataArray = [DataModel]()
    // pageが1増えるたびに20件ずつ取得するAPIを想定します。
    private var currentPage = 1

    func fetchData(handler: @escaping (_ result: [DataModel]?, _ error: String?) -> Void) {

        guard let url = URL(string: "https://example.com/data?page=\(self.currentPage)") else { return }
        let request = URLRequest(url: url)

        let task = URLSession.shared.dataTask(with: request) { [self] data, response, error in

            if let error = error {
                handler(nil, error.localizedDescription)
                return
            }

            guard let data = data, let response = response as? HTTPURLResponse else {
                handler(nil, "data or response is nil")
                return
            }

            if response.statusCode == 200 {

                do {
                    let object = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]

                    guard let results = object?["result"] as? NSArray
                    else { return }

                    // dataの重複を防ぐために、配列の中身を空にします。
                    dataArray = []
                    dataArray = results.map { DataModel(json: $0 as? [String: Any] ?? [:]) }

                    handler(dataArray, nil)
                    // データの取得が完了するたびに、次ページを取得するために以下を記載
                    currentPage += 1
                } catch {
                    handler(nil, error.localizedDescription)
                }
            } else {
                handler(nil, "statusCode error")
            }
        }
        task.resume()
    }
}
```
## ロードのステイタスを管理
上記のままでは、スクロールした際に一番下を複数回検知し、一気に何度もデータを読み込むようになってしまうと思うので、ロードのステイタスを管理するために、以下の処理を追記します。
これで、一番下までスクロールした際に次のデータのみを取得することができます。
※上記の`ViewModel`に対してコメント部分のみ追記があります。
```ViewModel.swift
final class BookListViewModel {

    // 追記: ロードのステイタスを管理します。
    private enum Status {
        case initial // 初期状態
        case loading // ロード中
        case completion // ロード完了
        case error // エラー
    }

    var dataArray = [DataModel]()
    private var currentPage = 1
    // 追記: 最初は.initialを設定
    private var status = Status.initial

    func fetchData(handler: @escaping (_ result: [DataModel]?, _ error: String?) -> Void) {

        // 追記: .loadingの時以外、処理を実行する。
        guard self.status != .loading else { return }
        // 追記: ステイタスを.loadingに変更
        self.status = .loading

        guard let url = URL(string: "https://example.com/data?page=\(self.currentPage)") else { return }
        let request = URLRequest(url: url)

        let task = URLSession.shared.dataTask(with: request) { [self] data, response, error in

            if let error = error {
                handler(nil, error.localizedDescription)
                // 追記: エラー
                status = .error
                return
            }

            guard let data = data, let response = response as? HTTPURLResponse else {
                handler(nil, "data or response is nil")
                // 追記: エラー
                status = .error
                return
            }

            if response.statusCode == 200 {

                do {
                    let object = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]

                    guard let results = object?["result"] as? NSArray
                    else { return }

                    dataArray = []
                    dataArray = results.map { DataModel(json: $0 as? [String: Any] ?? [:]) }

                    handler(dataArray, nil)
                    currentPage += 1
                    // 追記: ロード完了
                    status = .completion
                } catch {
                    handler(nil, error.localizedDescription)
                    // 追記: エラー
                    status = .error
                }
            } else {
                handler(nil, "statusCode error")
                // 追記: エラー
                status = .error
            }
        }
        task.resume()
    }
}
```
## ViewControllerでデータの取得とtableViewの更新を行う
`setUpdata`でデータの取得とtableViewの更新を行う
```ViewController.swift

    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        let currentOffsetY = scrollView.contentOffset.y
        let maximumOffset = scrollView.contentSize.height - scrollView.frame.height
        let distanceToBottom = maximumOffset - currentOffsetY
        if distanceToBottom < 0 && self.tableView.isDragging {
            print("一番下を検知したよー！")
            self.setUpData()
        }
    }

    func setUpData() {
        self.activityIndicator.startAnimating()
        self.viewModel.fetchData { result, error in

            DispatchQueue.main.async { [self] in
                activityIndicator.stopAnimating()
                if let error = error {
                    print(error)
                }
                if let result = result {
                    dataArray.append(contentsOf: result)
                    tableView.reloadData()
                }
            }
        }
    }
```

## 参考
https://aomathwift.hatenablog.com/entry/2019/06/18/232013