SwiftUI に、Container / Presentational パターンという選択肢

## 2023/03/09 更新 

本記事のサンプルアプリの解説で、`ToDoListCard` の表示が更新されないので Core Data のエンティティをそのまま受け取る実装にしたと書いていましたが、そうしなくても再レンダリングがかかってくれる手法を発見したので修正しました！ 

理屈としては、以下の記事と同じ理屈です。

https://qiita.com/Uhucream/items/db51185f0c203f6d4ad7


## 僕について

1. 新卒でフロントエンドをメインに React と TypeScript を 1年以上 経験
   - React Native も書いたことがあります。


1. 趣味(?)で 2022年から SwiftUI の個人開発アプリを作り始めたり始めなかったり


## この記事を書いた経緯

以前から書こうとは思っていたのですが、先日 Twitter に投稿したツイートが思いの外反響(?)があったので書くことを決意しました👶🏻

https://twitter.com/ukokkei95toyama/status/1630730375415623682?s=61&t=P-JFhcT2vG2q8sIYmj8O_w



### VIPER アーキテクチャ辛い

それまで、僕は [こちらの記事](https://blog.personal-factory.com/2020/05/31/viper-architecture-in-swiftui/) を参考に個人開発アプリで VIPER アーキテクチャを採用していました。


しかし、途中から以下のような違和感を感じ始め、苦しみました。

- Core Data の `@FetchRequest` プロパティラッパーが使えない (使うと依存関係がぐちゃぐちゃになる)
- 一つの画面を作るのに 4つ もファイルが必要
- Router いるのか問題

等々...



### VIPER やめようぜ

そう思い始めたのは昨年、こちらの記事を見かけたのがきっかけでした。

https://qiita.com/karamage/items/8a9c76caff187d3eb838


こちらの記事を読んで思いました。

**SwiftUI って 宣言的UI なんだから、宣言的 UI の先駆け(?)の React に倣ったらいいんじゃない？**

と。

## React と SwiftUI のコードの見た目を比較してみる

SwiftUI の書き方は、[Chakra UI](https://chakra-ui.com/docs/components) や [Native Base](https://docs.nativebase.io/?utm_source=HomePage&utm_medium=Hero_Fold&utm_campaign=NativeBase_3) を導入した React のコードと非常によく似ています。

React のコードをあまり見たことがないという方もいらっしゃるかと思いますので、ここで、軽く React と SwiftUI のコードの見た目を比較してみます。

- React
  ```tsx: Screen
    import { FC, useState } from 'react'
    import { Button, HStack, VStack, Text } from '@chakra-ui/react'

    const Screen: FC = () => {
      const [tappedCount, setTappedCount] = useState<number>(0)

      // useCallback で 書くべきですが、SwiftUI に寄せるためにあえて function で書いています。
      function countUp(): void {
        setClickedCount((previousValue) => (
          previousValue + 1
        ))
      }

      return (
        <VStack
          spacing="4"
        >
          <HStack>
            <Text>
              現在のカウント:
            </Text>

            <Text>{clickedCount}</Text>
          </HStack>

          <Button
            onClick={countUp}
          >
            カウントアップ
          </Button>
        </VStack>
      )
    }

  ```

- SwiftUI
  ```tsx: Screen
    import SwiftUI

    struct Screen: View {

        @State var tappedCount: Int = 0

        func countUp() -> Void {
            self.tappedCount += 1
        }

        var body: some View {
            VStack(
                spacing: 4
            ) {
                HStack {
                    Text("現在のカウント:")

                    Text("\(self.tappedCount)")
                }

                Button(
                    action: countUp
                ) {
                    Text("カウントアップ")
                }
            }
        }
    }


  ```

なるべくお互いの見た目が近くなるように書いてみました。

どうでしょう。
React は SwiftUI とは違い state の値を更新するのに専用の関数を使わなければならない点が違いますが、結構似ているのではないでしょうか。




## 宣言的UI の先駆け(?)の React の事例を見てみる

恥ずかしながら、すべて知っている訳では無かったので参考記事を添付します。(雑)

https://zenn.dev/morinokami/books/learning-patterns-1


## 個人的になじみ深かった Container / Presentational パターン

React の現場にいた僕にとって一番なじみ深かったのが、[Container / Presentational パターン](https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern) でした。

上記記事にも書いてあるのでざっくりですが、


- Container コンポーネント: API取得など、ビジネスロジックを担当
- Presentational コンポーネント: 原則としてビューロジックのみを担当

といった感じになります。

ちなみに、発案者は [@dan_abramov](https://twitter.com/dan_abramov) さんらしいです。


## Container / Presentational パターンを SwiftUI に導入すればいいのでは？ 

馴染み深かったこいつを SwiftUI に導入したらいいんじゃないか？と、[「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由](https://qiita.com/karamage/items/8a9c76caff187d3eb838) を見たときに思ったわけです。



## Container / Presentational パターンを SwiftUI に導入するメリット

1. １画面あたりのファイル数が 2つ
1. `@FetchRequest` を使っても罪にならない
1. `@FetchRequest` 以外にもプロパティラッパーをフル活用できる
    1. 特に `@AppStorage` を `ObservableObject` で束ねると、従来の MVVM 等のアーキテクチャだと `@ObservedObject` のネストになり、変更検知がされなかったりしていた
1. Presentational コンポーネントの Preview に、面倒な条件分岐(Preview環境かどうか)なしにモックデータを好き放題突っ込める
    - → Preview が見やすくなるので UI作成がしやすい


上記以外のメリットは参考記事と同じだと思います。



## サンプルを作ってみる


### サンプル概要

記事用にざっくりとしたサンプルを作ってみました。

完成した成果物のリンクはこちらです。

<https://github.com/Uhucream/SwiftUI-Container-Presentational-Pattern-Sample>

Container / Presentational パターンで組んだサンプルを書きたいだけなので、UI もあまりこだわっていません。

機能としては以下のようなものです。

- Core Data へのデータ追加・更新・削除

### アプリの内容

- ToDo アプリ的なやつ


<img width="200" alt="Simulator Screen Recording - iPhone 14 Pro Max - 2023-03-03 at 16.04.40.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/45c7eecb-045f-a273-ff2b-73be723580dc.gif">



### 組んでいく上で重要なこと

- ObservableObject は ViewModel などのような state を保管する用途では使いません。それだと Container / Presentational で組む意味がないのと、SwiftUI の機能を生かせないからです。(`@State`があるのに使わないのは変)
    - React のカスタムフック的な使い方をします。これは以下の記事に着想を得ました (※結局サンプルでは作る機会がありませんでした😇)


https://goodpatch-tech.hatenablog.com/entry/observableobject_as_hooks


## サンプルの解説

解説が必要そうな箇所のみ解説します。


### 作成環境

- プロジェクトは Swift Playgrounds 4 (Mac) で作成
- 編集は Xcode 14.2 (21534) で行いました



### ディレクトリ構成

#### 概要

-  ContentView の 子 View としてRootView という View を作り、それを各画面のルート View とする構成にしました。
- List の行のコンポーネントなど、画面固有のコンポーネントは画面と同じディレクトリ内に、`Components` フォルダを作成し、その中に入れる形にしました。(アーキテクチャではなく好み(?)の問題)


```ディレクトリ構成
.
├── ContentView.swift
├── Entities
│   └── CoreData
│       └── ToDo.swift
├── Extensions
│   └── CoreData
│       └── ConvenienceInitializer
│           └── ToDo+.swift
├── Package.swift
├── PersistenceController.swift
├── Scenes
│   ├── CreateToDo
│   │   ├── CreateToDoView.swift
│   │   └── CreateToDoViewContainer.swift
│   ├── EditToDo
│   │   ├── EditToDoView.swift
│   │   └── EditToDoViewContainer.swift
│   ├── Root
│   │   └── RootView.swift
│   ├── ToDoDetail
│   │   ├── ToDoDetailView.swift
│   │   └── ToDoDetailViewContainer.swift
│   └── ToDoList
│       ├── Components
│       │   └── ToDoListCard.swift
│       ├── ToDoListView.swift
│       └── ToDoListViewContainer.swift
└── ToDoApp.swift


```

#### 各画面の説明

- ToDoList: トップ画面

    <img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/a6f5af89-69dc-49e6-ab3c-1f5fcb122879.png">

- CreateToDo: ToDo 作成画面

    <img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/ce7e6d31-fb15-7717-a5df-988dc4f637f2.png">

- ToDoDetail: ToDo 詳細画面

    <img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/363a6041-fbc5-1fcc-2364-7fb42deb310d.png">

- EditToDo: ToDo 編集画面

    <img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/2fbd4bbf-23bf-8d89-1c3e-62b39042e3fd.png">




### Core Data のエンティティ

- 作ったエンティティは以下の 1つ のみ
    - ToDo: やることに関するエンティティ
        - title: ToDo のタイトル
        - memo: ToDo のメモ
        - createdAt: 作成日
        - dueAt: ToDo の期限

(※Swift Playgrounds で作ったので NSEntityDescription を自分で書く必要がありました。)


```swift: ToDo.swift

import Foundation
import CoreData

@objc(ToDo)
class ToDo: NSManagedObject {
    @NSManaged var title: String
    @NSManaged var memo: String
    @NSManaged var isDone: Bool
    @NSManaged var createdAt: Date
    @NSManaged var dueAt: Date?
}

extension ToDo: Identifiable {
    var id: UUID {
        .init()
    }

    static var entityDescription: NSEntityDescription {
        let description: NSEntityDescription = .init()

        description.name = String(describing: Self.self)
        description.managedObjectClassName = NSStringFromClass(Self.self)

        description.properties = [
            {
                $0.name = "title"
                $0.attributeType = .stringAttributeType
                $0.isOptional = false

                $0.defaultValue = "タイトル未設定"

                return $0
            }(NSAttributeDescription()),
            {
                $0.name = "memo"
                $0.attributeType = .stringAttributeType
                $0.isOptional = false

                $0.defaultValue = ""

                return $0
            }(NSAttributeDescription()),
            {
                $0.name = "isDone"
                $0.attributeType = .booleanAttributeType
                $0.isOptional = false

                $0.defaultValue = false

                return $0
            }(NSAttributeDescription()),
            {
                $0.name = "createdAt"
                $0.attributeType = .dateAttributeType
                $0.isOptional = false

                $0.defaultValue = Date.now

                return $0
            }(NSAttributeDescription()),
            {
                $0.name = "dueAt"
                $0.attributeType = .dateAttributeType
                $0.isOptional = true

                return $0
            }(NSAttributeDescription()),
        ]

        return description
    }
}


```

### ToDoList

1. まず、View を作ります。この段階では、ビジネスロジック / ビューロジック かどうかを気にせずにごちゃ混ぜに書きます。

    - `ToDoListView`
    
       - **2023/03/09 追記** 
           - `renderListCard()` 内で、`.id()` モディファイアを付け加えているのは、これがないと `ToDoListCard` の完了ボタンを押しても見た目が変わらない (=完了になっていないように見えてしまう) からです。
           - 詳細が気になる方は以下の記事をご覧ください。
             - https://qiita.com/Uhucream/items/db51185f0c203f6d4ad7

        ```swift:ToDoListView.swift
        struct ToDoListView: View {

            private var dateFormatter: DateFormatter {
              let formatter: DateFormatter = .init()

              formatter.dateStyle = .short
              formatter.timeStyle = .none

              return formatter
          }

          @Environment(\.managedObjectContext) var viewContext

            @FetchRequest(
                entity: ToDo.entity()
                sortDescriptors: [.init(keyPath: \ToDo.createdAt, ascending: false)]
            ) var createdToDos: FetchedResults<ToDo>

            @ViewBuilder
            func renderToDoCard(_ createdToDo: ToDo) -> some View {

                let currentRenderingID: String = "\(createdToDo.id)\(createdToDo.isDone)"

                ToDoListCard(
                    todo: createdToDo,
                    todoTitle: createdToDo.title,
                    dueAtDateString: dateFormatter.string(from: createdToDo.dueAt ?? .distantPast),
                    onTapMarkAsDoneButton: {
                        toggleDoneStatus(createdToDo)
                    }
                )
                .id(currentRenderingID)
            }

            func deleteTodos(_ targetsOffsets: IndexSet) -> Void {
                do {
                    let deleteTargets: [ToDo] = targetsOffsets
                        .map { targetOffset in
                            return createdToDos[targetOffset]
                        }

                    deleteTargets
                        .forEach { deleteTarget in
                            viewContext.delete(deleteTarget)
                        }


                    try viewContext.save()

                    viewContext.refreshAllObjects()
                } catch {
                    print(error)
                }
            }

            func toggleDoneStatus(_ todo: ToDo) -> Void {
                do {
                    todo.isDone.toggle()

                    try viewContext.save()

                    viewContext.refreshAllObjects()
                } catch {
                    print(error)
                }
            }

            var body: some View {
                if createdToDos.count == 0 {
                    Text("ToDo はありません")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(Color(uiColor: .systemGroupedBackground))
                        .edgesIgnoringSafeArea(.all)
                } else {
                    List {
                        Section {
                            ForEach(createdToDos, id: \.id) { todo in
                                NavigationLink(value: todo) {
                                    renderToDoCard(todo)
                                    //  .buttonStyle の指定がないと、完了ボタン押下時に行ごと反応してしまう
                                    .buttonStyle(.plain)
                                    .opacity(todo.isDone ? 0.5 : 1)
                                }
                            }
                            .onDelete(perform: deleteTodos)
                        }
                    }
                }
            }
        }
        ```

    - リストの行を描画するコンポーネント



      ```swift:ToDoListCard.swift
      struct ToDoListCard: View {
          
          @Environment(\.editMode) var editMode
          
          var isDone: Bool
          var todoTitle: String
          var dueAtDateString: String
          
          var onTapMarkAsDoneButton: (() -> Void)?
          
          var body: some View {
              HStack {
                  if !(editMode?.wrappedValue.isEditing ?? false) {
                      Button(
                          action: {
                              onTapMarkAsDoneButton?()
                          }
                      ) {
                          ZStack {
                              if isDone {
                                  Image(systemName: "checkmark.circle")
                                      .resizable()
                                      .aspectRatio(contentMode: .fit)
                                      .symbolVariant(.fill)

                              } else {
                                  Image(systemName: "circlebadge")
                                      .resizable()
                                      .aspectRatio(contentMode: .fit)
                              }
                          }
                          .foregroundColor(isDone ? .accentColor : .gray)
                          .frame(maxHeight: 24)
                      }
                  }
                  
                  VStack(alignment: .leading, spacing: 4) {
                      Text(todoTitle)
                      
                      Text("期日: \(dueAtDateString)")
                          .font(.caption)
                  }
              }
          }
      }
      ```

2. 1. で作った View から、ビジネスロジックを切り出した Container を作成して分割し、View は表示ロジックのみを担当する形にします。

    - Container (ToDoListViewContainer)

        ```swift: ToDoListViewContainer.swift
        struct ToDoListViewContainer: View {

            @Environment(\.managedObjectContext) var viewContext

            @FetchRequest(
                entity: ToDo.entity(),
                sortDescriptors: [.init(keyPath: \ToDo.createdAt, ascending: false)]
            ) var createdToDos: FetchedResults<ToDo>

            @State private var shouldShowCreateToDoViewSheet: Bool = false

            func deleteTodos(_ targetsOffsets: IndexSet) -> Void {
                do {
                    let deleteTargets: [ToDo] = targetsOffsets
                        .map { targetOffset in
                            return createdToDos[targetOffset]
                        }

                    deleteTargets
                        .forEach { deleteTarget in
                            viewContext.delete(deleteTarget)
                        }


                    try viewContext.save()

                    viewContext.refreshAllObjects()
                } catch {
                    print(error)
                }
            }

            func toggleDoneStatus(_ todo: ToDo) -> Void {
                do {
                    todo.isDone.toggle()

                    try viewContext.save()

                    viewContext.refreshAllObjects()
                } catch {
                    print(error)
                }
            }

            func showCreateToDoViewSheet() -> Void {
                self.shouldShowCreateToDoViewSheet = true
            }

            var body: some View {
                ToDoListView(
                    createdToDos: createdToDos
                )
                .onDeleteToDos(action: deleteTodos)
                .onTapMarkAsDoneButton(action: toggleDoneStatus)
                .toolbar {
                    ToolbarItemGroup(placement: .primaryAction) {
                        Button(
                            action: showCreateToDoViewSheet
                        ) {
                            Image(systemName: "plus")
                        }

                        if createdToDos.count > 0 {
                            EditButton()
                        }
                    }
                }
                .navigationDestination(for: ToDo.self) { todo in
                    ToDoDetailViewContainer(todo: todo)
                }
                .navigationTitle("ToDo")
                .sheet(isPresented: $shouldShowCreateToDoViewSheet) {
                    NavigationView {
                        CreateToDoViewContainer()
                            .navigationBarTitleDisplayMode(.inline)
                    }
                }
            }
        }
        ```

    - Presentational (ToDoListView)

      - 行削除時の処理や、完了ボタン押下時に Core Data のデータを変更するのは Container の役割なので、それらはコールバックで親に伝播させます。
      - Preview でデータを流し込みやすくするため、`cretedToDos` の型を `[ToDo]` としたいところだったのですが、それだと以下のように型エラーが出てしまうため、ジェネリクスを使って解決しています。

        <img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224212/9a0fc90c-b30d-429f-ea81-4d9938f50ad4.png">

        - 解決にあたって、以下のリンクを参考にしました。

            <https://stackoverflow.com/a/61495490/18698351>

    ```swift:ToDoListView.swift
    struct ToDoListView<ToDosResults: RandomAccessCollection>: View where ToDosResults.Element ==  ToDo {

        private var dateFormatter: DateFormatter {
            let formatter: DateFormatter = .init()

            formatter.dateStyle = .short
            formatter.timeStyle = .none

            return formatter
        }

        private var onTapMarkAsDoneButtonCallback: ((ToDo) -> Void)?

        private var onDeleteToDosCallback: ((IndexSet) -> Void)?

        var createdToDos: ToDosResults

        @ViewBuilder
        func renderToDoCard(_ createdToDo: ToDo) -> some View {
        
            let currentRenderingID: String = "\(createdToDo.id)\(createdToDo.isDone)"
        
            ToDoListCard(
                todo: createdToDo,
                todoTitle: createdToDo.title,
                dueAtDateString: dateFormatter.string(from: createdToDo.dueAt ?? .distantPast),
                onTapMarkAsDoneButton: {
                    toggleDoneStatus(createdToDo)
                }
            )
            .id(currentRenderingID)
        }

        // このように書くと、 SwiftUI のモディファイア風にコールバックを親から受け取れる
        func onTapMarkAsDoneButton(action: @escaping (ToDo) -> Void) -> Self {
            var view = self

            view.onTapMarkAsDoneButtonCallback = action

            return view
        }

        func onDeleteToDos(action: @escaping (IndexSet) -> Void) -> Self {
            var view = self

            view.onDeleteToDosCallback = action

            return view
        }

        var body: some View {
            if createdToDos.count == 0 {
                Text("ToDo はありません")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(uiColor: .systemGroupedBackground))
                    .edgesIgnoringSafeArea(.all)
            } else {
                List {
                    Section {
                        ForEach(createdToDos, id: \.id) { todo in
                            NavigationLink(value: todo) {
                                renderToDoCard(todo)
                                    //  .buttonStyle の指定がないと、完了ボタン押下時に行ごと反応してしまう
                                    .buttonStyle(.plain)
                                    .opacity(todo.isDone ? 0.5 : 1)
                            }
                        }
                        .onDelete(perform: onDeleteToDosCallback)
                    }
                }
                .listStyle(.insetGrouped)
            }
        }
    }
    ```

### ToDoDetail (詳細画面)

要領は ToDoList と同じなので、コードを貼って軽く解説する程度のみにします。

- Container

  - プロパティ `todo` を `@ObservedObject` としているのは、`ToDo` エンティティを `@ObservedObject` として受け取らないとデータが更新された際、View に変更が反映されないからです。

      <https://software.small-desk.com/development/2020/04/14/coredata-fetchrequest-passas-binding/>

  ```swift:ToDoDetailViewContainer.swift
  struct ToDoDetailViewContainer: View {

      @ObservedObject var todo: ToDo

      var body: some View {
          ToDoDetailView(
              title: $todo.title,
              memo: $todo.memo,
              dueAt: .init(
                  get: {
                      return todo.dueAt ?? .distantPast
                  },
                  set: { _ in

                  }
              )
          )
          .navigationTitle("詳細")
          .navigationBarTitleDisplayMode(.inline)
      }
  }
  ```

- Presentational

    ```swift:ToDoDetailView.swift
    struct ToDoDetailView: View {

        @Binding var title: String
        @Binding var memo: String

        @Binding var dueAt: Date

        var body: some View {
            List {
                Section {
                    Text(title)
                        .font(.title)
                        .fontWeight(.bold)
                }
                .listRowBackground(Color(uiColor: .systemGroupedBackground))

                Section {
                    HStack {
                        Text("期日:")

                        Text(dueAt, style: .date)
                    }
                }
                .listRowBackground(Color(uiColor: .systemGroupedBackground))

                Section {
                    VStack(alignment: .leading) {
                        Text("メモ")
                            .font(.caption)

                        RoundedRectangle(cornerRadius: 4)
                            .fill(Color(uiColor: .systemFill))
                            .frame(maxWidth: .infinity)
                            .aspectRatio(16 / 9, contentMode: .fit)
                            .overlay {
                                Text(memo)
                                    .padding()
                                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                            }
                    }
                }
                .listRowBackground(Color(uiColor: .systemGroupedBackground))
            }
        }
    }
    ```

## まとめ

コード解説をだいぶ端折ってしまいましたが、いかがだったでしょうか。

やはり Preview が使いやすかったり、プロパティラッパーがフル活用できるのは大きなメリットなのではないでしょうか。

個人的には、TCA の敷居が少し高く感じるので、そんなプロジェクトにもどんどん広まっていってほしいなと思います。

あと、あわよくば、React Native などを採用しているプロジェクトにも、SwiftUI は思っているより学習コストは高くないんだよーということが広まって欲しいですｗ

Swift 界隈に足を突っ込み始めたばかりなので、間違っている箇所もあるかもしれませんが、その際はぜひぜひコメントや編集リクエストの方よろしくお願いします☺️



## 最後に

実際に Container / Presentational パターンを採用したアプリを作ってみたのでもし良ければ見ていってください☺️

https://github.com/Uhucream/Simple-ID-Photo