はじめに
こんにちは、id:FM_Harmonyです。
iOSアプリの開発プロジェクトに参画して1年が経ち、自身の中でiOS開発の土台が固まり、
ようやく様々な技術をキャッチアップ出来るようになりました。
そこで、今回はいま学習中のSwiftUIについて、MVVMの理解もかねて記事にしてみました。
SwiftUIについては公開されたのが2019年と新しい技術ですので、
この記事がこれから学習を始める方の参考になれば幸いです。
目次
MVVMとは
ソフトウェアアーキテクチャの一つです。
以下、Wikipediaより引用します。
Model-View-ViewModel (MVVM、モデル・ビュー・ビューモデル) はUIを持つソフトウェアに適用されるソフトウェアアーキテクチャの一種である MVVMはソフトウェアをModel・View・ViewModelの3要素に分割する。プレゼンテーションとドメインを分離し(V-VM / M)また宣言的Viewを分離し状態とマッピングを別にもつ(V / VM) (中略) ### Model アプリケーションのドメイン(問題領域)を担う、そのアプリケーションが扱う領域のデータと手続き(ビジネスロジック - ショッピングの合計額や送料を計算するなど)を表現する要素である。 (中略) ### View View(ビュー)はアプリケーションの扱うデータをユーザーが見るのに適した形で表示し、ユーザーからの入力を受け取る要素である。すなわちユーザインタフェースの入出力が責務である。 (中略) ### ViewModel ViewModel(ビューモデル)はViewを描画するための状態の保持と、Viewから受け取った入力を適切な形に変換してModelに伝達する役目を持つ。すなわちViewとModelの間の情報の伝達と、Viewのための状態保持のみを役割とする要素である。 実装ではしばしば、Viewとの通信はデータバインディング機構のような仕組みを通じて行う。その場合ViewModelの変更は開発者から見て自動的にViewに反映される。
私は以下のように理解しています。
- View
- ユーザからの入力をViewModelに伝える
- ViewModelの変更を画面に出力する
- ViewModel
- Viewから受け取った入力でModelを変更する
- Modelの変更を自身に反映する
- Model
- WebやDBにあるデータにアクセスする
- ViewModelによる変更を永続化する
SwiftUIとは
iOS向けのUIフレームワークで、iOS13以降で利用することができます。
宣言的に部品を記述することができるのが特徴で、
Combineフレームワークによるイベントの通知、受信と組み合わせて、
View - ViewModel間のデータバインディングを実現できます。
※CombineフレームワークもiOS13以降で利用することができます
個人的な感想ですが、SwiftUIを用いることで、
簡潔なコードでMVVMパターンによるアプリ実装を行えると思います。
アプリの概要
MVVM、SwiftUIの学習として、メモアプリを作ってみました。
大まかな仕様は以下の通りです。
- 1行のメモを追加できる
- 登録したメモは登録日の新しい順に表示する
- メモを選んで削除できる
- メモをすべて削除できる
実装
それでは実装内容の紹介に移ります。
今回はメモの一覧画面、メモのViewModel、メモのモデルを作成します。
また、登録したメモは Realm で端末に永続化します。
それぞれの関係性は以下の図の通りです。
MVVMに従い、ユーザの入力によりView - ViewModel間でイベント通知が行われ、
画面の再描画や永続化処理の呼び出しが自動的に行われます。
Modelの実装
具体的な実装内容の紹介に移ります。
まずは、Modelの実装です。
今回はModelに以下の役割を持たせています。
- Realmからのデータ取得
- Realmへの永続化処理
import Foundation import RealmSwift class Memo: Object, Identifiable { @objc dynamic var text = "" @objc dynamic var postedDate = Date() } extension Memo { private static var config = Realm.Configuration(schemaVersion: 1) private static var realm = try! Realm(configuration: config) static func findAll() -> Results<Memo> { realm.objects(self) } static func add(_ memo: Memo) { try! realm.write { realm.add(memo) } } static func delete(_ memo: Memo) { try! realm.write { realm.delete(memo) } } static func delete(_ memos: [Memo]) { try! realm.write { realm.delete(memos) } } }
ViewModelの実装
次はViewModelの実装です。
出力としてViewに表示するメモの一覧を、入力としてメモに登録するテキスト、削除するメモ、
全削除の処理が行われたかという状態を持たせています。
各プロパティには@Published
を付けることで、値の変更が行われた際に、
変更されたことを通知できるようにしてあります。
また、AnyCancellable型のプロパティを持たせることで、
@Publish
なプロパティの変更が行われた際に、ViewModelで自動的に永続化処理が始まるようにしています。
import Foundation import Combine class MemoViewModel: ObservableObject { @Published private(set) var memos: [Memo] = Array(Memo.findAll()) @Published var memoTextField = "" @Published var deleteMemo: Memo? @Published var isDeleteAllTapped = false private var addMemoTask: AnyCancellable? private var deleteMemoTask: AnyCancellable? private var deleteAllMemoTask: AnyCancellable? init() { addMemoTask = self.$memoTextField .sink() { text in guard !text.isEmpty else { return } let memo = Memo() memo.text = text self.memos.append(memo) Memo.add(memo) } deleteMemoTask = self.$deleteMemo .sink() { memo in guard let memo = memo else { return } if let index = self.memos.firstIndex(of: memo) { self.memos.remove(at: index) Memo.delete(memo) } } deleteAllMemoTask = self.$isDeleteAllTapped .sink() { isDeleteAllTapped in if (isDeleteAllTapped) { Memo.delete(self.memos) self.memos.removeAll() self.isDeleteAllTapped = false } } } }
Viewの実装
最後にViewの実装です。
ViewはMemoListView
とMemoRowView
に分けており、
前者がメモの一覧を、後者が登録したメモの表示を行っています。
MemoListViewではViewModelで@Published
を付けたプロパティを受け取り、
Listを再描画できるようにしています。
また、ユーザ操作により、@Published
を付けたプロパティの変更を行い、
プロパティ変更の通知からViewModelの処理が自動的に始まるようにしています。
import SwiftUI // MARK: MemoListView struct MemoListView: View { @ObservedObject var viewModel = MemoViewModel() @State private var isMemoTextFieldPresented = false @State private var isDeleteAlertPresented = false @State private var isDeleteAllAlertPresented = false @State private var memoTextField = "" var body: some View { NavigationView { VStack { if (isMemoTextFieldPresented) { TextField("メモを入力してください", text: $memoTextField) .textFieldStyle(DefaultTextFieldStyle()) .keyboardType(.asciiCapable) } List { ForEach(viewModel.memos.sorted { $0.postedDate > $1.postedDate }) { memo in HStack { MemoRowView(memo: memo) Spacer() // Buttonにすると行全体にタップ判定がついてしまったので、Text.onTapGestureを代わりに使っている Text("削除").onTapGesture { isDeleteAlertPresented.toggle() } .padding() .foregroundColor(.white) .background(Color.red) } .alert(isPresented: $isDeleteAlertPresented) { Alert(title: Text("警告"), message: Text("メモを削除します。\nよろしいですか?"), primaryButton: .cancel(Text("いいえ")), secondaryButton: .destructive(Text("はい")) { viewModel.deleteMemo = memo } ) } } } } .navigationTitle("メモの一覧") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("全削除") { isDeleteAllAlertPresented.toggle() } .disabled(viewModel.memos.isEmpty) } ToolbarItem(placement: .navigationBarTrailing) { Button("追加") { if (isMemoTextFieldPresented) { viewModel.memoTextField = memoTextField memoTextField = "" } isMemoTextFieldPresented.toggle() }.disabled(isMemoTextFieldPresented && memoTextField.isEmpty) } } .alert(isPresented: $isDeleteAllAlertPresented) { Alert(title: Text("警告"), message: Text("全てのメモを削除します。\nよろしいですか?"), primaryButton: .cancel(Text("いいえ")), secondaryButton: .destructive(Text("はい")) { viewModel.isDeleteAllTapped = true } ) } } } } // MARK: MemoRowView struct MemoRowView: View { var memo: Memo var body: some View { VStack(alignment: .leading) { Text(formatDate(memo.postedDate)) .font(.caption) .fontWeight(.bold) Text(memo.text) .font(.body) } } func formatDate(_ date : Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .long formatter.timeStyle = .medium formatter.locale = Locale(identifier: "ja_JP") return formatter.string(from: date) } }
メイン部分ではMemoListView
を作成し、アプリ起動時にメモの一覧が表示されるようにしています。
import SwiftUI @main struct DemoApplicationApp: App { var body: some Scene { WindowGroup { MemoListView() } } }
デモ
作成したアプリのデモ動画です。
概要に記載した操作が行えることを確認いただけるかと思います。
おわりに
メモアプリ作成を通じて、SwiftUI、MVVMの知見が得られました。
元々、業務ではMVCによるiOSアプリ開発を行っていましたが、
SwiftUIによる実装は画面の再描画を意識する必要が無くなり、処理が見通しやすくなったと感じました。
SwiftUIについては学習し始めたばかりなので、これからもキャッチアップを進めていこうと思います。
参考
- Model View ViewModel - Wikipedia
- SwiftUIの概要 - Xcode - Apple Developer
- iOS13.3 @Publishedでの値更新からsinkが呼ばれなくなった?(ミス解決) - Qiita
エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
https://career-recruit.rakus.co.jp/career_engineer/カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.comラクスDevelopers登録フォーム
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!
◆TECH PLAY
techplay.jp
◆connpass
rakus.connpass.com