[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その7:TODO アプリとして改良第1弾)

SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

前回までで、以下のような TODO アプリができました。

MyTODOアプリ
MyTODOアプリ

今回は、以下を実装していきます。

  • チェックされた要素は非表示にする
  • メインビューリストで、「未実行 TODOItem」「実行済み TODOItem」を選択表示できるようにする

リスト表示対象を isDone == false に限定する

まずは、テストから作成していきます。

テストの作成

3つの要素を作成し、1、2番目の要素にチェックを入れて、リストが適切に更新されるかをテストします。

test_filterTodoItemsOnlyUndone_addAndCheck_todoItemsShouldBeUpdated

    func test_filterTodoItemsOnlyUndone_addAndCheck_todoItemsShouldBeUpdated() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        
        // (1)
        // add
        mainPage.addButtonTap().typeTitle("Item0").typeDetail("Item0Detail").tapOk()
        mainPage.addButtonTap().typeTitle("Item1").typeDetail("Item1Detail").tapOk()
        mainPage.addButtonTap().typeTitle("Item2").typeDetail("Item2Detail").tapOk()
        XCTAssertEqual(mainPage.todoListRows.count, 3)
        
        // (2)
        _ = mainPage.itemPageObjectAtIndex(index: 0).toggleIsDone()
        XCTAssertEqual(mainPage.todoListRows.count, 2)

        _ = mainPage.itemPageObjectAtIndex(index: 1).toggleIsDone()
        XCTAssertEqual(mainPage.todoListRows.count, 1)
    }
コード解説
  1. 先に作成した PageObject を使うと、UI 経由の要素作成もこんなに簡単に書けます
  2. 要素をタップし、isDone フラグを 設定し、表示リストが更新されることをテストしてます

現在は、すべての TODOItem をリスト表示しているので、上記のテストは、failed になります。

Model の修正

現在の List に表示される要素は、TODOItemStore.items が返す要素です。

全ての要素を取得する方法は今後も必要ですので、指定されたフィルターを適用した要素を返すメソッドを作ることにします。

デフォルトは、フィルターなしです。

TODOItemStore

struct TODOItemStore {
    // .. snip ..
    func filteredItems(_ predicate: NSPredicate? = nil) -> [TODOItem] {
        var items:[TODOItem] = []
        let request:NSFetchRequest = CDTODOItem.fetchRequest()
        if let predicate = predicate {
            request.predicate = predicate
        }
        do {
            items = try container.viewContext.fetch(request).map(TODOItem.init)
        } catch {
            TODOItemStore.logger.error("error in fetching data from coredata")
        }
        return items
    }
}

これまでは、TODOItemStore.items を使って、ViewModel から View に渡すデータを保存していましたが、切り替える必要があります。
具体的には、MyTODOViewModel 中で、要素を修正したときに、"todoItems = todoItemStore.items" としていたところを、" todoItems = todoItemStore.filteredItems(NSPredicate(format: "isDone == false")) "とする必要があります。

これまでのテストを確認すると ほとんどパスするのですが、以下のテストで fail することに気づきます。

test_addOneElement_withSpecifiedData_allDataShouldBeDisplayedCorrectly(修正前)

    func test_addOneElement_withSpecifiedData_allDataShouldBeDisplayedCorrectly() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)

        // create not-done todoitem
        let newItemPage = mainPage.addButtonTap()
        newItemPage
            .typeTitle("TypedItemTitle")
            .typeDetail("TypedItemDetail")
            .tapOk()
        
        let cellPage = mainPage.itemPageObjectAtIndex(index: 0)
        XCTAssertEqual(cellPage.itemTitle, "TypedItemTitle")
        XCTAssertEqual(cellPage.itemDetail, "TypedItemDetail")
        XCTAssertEqual(cellPage.itemIsDoneImageTitle, "square")

        _ = cellPage.toggleIsDone()
        XCTAssertEqual(cellPage.itemIsDoneImageTitle, "checkmark.square")
    }
TODOItem の各プロパティが正しく表示されているかを確認するテストに、TODOItem をタップすることで、チェックマークが入るか確認するテストも入れていたのですが、チェックされると非表示になるので、fail するようになったということです。

別のテストでチェックマークを確認することにして、このテストからは、削除します。

test_addOneElement_withSpecifiedData_allDataShouldBeDisplayedCorrectly(修正前)

    func test_addOneElement_withSpecifiedData_allDataShouldBeDisplayedCorrectly() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)

        // create not-done todoitem
        let newItemPage = mainPage.addButtonTap()
        newItemPage
            .typeTitle("TypedItemTitle")
            .typeDetail("TypedItemDetail")
            .tapOk()
        
        let cellPage = mainPage.itemPageObjectAtIndex(index: 0)
        XCTAssertEqual(cellPage.itemTitle, "TypedItemTitle")
        XCTAssertEqual(cellPage.itemDetail, "TypedItemDetail")
        XCTAssertEqual(cellPage.itemIsDoneImageTitle, "square")

//        _ = cellPage.toggleIsDone()
//        XCTAssertEqual(cellPage.itemIsDoneImageTitle, "checkmark.square")
    }

# 削除部分をわかりやすくするためにコメントアウトしていますが、削除してしまって問題ありません。

これで、TODOItem はチェックされると非表示になり、テストも全てパスするようになりました。

ここまでで作ったアプリは、以下のような動作をします。

MyTODOAsOf20201215.m4v

リストに表示する TODOItem を選択可能に

次に、以下を実装します。

  • メインビューリストで、「未実行 TODOItem」「実行済み TODOItem」を選択表示できるようにする

現在の仕様では、TODOItem にチェックをいれてしまうと、それ以降 表示することができなくなってしまいます。

実施済み TODOItem と 未実施 TODOItem のどちらを表示するかを設定できるようにして、チェックを入れた後にも表示する方法を作ります。

実装の方針としては、ViewModel に、どちらを表示するか表すフラグを保持することにします。

UI 的には、NavigationBar に、ボタンを配置して、切り替えられるようにします。

テストの作成 (Model テスト)

Model, View のそれぞれでテストすることにします。

まずは、テストを書きます。

モデルに要素を作成し、チェックを操作して、返されるリストが正しいことをテストします。

test_checkFilteredList_createAndCheck_listShouldNotContainCheckedItem

    func test_checkFilteredList_createAndCheck_listShouldNotContainCheckedItem() throws {
        let model = TODOItemStore(true)
        XCTAssertEqual(model.items.count, 0)
        _ = model.createTODOItem("item0", "item0 detail")
        _ = model.createTODOItem("item1", "item1 detail")
        _ = model.createTODOItem("item2", "item2 detail")

        let predicate = NSPredicate(format: "isDone == false")
        XCTAssertEqual(model.filteredItems(nil).count, 3)
        XCTAssertEqual(model.filteredItems(predicate).count, 3)

        model.toggleTODOItem(model.items[1])
        XCTAssertEqual(model.filteredItems(nil).count, 3)
        XCTAssertEqual(model.filteredItems(predicate).count, 2)

        model.toggleTODOItem(model.items[2])
        XCTAssertEqual(model.filteredItems(nil).count, 3)
        XCTAssertEqual(model.filteredItems(predicate).count, 1)

        model.toggleTODOItem(model.items[1])
        XCTAssertEqual(model.filteredItems(nil).count, 3)
        XCTAssertEqual(model.filteredItems(predicate).count, 2)
    }

モデルのテストは、これまでのコードでパスします。

次に、View のテストを作成します。

テスト作成 (View, ViewModel)

アプリ設計として、ViewModel が 何を表示するかのフラグを持ち、そのフラグに沿う NSPredicate を提供します。View からは、ViewModel のフラグを操作し、Model は ViewModel の提供する NSPredicate を使ってフィルターすることを想定します。

動作としては、View の切り替えボタンが押された時に、リストの表示が切り替わるということになります。

View のテストとしては、ボタン切り替えで、その時に表示されるべき要素が表示されるかをテストします。

test_filterTodoItems_toggleFilter_todoItemsShouldBeUpdated

    func test_filterTodoItems_toggleFilter_todoItemsShouldBeUpdated() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        
        // add
        // (1)
        sleep(1)
        // (2)
        mainPage.addButtonTap().typeTitle("Item0").typeDetail("Item0Detail").tapOk()
        // (3)
        _ = mainPage.itemPageObjectAtIndex(index: 0).toggleIsDone()
        // (4)
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        // (5)
        mainPage.toggleFilter()
        XCTAssertEqual(mainPage.todoListRows.count, 1)
        // (6)
        mainPage.toggleFilter()
        XCTAssertEqual(mainPage.todoListRows.count, 0)
    }
コード解説
  1. UITest の動作が安定しなかったので、1秒待つようにしました
  2. テスト用の要素を作成しています
  3. 要素のチェックマークをつけています
  4. リストの行が 非表示になっていることをテスト
  5. リストの表示を切り替えると、1行表示されることをテスト
  6. 再度 表示を切り替えると、非表示になることをテスト

テストコードがコンパイルできないので、TODOListPageObject に toggle するボタンを追加します。

example

class TODOListPageObject: PageObject {
    // .. snip ..
    private var todoList: XCUIElement { app.tables["TODOList"]}
    var todoListRows:XCUIElementQuery { todoList.cells }
    private var navBar: XCUIElement { app.navigationBars["MyTODO"]}
    private var addButton: XCUIElement { navBar.buttons.element(boundBy: 1) }
    private var editButton: XCUIElement { navBar.buttons.element(boundBy: 0) }
    private var doneButton: XCUIElement { navBar.buttons.element(boundBy: 0) }
    // (1)
    private var toggleButton: XCUIElement { navBar.buttons.element(boundBy: 2)}

    // .. snip ..
    // (2)
    func toggleFilter() -> TODOListPageObject {
        toggleButton.tap()
        return self
    }
}

コード解説
  1. NavigationBar の一番右に追加されると仮定して、Toggle ボタンを取得できるようにしました
  2. ボタンを使って、filter を toggle する操作をメソッドとして作りました

テストコードのコンパイルはできるようになりました。

ViewModel の実装

先ほど想定した設計「ViewModel が 何を表示するかのフラグを持ち、そのフラグに沿う NSPredicate を提供する。View からは、ViewModel のフラグを操作し、Model は ViewModel の提供する NSPredicate を使ってフィルターする」を実装していきます。

MyTODOViewModel 修正後

class MyTODOViewModel : ObservableObject {
    var todoItemStore: TODOItemStore
    @Published var todoItems:[TODOItem] = []
    // (1)
    @Published var showUndone:Bool = true
    // (2)
    var predicate: NSPredicate {
        if showUndone {
            return NSPredicate(format: "isDone == false")
        }
        return NSPredicate(format: "isDone == true")
    }

    // .. snip ..

    // (3)
    func toggleDisplay() {
        showUndone.toggle()
        // (4)
        self.todoItems = self.todoItemStore.filteredItems(predicate)
    }
}
コード解説
  1. undone な TODOItem を表示するかのフラグ
  2. フラグに応じて、NSPredicate を返します
  3. リストの表示を切り替えるメソッド。View から呼ばれる予定
  4. 表示に変更が発生する時はこのように、self.todoItems を更新します

View の実装

View のテストの fail の理由は、ボタンが未配置のためです。リストの表示を切り替えるボタンをつけます。

ContentView

struct ContentView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    @State private var showNewItemView = false

    var body: some View {
        NavigationView {
            List {
            // .. snip ..
            }
            .accessibility(identifier: "TODOList")
            .navigationTitle("MyTODO")
            .toolbar {
                // .. snip ..
                ToolbarItem(placement: .navigationBarTrailing) {
                    HStack {
                        Button(action: {
                            showNewItemView.toggle()
                        }) {
                            Label("Add Item", systemImage: "plus")
                        }
                        // (1)
                        Button(action: {
                            viewModel.toggleDisplay()
                        }) {
                            // (2)
                            Label("Toggle", systemImage:viewModel.showUndone ? "checkmark.square" : "square")
                        }
                    }
                }
            }
        }
        // .. snip ..
コード解説
  1. 新規追加ボタンの右側に切り替えボタンを追加します
  2. どちらを表示しているモードかに応じて表示するアイコンを切り替えます

先ほどの UITest を実行するとパスすることが確認できます。

次回は、以下を予定してます。

  • リファクタリング
  • 既存の TODOItem を編集できるようにする

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です