2023/03/09 更新
本記事のサンプルアプリの解説で、ToDoListCard
の表示が更新されないので Core Data のエンティティをそのまま受け取る実装にしたと書いていましたが、そうしなくても再レンダリングがかかってくれる手法を発見したので修正しました!
理屈としては、以下の記事と同じ理屈です。
僕について
-
新卒でフロントエンドをメインに React と TypeScript を 1年以上 経験
- React Native も書いたことがあります。
-
趣味(?)で 2022年から SwiftUI の個人開発アプリを作り始めたり始めなかったり
この記事を書いた経緯
以前から書こうとは思っていたのですが、先日 Twitter に投稿したツイートが思いの外反響(?)があったので書くことを決意しました👶🏻
VIPER アーキテクチャ辛い
それまで、僕は こちらの記事 を参考に個人開発アプリで VIPER アーキテクチャを採用していました。
しかし、途中から以下のような違和感を感じ始め、苦しみました。
- Core Data の
@FetchRequest
プロパティラッパーが使えない (使うと依存関係がぐちゃぐちゃになる) - 一つの画面を作るのに 4つ もファイルが必要
- Router いるのか問題
等々...
VIPER やめようぜ
そう思い始めたのは昨年、こちらの記事を見かけたのがきっかけでした。
こちらの記事を読んで思いました。
SwiftUI って 宣言的UI なんだから、宣言的 UI の先駆け(?)の React に倣ったらいいんじゃない?
と。
React と SwiftUI のコードの見た目を比較してみる
SwiftUI の書き方は、Chakra UI や Native Base を導入した React のコードと非常によく似ています。
React のコードをあまり見たことがないという方もいらっしゃるかと思いますので、ここで、軽く React と SwiftUI のコードの見た目を比較してみます。
-
React
Screenimport { 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
Screenimport 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画面あたりのファイル数が 2つ
-
@FetchRequest
を使っても罪にならない -
@FetchRequest
以外にもプロパティラッパーをフル活用できる- 特に
@AppStorage
をObservableObject
で束ねると、従来の MVVM 等のアーキテクチャだと@ObservedObject
のネストになり、変更検知がされなかったりしていた
- 特に
- Presentational コンポーネントの Preview に、面倒な条件分岐(Preview環境かどうか)なしにモックデータを好き放題突っ込める
- → Preview が見やすくなるので UI作成がしやすい
上記以外のメリットは参考記事と同じだと思います。
サンプルを作ってみる
サンプル概要
記事用にざっくりとしたサンプルを作ってみました。
完成した成果物のリンクはこちらです。
https://github.com/Uhucream/SwiftUI-Container-Presentational-Pattern-Sample
Container / Presentational パターンで組んだサンプルを書きたいだけなので、UI もあまりこだわっていません。
機能としては以下のようなものです。
- Core Data へのデータ追加・更新・削除
アプリの内容
- ToDo アプリ的なやつ
組んでいく上で重要なこと
- 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
各画面の説明
Core Data のエンティティ
- 作ったエンティティは以下の 1つ のみ
- ToDo: やることに関するエンティティ
- title: ToDo のタイトル
- memo: ToDo のメモ
- createdAt: 作成日
- dueAt: ToDo の期限
- ToDo: やることに関するエンティティ
(※Swift Playgrounds で作ったので NSEntityDescription を自分で書く必要がありました。)
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
-
まず、View を作ります。この段階では、ビジネスロジック / ビューロジック かどうかを気にせずにごちゃ混ぜに書きます。
-
ToDoListView
-
2023/03/09 追記
-
renderListCard()
内で、.id()
モディファイアを付け加えているのは、これがないとToDoListCard
の完了ボタンを押しても見た目が変わらない (=完了になっていないように見えてしまう) からです。 - 詳細が気になる方は以下の記事をご覧ください。
-
ToDoListView.swiftstruct 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) } } } } }
-
2023/03/09 追記
-
リストの行を描画するコンポーネント
ToDoListCard.swiftstruct 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) } } } }
-
-
- で作った View から、ビジネスロジックを切り出した Container を作成して分割し、View は表示ロジックのみを担当する形にします。
-
Container (ToDoListViewContainer)
ToDoListViewContainer.swiftstruct 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)
ToDoListView.swiftstruct 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/
ToDoDetailViewContainer.swiftstruct 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.swiftstruct 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 パターンを採用したアプリを作ってみたのでもし良ければ見ていってください☺️