LoginSignup
13
11

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

Last updated at Posted at 2023-03-03

2023/03/09 更新

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

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

僕について

  1. 新卒でフロントエンドをメインに React と TypeScript を 1年以上 経験

    • React Native も書いたことがあります。
  2. 趣味(?)で 2022年から SwiftUI の個人開発アプリを作り始めたり始めなかったり

この記事を書いた経緯

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

VIPER アーキテクチャ辛い

それまで、僕は こちらの記事 を参考に個人開発アプリで VIPER アーキテクチャを採用していました。

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

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

等々...

VIPER やめようぜ

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

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

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

と。

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

SwiftUI の書き方は、Chakra UINative Base を導入した React のコードと非常によく似ています。

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

  • React

    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

    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 の事例を見てみる

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

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

React の現場にいた僕にとって一番なじみ深かったのが、Container / Presentational パターン でした。

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

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

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

ちなみに、発案者は @dan_abramov さんらしいです。

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

馴染み深かったこいつを SwiftUI に導入したらいいんじゃないか?と、「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 を見たときに思ったわけです。

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

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

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

サンプルを作ってみる

サンプル概要

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

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

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

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

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

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

アプリの内容

  • ToDo アプリ的なやつ
Simulator Screen Recording - iPhone 14 Pro Max - 2023-03-03 at 16.04.40.gif

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

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

サンプルの解説

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

作成環境

  • プロジェクトは 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: トップ画面

    image.png
  • CreateToDo: ToDo 作成画面

    image.png
  • ToDoDetail: ToDo 詳細画面

    image.png
  • EditToDo: ToDo 編集画面

    image.png

Core Data のエンティティ

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

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

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 の完了ボタンを押しても見た目が変わらない (=完了になっていないように見えてしまう) からです。
        • 詳細が気になる方は以下の記事をご覧ください。
      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)
                      }
                  }
              }
          }
      }
      
    • リストの行を描画するコンポーネント

      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)
                  }
              }
          }
      }
      
    1. で作った View から、ビジネスロジックを切り出した Container を作成して分割し、View は表示ロジックのみを担当する形にします。
    • Container (ToDoListViewContainer)

      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] としたいところだったのですが、それだと以下のように型エラーが出てしまうため、ジェネリクスを使って解決しています。

        image.png
    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

    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

    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 は思っているより学習コストは高くないんだよーということが広まって欲しいですw

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

最後に

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

13
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
11