RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

メモアプリ開発でSwiftUIによるMVVMを学んでみた

f:id:tech-rakus:20210331095856p:plain

はじめに

こんにちは、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 で端末に永続化します。

それぞれの関係性は以下の図の通りです。

f:id:FM_Harmony:20210330114410p:plain

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はMemoListViewMemoRowViewに分けており、
前者がメモの一覧を、後者が登録したメモの表示を行っています。

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()
        }
    }
}

デモ

作成したアプリのデモ動画です。
概要に記載した操作が行えることを確認いただけるかと思います。

f:id:FM_Harmony:20210330115553g:plain

おわりに

メモアプリ作成を通じて、SwiftUI、MVVMの知見が得られました。

元々、業務ではMVCによるiOSアプリ開発を行っていましたが、
SwiftUIによる実装は画面の再描画を意識する必要が無くなり、処理が見通しやすくなったと感じました。

SwiftUIについては学習し始めたばかりなので、これからもキャッチアップを進めていこうと思います。

参考


  • エンジニア中途採用サイト
    ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
    ご興味ありましたら是非ご確認をお願いします。
    20210916153018
    https://career-recruit.rakus.co.jp/career_engineer/

  • カジュアル面談お申込みフォーム
    どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
    以下フォームよりお申込みください。
    forms.gle

  • イベント情報
    会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.