SwiftUIで一覧画面から詳細画面へ遷移後、データを更新して一覧画面に戻るとちゃんと更新されたデータが反映される実装例

[株式会社Never](https://neverjp.com/)のshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

https://neverjp.com/service/

弊社のアプリ開発は`Flutter`を利用していますが、PJによってはiOSネイティブで開発しています。そこで得た知見を共有します。

本記事は新しい発見とかではないですが、誰かの悩みを解決できるものであれば幸いです。

# 概要

メモアプリ等でよくある、一覧画面から詳細画面へ遷移後、データを更新して一覧画面に戻るとちゃんと更新されたデータが反映される実装例を紹介します。

今回紹介するコードはこちらにまとめてます。
https://github.com/never-inc/iOSAdventCalendar2023

# 実装例のアプリ

一覧画面でダミーデータを表示し、選択すると詳細画面へ遷移します。詳細画面でデータを更新すると一覧画面にも反映されるものです。

一覧画面
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/83e7642a-aedf-1f05-ecda-edcb5bb0c7f8.png">

詳細画面
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/b9e1b55b-ea8c-25f0-315a-23fc95eda665.png">

詳細画面で変更すると
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/ac4face8-b2a2-ed8c-3ff8-de58b8c94cce.png">

一覧画面も反映される
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/8abedfa6-cd3c-5f20-3ebc-5b67724d71d1.png">

## 登場人物

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/8ddddc54-66ef-c713-3105-3a29ee60f97b.png">

### `DataSource`

今回、DBは使いたくなかったので、データはメモリキャッシュ上で管理します。`DataSource`というクラスを用意してシングルトンとして扱います。

↑の図では枠からはみ出てます、外部から提供されるものだと思ってもらえればと。

```swift:AdventCalendar2023/DataSource/DataSource.swift
// APIやローカルDBの代わりとなるもの
final class DataSource {
    static let shared = DataSource()
    private(set) var items: [[String: Any]] = (0...19).map { index in ["todoId": UUID().uuidString, "text": "\(index)"] }
    
    func addTodo(_ todo: [String: Any]) {
        items = [todo] + items
    }
    
    func fetchTodo(_ todoId: String) -> [String: Any]? {
        items.first(where: { $0["todoId"] as? String == todoId })
    }
    
    func updateTodo(_ todo: [String: Any]) {
        guard let todoId = todo["todoId"] as? String else {
            return
        }
        items = items.map { $0["todoId"] as? String == todoId ? todo : $0 }
    }
    
    func deleteTodo(_ todoId: String) {
        items = items.filter { $0["todoId"] as? String != todoId }
    }
    
    func clear() {
        items = (0...19).map { index in ["todoId": UUID().uuidString, "text": "\(index)"] }
    }
}
```

### `ObservableObject`

`DataSource`から取得したデータを、`ObservableObject`のクラス`TodoController`で管理します。


```swift:AdventCalendar2023/Model/TodoController.swift
final class TodoController: ObservableObject {
    
    @Published private(set) var items: [Todo] // 👈 一覧画面に表示するデータ
    
    private let dataSource = DataSource.shared
    
    init() {
        items = dataSource.items.compactMap { Todo.fromDic($0) }
    }
    
    func addTodo(_ todo: Todo) {
        items = [todo] + items
        dataSource.addTodo(todo.toDic())
    }
    
    func fetchTodo(_ todoId: String) -> Todo? {
        if let todo = items.first(where: { $0.todoId == todoId }) {
            return todo
        }
        // もしitemsに存在しなければ、データソースから取得する
        if let dic = dataSource.fetchTodo(todoId), let todo = Todo.fromDic(dic) {
            // TODO: ユースケースよってはitemsにセットする（しないケースもあるけど）
            return todo
        }
        return nil
    }
    
    func updateTodo(_ todo: Todo) {
        items = items.map { $0.todoId == todo.todoId ? todo : $0 }
        dataSource.updateTodo(todo.toDic())
    }
    
    func deleteTodo(_ todoId: String) {
        items = items.filter { $0.todoId != todoId }
        dataSource.deleteTodo(todoId)
    }
}
```

Todoのデータクラスは以下のとおりです。

```swift:AdventCalendar2023/Model/Todo.swift
struct Todo: Codable, Hashable {
    let todoId: String
    let text: String
    
    // Dic -> Todo
    static func fromDic(_ dic: [String: Any]) -> Todo? {
        let decoder = JSONDecoder()
        if let data = try? JSONSerialization.data(withJSONObject: dic),
           let todo = try? decoder.decode(Todo.self, from: data) {
            return todo
        }
        return nil
    }
    
    // Todo -> Dic
    func toDic() -> [String: Any] {
        let encoder = JSONEncoder()
        if let data = try? encoder.encode(self), 
            let dic = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
            return dic
        }
        return [:]
    }
}
```

# 実装例

3つの実装例を紹介します。

## `EnvironmentObject`を使う

一覧画面で生成した`TodoController`のインスタンスを、`.environmentObject`を使って詳細画面へ渡す方法です。

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/3499a1cb-e38d-ae35-acb9-8b97f5cc0fa0.png">

```swift:AdventCalendar2023/SampleEnvironment/EnviromentContentView.swift
struct EnvironmentContentView: View {

    @StateObject private var todoController = TodoController()
    
    private var items: [Todo] {
        todoController.items
    }
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    NavigationLink {
                        EnvironmentDetailView(todoId: item.todoId)
                            .environmentObject(todoController) // 👈 ココ
                    } label: {
                        Text(item.text)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .navigationTitle("Sample With Environment")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }
    ...
}
```

```swift:AdventCalendar2023/SampleEnvironment/EnvironmentDetailView.swift
struct EnvironmentDetailView: View {
    
    let todoId: String
    @State private(set) var item: Todo?
    @EnvironmentObject private var todoController: TodoController // 👈 ココ
    
    var body: some View {
        VStack {
            Text(item?.text ?? "")
            Button(action: {
                guard let item = self.item else {
                    return
                }
                let newTodo = item.copyWith(text: Todo.randomText())
                self.item = newTodo
                todoController.updateTodo(newTodo) // 👈 ココ
            }) {
                Text("Update")
            }
            .padding()
        }
        .onAppear {
            item = todoController.fetchTodo(todoId)
        }
    }
}
```

一覧画面と詳細画面は同じインスタンスの`TodoController`を参照できるので、詳細画面で`TodoController`が持つ`items`データを更新したら一覧画面にも反映されます。


[全てのソースコードはこちら](https://github.com/never-inc/iOSAdventCalendar2023/tree/main/AdventCalendar2023/SampleEnvironment)

なお、 iOS17から登場した[Observation](https://developer.apple.com/documentation/observation/)の`@Observable` + `Enviroment` を利用しても同じことができます。

## `NotificationCenter`の`publisher`を使う

`NotificationCenter`の`publisher`を使えば、データを更新したことを他の画面へ通知できます。 詳細画面で`TodoController`のインスタンスに依存しなくても利用できます。

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/163073b2-3806-afb5-4572-f18836932a28.png">

更新通知であることを判断できるよう`Notification.Name`に`updateTodo`を定義します。

```swift:AdventCalendar2023/SampleNotificationCenter/NotificationName+Extension.swift
extension Notification.Name {
    static var updateTodo: Notification.Name {
        Notification.Name("updateTodo")
    }
}
```

一覧画面で`updateTodo`からの通知を受け取り、通知がきたら`TodoController`の持つ`items`を更新します。

```swift:AdventCalendar2023/SampleNotificationCenter/NotificationCenterContentView.swift
struct NotificationCenterContentView: View {
    ...
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    NavigationLink {
                        NotificationCenterDetailView(todoId: item.todoId)
                    } label: {
                        Text(item.text)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            // 👇 ココ
            .onReceive(NotificationCenter.default.publisher(for: .updateTodo)) { value in
                guard let todo = value.object as? Todo else {
                    return
                }
                todoController.updateTodo(todo)
            }
            .navigationTitle("Sample With NotificationCenter")
            ...
        }
    }
    ...
}
```

詳細画面でデータを更新したら、`NotificationCenter.default.post`を使い`updateTodo`に対して通知します。通知する際に新しいデータ`newTodo`を送ります。

```swift:AdventCalendar2023/SampleNotificationCenter/NotificationCenterDetailView.swift
struct NotificationCenterDetailView: View {
    
    let todoId: String
    @State private(set) var item: Todo?
    private let dataSource = DataSource.shared
    
    var body: some View {
        VStack {
            Text(item?.text ?? "")
            Button(action: {
                guard let item = self.item else {
                    return
                }
                let newTodo = item.copyWith(text: Todo.randomText())
                self.item = newTodo
                dataSource.updateTodo(newTodo.toDic())
                // 👇 ココ
                NotificationCenter.default.post(name: .updateTodo, object: newTodo)
            }) {
                Text("Update")
            }
            .padding()
        }
        .onAppear {
            // データソースから取得
            if let dic = dataSource.fetchTodo(todoId), let todo = Todo.fromDic(dic) {
                item = todo
            }
        }
    }
}
```

[全てのソースコードはこちら](https://github.com/never-inc/iOSAdventCalendar2023/tree/main/AdventCalendar2023/SampleNotificationCenter)

### `onReceive`ではなく`TodoController`で監視する

一覧画面の`onReceive`ではなく`TodoController`で通知を受け取り、データを更新できます。

```swift:AdventCalendar2023/Model/TodoController.swift
final class TodoController: ObservableObject {
    ...
    // 👇 disposerが必要
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        ...
        // 👇 ココ
        NotificationCenter.default.publisher(for: .updateTodo)
            .sink { [weak self] value in
                guard let todo = value.object as? Todo else {
                    return
                }
                self?.updateTodo(todo)
            }
            .store(in: &cancellables)
    }
    ...
}
```

## `Combine`の`PassthroughSubject`を使う

`Combine`の`PassthroughSubject`を使うことで、`NotificationCenter`のようなことができます。`PassthroughSubject`を使う理由として、状態を保持せずに通知のみを行うためです。

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/1dfabefb-c4a5-660e-ea24-a9203a12e757.png">

`Observer`を作ります。受け取る側も通知する側も同じインスタンスを扱わないといけないため、シングルトンで扱います。

```swift:AdventCalendar2023/SamplePassthroughSubject/Observer.swift
final class Observer {
    static let shared = Observer()
    let todoSubject = PassthroughSubject<Todo, Never>()
}
```

一覧画面で`Observer.shared.todoSubject`からの通知を受け取り、通知がきたら`TodoController`の持つ`items`を更新します。

```swift:AdventCalendar2023/SamplePassthroughSubject/PassthroughSubjectContentView.swift
struct PassthroughSubjectContentView: View {
    ...
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    NavigationLink {
                        PassthroughSubjectDetailView(todoId: item.todoId)
                    } label: {
                        Text(item.text)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            // 👇 ココ
            .onReceive(Observer.shared.todoSubject) { todo in
                todoController.updateTodo(todo)
            }
            .navigationTitle("Sample With PassthroughSubject")
            ...
        }
    }
    ...
}
```

詳細画面でデータを更新したら、`Observer.shared.todoSubject.send`を使い、新しいデータ`newTodo`を送ります。

```swift:AdventCalendar2023/SamplePassthroughSubject/PassthroughSubjectDetailView.swift
struct PassthroughSubjectDetailView: View {
    
    let todoId: String
    @State private(set) var item: Todo?
    private let dataSource = DataSource.shared
    
    var body: some View {
        VStack {
            Text(item?.text ?? "")
            Button(action: {
                guard let item = self.item else {
                    return
                }
                let newTodo = item.copyWith(text: Todo.randomText())
                self.item = newTodo
                dataSource.updateTodo(newTodo.toDic())
                // 👇 ココ
                Observer.shared.todoSubject.send(newTodo)
            }) {
                Text("Update")
            }
            .padding()
        }
        .onAppear {
            // データソースから取得
            if let dic = dataSource.fetchTodo(todoId), let todo = Todo.fromDic(dic) {
                item = todo
            }
        }
    }
}
```

[全てのソースコードはこちら](https://github.com/never-inc/iOSAdventCalendar2023/tree/main/AdventCalendar2023/SamplePassthroughSubject)

### `onReceive`ではなく`TodoController`で監視する

一覧画面の`onReceive`ではなく`TodoController`で通知を受け取り、データを更新できます。

```swift:AdventCalendar2023/Model/TodoController.swift
final class TodoController: ObservableObject {
    ...
    // 👇 disposerが必要
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        ...
        // 👇 ココ
        Observer.shared.todoSubject
            .sink { [weak self] todo in
                self?.updateTodo(todo)
            }
            .store(in: &cancellables)
    }
    ...
}
```

# 終わりに

実装例を紹介しました。

余談ですが、一覧画面から詳細画面へ渡す引数は`String`の`todoId`にしています。

理由として渡す引数はプリミティブ型で管理した方が、一覧画面以外（通知バナータップなど）の所から遷移がしやすいからです。

他にも良いやり方がありましたら教えてください。

ありがとうございました！