[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その9:TODOItem を編集可能に)

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

環境&対象

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

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

既存 TOODItem の編集

既存の TODOItem を編集できるようにする

モデルのアップデート

既存の TODOItem のプロパティをアップデートするメソッドを作ります。

まずは、モデルテストから作ります。

モデルテスト

既存の TODOItem の Title, Detail, isDone を変更できることを確かめます。

Model 追加テストコード

    func test_modifyTODOItem_withNewPropertyValue_shouldBeUpdatedInCoreData() throws {
        let model = TODOItemStore(true)
        let item = model.createTODOItem("item0", "item0 detail")

        // change title
        let id = try XCTUnwrap(item.id)
        XCTAssertEqual(item.isDone, false)
        let refetchedItem = try XCTUnwrap(model.updateItem(id, title: "updatedTitle", detail: "updatedDetail", isDone: true))

        XCTAssertEqual(refetchedItem.title, "updatedTitle")
        XCTAssertEqual(refetchedItem.detail, "updatedDetail")
        XCTAssertEqual(refetchedItem.isDone, true)
    }

TODOItem のプロパティ値を CoreData (DB layer) に反映するメソッド (updateItem) を作り、id を使って、改めて fetch するメソッド (refetchItem) も作ります。

Model 追加コード

    func updateItem(_ id: UUID, title: String? = nil, detail: String? = nil, isDone: Bool? = nil) -> TODOItem? {
        let request: NSFetchRequest = NSFetchRequest(entityName: "CDTODOItem")
        request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg)
        
        guard let items = try? container.viewContext.fetch(request),
              items.count == 1, let cditem = items.first as? CDTODOItem else { return nil }
        if let title = title {
            cditem.title = title
        }
        if let detail = detail {
            cditem.detail = detail
        }
        if let isDone = isDone {
            cditem.isDone = isDone
        }
        save()
        return refetchItem(id)
    }
    
    func refetchItem(_ id: UUID) -> TODOItem? {
        let request: NSFetchRequest = NSFetchRequest(entityName: "CDTODOItem")
        request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg)
        guard let items = try? container.viewContext.fetch(request),
              items.count == 1, let cditem = items.first as? CDTODOItem else { return nil }
        return TODOItem(cditem)
    }

テストを実行するとパスすることを確認できます。

以前に、isDone をアップデートするメソッド(toggleIsDone)を作っていましたが、今回作った updateItem を使うようにします。

TODOItemStore.toggleIsDone

    func toggleIsDone(_ item: TODOItem) {
        guard let id = item.id else { return }
        _ = updateItem(id, isDone: !item.isDone)
    }

これまでの全てのテストをパスすることを確認してモデルの修正は、終わりです。

View, ViewModel のアップデート

まずは、View のテストを作ります。

新規 TODOItem 作成シートを再利用して、TODOItem の編集を行う方針で作ることにします。

View,ViewModel 向けテスト作成

View のテストとして、新規 TODOItem 作成後に、詳細ビューに移行して、プロパティを変更してみます。

test_editTODOItem_withNewPropertyValue_shouldBeStored

    func test_editTODOItem_withNewPropertyValue_shouldBeStored() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        
        // add
        sleep(1)
        mainPage.addButtonTap().typeTitle("Title").typeDetail("Detail").tapOk()
        
        sleep(1)
        let detailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        sleep(1)
        detailPage
            .typeTitle("UpdatedTitle")
            .typeDetail("UpdatedDetail")
            .tapIsDone()
            .tapOk()
        
        _ = mainPage.toggleFilter()
        
        sleep(1)
        let detailPageAfter = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        XCTAssertEqual(detailPageAfter.titleText, "UpdatedTitle")
        XCTAssertEqual(detailPageAfter.detailText, "UpdatedDetail")
        XCTAssertEqual(detailPageAfter.isDoneState, "1")
    }

# 要素がうまく取得できない時があり、sleep を適宜入れています。

これに合わせて、以下の修正が、PageObject に必要となります。

  • 新規 TODOItem 作成ビュー用の TODOListNewItemPageObject に、isDone を操作できる Toggle を追加(名前も TODOListItemDetailPageObject に変更しました)
  • メインビューを表す TODOListPageObject に、行をタップすると TODOItem の詳細ビューに遷移するメソッドの追加

ここで、以下の不整合に気づきます。

  • 行をタップした時に、isDone を toggle するのか、詳細ビューに遷移するのか、どっち?

これまでは、行をタップしたときに toggle していましたが、行タップは、詳細ビューへの遷移。チェックボックスをタップしたときに、toggle するような動作に変更します。(テスト的には、チェックボックスを操作していましたが、View としては、行をタップされても動作していました)

TODOListNewItemPageObject -> TODOListItemDetailPageObject 修正

class TODOListItemDetailPageObject: PageObject {
    var app: XCUIApplication
    
    init(_ app: XCUIApplication) {
        self.app = app
    }

    private var titleField: XCUIElement { app.textFields["ItemDetailViewTitle"] }
    private var detailField: XCUIElement { app.textFields["ItemDetailViewDetail"] }
    // (1)
    private var isDoneToggle: XCUIElement { app.switches["ItemDetailViewIsDone"] }
    private var okButton: XCUIElement { app.buttons["ItemDetailViewOk"] }
    private var cancelButton: XCUIElement { app.buttons["ItemDetailViewCancel"] }
    
    // (2)
    var titleText: String { titleField.value as? String ?? "unknown type" }
    var detailText: String { detailField.value as? String ?? "unknown type"}
    var isDoneState: String { isDoneToggle.value as? String ?? "unknown type" }
    
    func typeTitle(_ title: String) -> TODOListItemDetailPageObject{
        titleField.tap()
        titleField.doubleTap()
        titleField.typeText(XCUIKeyboardKey.delete.rawValue)
        titleField.typeText(title)
        return self
    }
    func typeDetail(_ detail: String) -> TODOListItemDetailPageObject{
        detailField.tap()
        detailField.doubleTap()
        detailField.typeText(XCUIKeyboardKey.delete.rawValue)
        detailField.typeText(detail)
        return self
    }
    // (3)
    func tapIsDone() -> TODOListItemDetailPageObject {
        isDoneToggle.tap()
        return self
    }
    
    func tapOk() {
        okButton.tap()
    }
    
    func tapCancel() {
        cancelButton.tap()
    }
}
コード解説
  1. isDone の状態を表す Toggle を取得できるようにします
  2. Title, Detail, isDone の状態を返すメソッド
  3. isDone の状態を表す Toggle をタップするメソッド

TODOListPageObject も、行をタップして、詳細ビューへ遷移するメソッドを追加します。

TODOListPageObject 修正

class TODOListPageObject: PageObject {
    // .. snip ..
    // (1)
    func addButtonTap() -> TODOListItemDetailPageObject {
        addButton.tap()
        return TODOListItemDetailPageObject(app)
    }
    // (2)
    func rowPageObjectAtIndex(at: Int) -> TODOItemRowPageObject {
        return TODOItemRowPageObject(self.app, cell: todoListRows.element(boundBy: index))
    }
}
コード解説
  1. 新規作成にも、同じビュー View の使うようにします
  2. メインビューの行を表す Page Object を返します。この TODOItemRowPageObject が tapToDetailVew メソッドを提供します

コンパイルは通る状態になります。(テストをパスはしません)

View を変更

テストができたので、変更を行います。

  • NavigationLink を使って、行タップ時に、TODOItem の詳細ビューへ遷移
  • 詳細ビュー(NewTODOItemView)に isDone を表現する Toggle を追加
  • NavigationLink の Label 要素内をうまく XCUIElement で走査できないので、チェックボックスを NavigationLink 外へ移動
  • 上記と同じ理由で、テストを変更。プロパティの妥当性は、詳細ビューに遷移して確認(メインビューでの表示の正しさは目視しかできなくなりました)
TODOItemView

struct TODOItemView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    let todoItem: TODOItem
    var body: some View {
        HStack {
            VStack {
                Text(todoItem.title)
                    .font(.largeTitle)
//                    .accessibility(identifier: "TODOItemTitleText")
                Text(todoItem.detail)
                    .font(.body)
//                    .accessibility(identifier: "TODOItemDetailText")
            }
        }
//        .accessibility(identifier: "TODOItemView")
    }
}
コード解説
  1. NavigationLink の Label 内に含まれると 走査不能になってしまうので、チェックボックス用の Image を外部に出しました
  2. Accessibility ID で取得できなくなったので、ID 設定も止めています
TODOITemDetailView

struct TODOITemDetailView: View {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOITemDetailView", category: "TODOITemDetailView")
    @EnvironmentObject var viewModel: MyTODOViewModel
    @Environment(\.presentationMode) var presentationMode
    // (1)
    var item: TODOItem?
    @State private var editItem:TODOItem

    // (2)
    init(_ item:TODOItem? ) {
        self.item = item
        if let item = item {
            _editItem = State(wrappedValue: TODOItem(item.title, item.detail, item.isDone))
        } else {
            _editItem = State(wrappedValue: TODOItem(""))
        }
    }
    
    var body: some View {
        VStack {
            Spacer()
            HStack {
                Text("Title : ")
                TextField("title", text: $editItem.title)
                    .accessibility(identifier: "ItemDetailViewTitle")
            }
            .padding()
            HStack {
                Text("Detail: ")
                TextField("detail", text: $editItem.detail)
                    .accessibility(identifier: "ItemDetailViewDetail")
            }
            .padding()
            HStack {
                Text("IsDone: ")
                Toggle("isDoneToggle", isOn: $editItem.isDone)
                    .accessibility(identifier: "ItemDetailViewIsDone")
                    .labelsHidden()
            }
            .padding()
            Spacer()
            HStack {
                Button(action: {
                    // (3)
                    if let item = item {
                        viewModel.updateTODOItem(item, title: editItem.title, detail: editItem.detail, isDone: editItem.isDone)
                    } else {
                        _ = viewModel.createTODOItem(editItem.title, editItem.detail, editItem.isDone)
                    }
                    presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("OK")
                })
                .accessibility(identifier: "ItemDetailViewOk")
                .padding()
                Button(action: {
                    presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("Cancel")
                })
                .accessibility(identifier: "ItemDetailViewCancel")
                .padding()
            }
            .padding()
            Spacer()
        }
        .padding()
    }
}
コード解説
  1. 編集時には、渡される TODOItem を保持します
  2. 新規作成時には、nil が渡されますが、既存要素編集時には、該当 item が渡されます。内部の State 変数に必要に応じて値をコピーしておきます。このビュー内での編集対象は、editItem です。
  3. OK が押された時には、新規作成/既存編集に応じて、新規作成・プロパティアップデートを行います。
MyTODOMainView

struct MyTODOMainView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    @Environment(\.presentationMode) var presentationMode
    @State private var showNewItemView = false

    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems, id:\.self) { item in
                    HStack {
                        // (1)
                        Image(systemName: item.isDone ? "checkmark.square" : "square")
                            .resizable()
                            .accessibility(identifier: "TODOItemIsDoneImage")
                            .frame(width: 25, height: 25)
                            .onTapGesture {
                                withAnimation( .easeOut ) {
                                    self.viewModel.updateTODOItem(item, isDone: !item.isDone)
                                }
                            }
                            .padding(.trailing, 15)
                        // (2)
                        NavigationLink(
                            destination: TODOITemDetailView(item),
                            label: {
                                TODOItemView(todoItem: item)
                            })
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .accessibility(identifier: "TODOList")
            .navigationTitle("MyTODO")
            .toolbar {
                #if os(iOS)
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                #endif

                ToolbarItem(placement: .navigationBarTrailing) {
                    HStack {
                        Button(action: {
                            showNewItemView.toggle()
                        }) {
                            Image(systemName: "plus").resizable().scaledToFit().frame(width: 20, height: 20)
                        }
                        .padding(.trailing, 10)
                        Button(action: {
                            viewModel.toggleDisplayFilter()
                        }) {
                            Image(systemName: viewModel.showUndoneItems ? "checkmark.square" : "square").resizable().scaledToFit().frame(width: 20, height: 20)
                            
                        }
                    }
                }
            }
        }
        .sheet(isPresented: $showNewItemView) {
            // (3)
            TODOITemDetailView(nil)
        }
        .padding()
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { viewModel.todoItems[$0] }.forEach(viewModel.deleteTODOItem)
        }
    }
}
コード解説
  1. チェックボックスイメージを NavigationLink 外部に出しています
  2. NavigationLink から TODOItemDetailView に遷移するときは、該当 TODOItem を渡します。
  3. 新規作成時には、TODOItemDetailView に nil を渡します。
  4. GUI 要素の配置を少し調整しました。

これで、TODOItem について、 いわゆる CRUD することができるようになりました。

動かすと以下のような感じのアプリになっています。

普通に使える気がするので、これを Version1 にして、追加機能は、データ互換性も考慮しつつ実装していくことにします。

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

コメントを残す

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