[Swift×Firestore] onMoveでシフト可能なドキュメント

こんにちは！
本日は、私が先日開発したアプリ「ごほうび習慣プラス」に使用したシフト機能ついてご紹介します。
このアプリは、日々の習慣を報酬と結びつけることで、ユーザーがモチベーションを持続できるように設計されています。アプリはこちらのリンクからご覧いただけます

https://apps.apple.com/jp/app/%E3%81%94%E3%81%BB%E3%81%86%E3%81%B3%E7%BF%92%E6%85%A3%E3%83%97%E3%83%A9%E3%82%B9-%E3%82%B2%E3%83%BC%E3%83%9F%E3%83%95%E3%82%A3%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E7%BF%92%E6%85%A3%E5%8C%96%E3%82%A2%E3%83%97%E3%83%AA/id6474091359

「ごほうび習慣プラス」の核となる機能は、ユーザーが設定した習慣を報酬と連携させることです。このアプローチにより、習慣を守ることが、単なる義務ではなく、楽しい挑戦となります。

このアプリでは、Firestoreを利用してデータ管理をしています。ユーザーは自分の習慣をアプリに記録し、それらを好きなように並べ替えることができます。この柔軟な並べ替え機能は、SwiftとFirestoreの組み合わせによって実現されています。

この記事では、SwiftUIのListのonMoveなどを使用してシフト可能なFirestoreのドキュメントを実現するための方法について掘り下げていきます。

# シフト可能なコレクションの設計
Firestoreでは、各習慣をドキュメントとして保存し、これらのドキュメントをコレクション内で管理しています。

習慣の並べ替えを効率的に行うために、3つの方法を考慮しました。

- ドキュメントに順番情報を保持する方法: 
この方法では、各ドキュメントにindexやorderといった順番の情報を保持します。しかし、この方法はシフト処理時に大量のドキュメントの読み込みが必要となり、コストが高くなるという問題があります。
- 前後関係に基づく管理方法: 
各ドキュメントに前のドキュメントや次のドキュメントの情報を付与することで、連鎖的にデータを管理します。ただし、この方法は一部のデータが破損した場合、連鎖的に影響が出るリスクがあります。
- 順番を示すIDの配列を持つ方法: 
この方法では、ドキュメントのIDを順番に並べた配列を別のドキュメントに保持し、この配列を並べ替えることによってドキュメントの順序を管理します。これは読み取り回数が少なく、安全な方法です。
結局、3番目の方法を選択しました。この方法により、Firestore上でのデータの整合性を保ちつつ、効率的かつ安全に並べ替えを行うことが可能になります。

# タスクの追加（書き込み時の処理）
新しいタスクをFirestoreに追加する際、setメソッドが使用されます。このメソッドは単に習慣化タスクをFiretoreに保存しています。

``` swift
func set(value: HabitTaskEntity) async throws {
    return try await withCheckedThrowingContinuation { continuation in
        do {
            try db.collection(collectionName).document(value.id).setData(from: value, merge: false) { error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        } catch(let error) {
            continuation.resume(throwing: error)
        }
    }
}
```
タスクの追加時、Swift側でFirestoreに新しいタスクが保存されると、FirebaseのCloud Functionsがトリガーされます。この関数は、新しいタスクがhabitTasksコレクションに追加された際に自動的に実行されます。この関数の中で、addToOrderという別のヘルパー関数が呼ばれます。addToOrder関数は、新しく追加されたタスクのIDをユーザーのorderドキュメントに追加するために使用されます。

``` javascript
exports.addHabitTasksToOrder = functions.region('asia-northeast1').firestore
    .document('habitTasks/{id}')
    .onCreate((snap, context) => {
        const newTaskId = context.params.id;
        const userId = snap.data().userId;
        return addToOrder(newTaskId, userId, 'habitTasks');
    });

function addToOrder(newDocumentId, userId, collectionName) {
    const orderRef = admin.firestore().doc(`${collectionName}Order/${userId}`);
    return orderRef.get().then(doc => {
        if (!doc.exists) {
            return orderRef.set({ orders: [newDocumentId] });
        } else {
            const orders = doc.data().orders;
            orders.unshift(newDocumentId);
            return orderRef.update({ orders: orders });
        }
    });
}
```
Orderドキュメントの参照を取得：ユーザーIDに基づいて、対応するorderドキュメントの参照を取得します。
ドキュメントの存在確認：orderドキュメントが既に存在するかどうかを確認します。
新規作成または更新：
ドキュメントが存在しない場合：新しいorderドキュメントを作成し、新しく追加されたタスクIDを含む配列をセットします。
ドキュメントが存在する場合：既存のorders配列を取得し、新しいタスクIDを配列の先頭に追加した後、ドキュメントを更新します。
この処理により、新しいタスクが追加されるたびに、そのタスクのIDがユーザー固有のorderドキュメントに追加されます。

# 読み取り時の処理
以下のgetAllメソッドは、特定のユーザーに関連するすべての習慣タスクをFirestoreから非同期的に取得するために使用されます。このメソッドでは、

Orderドキュメントの取得：ユーザーIDに基づいて、Firestoreのorderを保存しているコレクションから特定のuserIdのドキュメントを取得します。このドキュメントには、タスクIDのリストが含まれています。
Orderリストの取得：orderドキュメントからordersフィールド（タスクIDのリスト）を取得します。
タスクの非同期的取得：ordersリストに含まれる各タスクIDに対して、非同期的にタスクの詳細を取得します。これはasyncCompactMap拡張機能を使用して行われます。

``` swift
func getAll(userId: String) async throws -> [HabitTaskEntity] {
    guard let orderDocument = try await db.collection(orderCollectionName).document(userId).getDocument().data() else {
        return []
    }
    guard let orders: [String] = orderDocument["orders"] as? [String] else {
        return []
    }
    return await orders.asyncCompactMap { id in
        try await db.collection(collectionName).document(id).getDocument(as: HabitTaskEntity.self)
    }
}
```

asyncCompactMapは、compactMapを改良して、非同期処理に対応させ、内部でthrowしたデータはスキップするようにしています。この実装によって、orderに含まれるタスクが何らかの理由で取得できないときにリスト全体がエラーになることを回避しています。

``` swift
extension Collection {
    public func asyncCompactMap<T>(
        _ transform: @escaping (Element) async throws -> T?
    ) async -> [T] {
        var values = [T]()

        for element in self {
            do {
                if let value = try await transform(element) {
                    values.append(value)
                }
            } catch {
                continue
            }
        }

        return values
    }
}
```

# onMoveによるシフト時の処理
SwiftUIのListでは、ユーザーがリスト内のアイテムをドラッグ&ドロップで並び替えることができます。この動作は、onMoveクロージャを使用して処理されます。以下のコードは、ユーザーがリスト内のタスクを移動させた際の処理を示しています。

``` swift
.onMove(perform: { indices, newOffset in
    if let userId = deviceState.currentUser?.uid {
        guard let startIndex: Int = indices.first else { return }
        let destinationIndex = newOffset > startIndex ? newOffset - 1 : newOffset
        habitTasksState.swapHabitTasks(userId: userId, id: habitTasks[startIndex].id, newIndex: destinationIndex)
    }
})

func swapHabitTasks(userId: String, id: String, newIndex: Int) {
    Task {
        do {
            try await repository.moveTaskInOrder(userId: userId, taskId: id, newOrderIndex: newIndex)
            getHabitTasks(userId: userId)
        } catch(let error) {
            print(error.localizedDescription)
        }
    }
}
```

onMoveでは、移動するタスクの元のインデックス（startIndex）、タスクを移動する先のインデックスを取得して、Repositoryに渡しています。moveTaskInOrderではFonctionsのonCallを呼んで、クラウド側を更新します。

``` swift
func moveTaskInOrder(userId: String, taskId: String, newOrderIndex: Int) async throws {
    let functions = Functions.functions(region: "asia-northeast1")
    return try await withCheckedThrowingContinuation { continuation in
        functions.httpsCallable("moveItemInOrder").call(["id": taskId, "newOrderIndex": newOrderIndex, "userId": userId, "collectionName": collectionName]) { (result, error) in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let resultData = result?.data as? [String: Any] {
                print(result)
                continuation.resume()
            }
        }
    }
}
```
呼ばれたCloudFunctions側のコードは以下になります。

``` javascript
function moveItemInOrder(id, newOrderIndex, userId, collectionName) {
    const orderRef = admin.firestore().doc(`${collectionName}Order/${userId}`);
    return orderRef.get().then(doc => {
        if (!doc.exists) {
            throw new functions.https.HttpsError('not-found', 'User order not found.');
        }

        const orders = doc.data().orders;
        const currentIndex = orders.indexOf(id);

        if (currentIndex === -1) {
            throw new functions.https.HttpsError('invalid-argument', 'TaskId not found in order.');
        }

        orders.splice(currentIndex, 1);
        orders.splice(newOrderIndex, 0, id);

        return orderRef.update({ orders: orders });
    });
}
```

Orderドキュメントの参照取得：指定されたユーザーIDに基づいて、そのユーザーのorderドキュメントの参照を取得します。
ドキュメントの存在確認：orderドキュメントが存在するか確認します。存在しない場合、エラーが投げられます。
タスクの順序変更：orderドキュメント内のorders配列から、移動するタスクのIDを探し、その位置を新しいインデックス位置に更新します。まず、現在の位置からタスクIDを削除し、次に新しい位置にタスクIDを挿入します。
ドキュメントの更新：変更されたorders配列でorderドキュメントを更新します。
# 削除時の処理
Swift側で単純にタスクを削除します。

``` swift
func delete(taskId: String) async throws {
    return try await withCheckedThrowingContinuation { continuation in
        db.collection(collectionName).document(taskId).delete { error in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume()
            }
        }
    }
}
```

タスクが削除された際、Cloud FunctionsによるremoveDocumentFromOrder関数がトリガーされます。この関数は、削除されたタスクIDをユーザーのorderドキュメントから削除します。

``` javascript
exports.removeHabitTaskFromOrder = functions.region('asia-northeast1').firestore
    .document('habitTasks/{id}')
    .onDelete((snap, context) => {
        const taskId = context.params.id;
        const userId = snap.data().userId;
        return removeDocumentFromOrder(taskId, userId, 'habitTasks');
    });
```

長くなってしまいましたが、以下のような方法を取れば安全にシフト可能なコレクションを作ることができます。CloudFunctionなんかはテンプレ化してしまえば、どんなプロジェクトでも汎用的にこの手法が使えると思います。

最後に、[ごほうび習慣プラス](https://apps.apple.com/jp/app/%E3%81%94%E3%81%BB%E3%81%86%E3%81%B3%E7%BF%92%E6%85%A3%E3%83%97%E3%83%A9%E3%82%B9-%E3%82%B2%E3%83%BC%E3%83%9F%E3%83%95%E3%82%A3%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E7%BF%92%E6%85%A3%E5%8C%96%E3%82%A2%E3%83%97%E3%83%AA/id6474091359)をぜひダウンロードしてみてください！

https://apps.apple.com/jp/app/%E3%81%94%E3%81%BB%E3%81%86%E3%81%B3%E7%BF%92%E6%85%A3%E3%83%97%E3%83%A9%E3%82%B9-%E3%82%B2%E3%83%BC%E3%83%9F%E3%83%95%E3%82%A3%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E7%BF%92%E6%85%A3%E5%8C%96%E3%82%A2%E3%83%97%E3%83%AA/id6474091359