[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その10:Ver.2 で Priority を導入)

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

環境&対象

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

  • macOS Catalina 10.15.7
  • Xcode 12.3
  • iOS 14.2

Ver.2 として機能追加

以下の機能追加をしていきます。

  • TODOItem が、優先度を持つ
  • 優先度でソート可能にする
  • UNDO/REDO 対応?

Model(TODOItem/CDTODOItem, TODOItemStore) へ機能追加

TODOItem, TODOItemStore のテスト追加

モデル等のファイルを変更する前に、テストを作成します。

以下のテストを追加します。

  • 設定した priority が保存されているか
Model_Tests.swift テスト修正


    func test_createItem_newItem_withCorrectValues() {
        // prep model with coredata
        let model = TODOItemStore(true)
        
        XCTAssertEqual(model.items.count, 0)

        // create new item
        // (1)
        _ = model.createTODOItem("Item Title", "item detail", false, .high)
        XCTAssertEqual(model.items.count, 1)

        // get item from model
        let item = model.items.first!
        
        // test : compare properties
        XCTAssertEqual(item.title, "Item Title")
        XCTAssertEqual(item.detail, "item detail")
        XCTAssertEqual(item.isDone, false)
        // (2)
        XCTAssertEqual(item.priority, .high)
    }
コード解説
  1. initializer に priority を指定できるようにします
  2. 設定した priority が取得できることをテスト

テストができたので、モデルに優先度(priority)を追加します。

CoreData モデルのバージョン追加

CoreData のモデルから、変更していきます。

既存のモデルを変更してしまうと、以前のモデルを読み込めなくなってしまいます。

新しい Version のモデルとして作成すれば、以前のモデルから migration することが可能となります。

新しい Version 設定

CoreData のモデルに新しい Version を設定します。以下の手順です。

  1. Xcode で モデルファイル(MyTODO.xcdatamodeld) を開く
  2. メニュー [Editor]-[Add Model Version…] を選択する
  3. Version name を設定する(ver2 としました)
Add Model Version...
Add Model Version…

新しい Version にプロパティ追加

Xcode の Project Navigator で CoreData ファイルを見ると、複数のファイルを内包するように変わっています。

先ほど作成した ver2 を選択して、新しいプロパティを追加していきます。

モデル ver2
モデル ver2 を選択

プライオリティ(Int型) を追加します。

プライオリティ追加
プライオリティ追加

作成後に、Current を、現在作った ver2 に設定します。

ModelVersion の設定
ModelVersion の設定
ModelVersionの設定 これで、CoreData 側は OK です。

TODOItem, TODOItemStore の実装追加

次に、TODOItem に dueDate に対応するプロパティを追加します。
initializer 等も全て修正します。

TODOItem


// MARK: TODOItem independent from DB
struct TODOItem : Identifiable, Hashable {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem")

    var id: UUID? = nil
    var title: String = ""
    var detail: String = ""
    var isDone: Bool = false
    // (1)
    var priority: Priority = .middle
    
    // (2)
    init(_ title: String,_ detail: String = "",_ isDone: Bool = false, _ priority:Priority = .middle) {
        self.id = UUID()
        self.title = title
        self.detail = detail
        self.isDone = isDone
        self.priority = priority
    }
    // (3)
    enum Priority: Int, CaseIterable, CustomStringConvertible {
        case lowest = 0
        case low = 1
        case middle = 2
        case high = 3
        case highest = 4
        var description: String {
            switch rawValue {
                case 0:
                    return "lowest"
                case 1:
                    return "low"
                case 2:
                    return "middle"
                case 3:
                    return "high"
                case 4:
                    return "highest"
                default:
                    return "unknown"
            }
        }
        init(value: Int16) {
            switch value {
                case 0:
                    self = .lowest
                case 1:
                    self = .low
                case 2:
                    self = .middle
                case 3:
                    self = .high
                case 4:
                    self = .highest
                default:
                    TODOItem.logger.error("unknown rawValue for priority, treat it as .middle")
                    self = .middle
            }
        }
    }
}
コード解説
  1. Priority を保存するプロパティを定義
  2. init の引数に priority を追加
  3. Priority は enum で定義 (CoreData で保存に使用する Int16 での initializer も定義)

上記以外にも、以下の箇所の修正が必要となります。

  • CoreData Entity からの TODOItem 生成メソッド(TODOItem.init(_ cdItme:CDTODOItem))
  • 新規 TODOItem 作成メソッド(TODOItemStore.createTODOItem)
  • 新規 TODOItem 作成メソッド(MyTODOViewModel.createTODOItem)

上記のコードで先ほど作ったモデルのテストはパスします。過去のモデルテストもパスすることを確認して、View/ViewModel に着手します。

View(MyTODOMainView) への機能追加

機能追加としては、以下の点になります。

  • TODOItem 表示に、プライオリティ表示を追加
  • 編集画面に、プライオリティを表示し変更可能に

View の修正での必要に応じて、ViewModel も変更していきます。

View のテスト追加

PageObject 的には、新しく表示される締切日を 処理するためにTODOItemRowPageObject を修正したいのですが、NavigationLink を使う関係で、表示要素の詳細を取得することが難しいので、編集画面でテストすることにします。

つまり、PageObject 的には、TODOListItemDetailPageObject のみを対応させます。

編集画面でのプライオリティ選択は、Segmented Control にします。

プライオリティ選択の要素も考慮した PageObject は、以下のようになります。

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 highestSegment: XCUIElement { app.buttons["highestt"] }
    private var highSegment: XCUIElement { app.buttons["high"] }
    private var middleSegment: XCUIElement { app.buttons["middle"] }
    private var lowSegment: XCUIElement { app.buttons["low"] }
    private var lowestSegment: XCUIElement { app.buttons["lowest"] }
    
    private var isDoneToggle: XCUIElement { app.switches["ItemDetailViewIsDone"] }
    
    private var okButton: XCUIElement { app.buttons["ItemDetailViewOk"] }
    private var cancelButton: XCUIElement { app.buttons["ItemDetailViewCancel"] }
    
    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
    }
    // (2)
    func selectPriority(_ value: Int) -> TODOListItemDetailPageObject {
        switch value {
            case 0:
                lowestSegment.tap()
            case 1:
                lowSegment.tap()
            case 2:
                middleSegment.tap()
            case 3:
                highSegment.tap()
            case 4:
                highestSegment.tap()
            default:
                break
        }
        return self
    }
    // (3)
    var selectedPriority: Int {
        if lowestSegment.isSelected { return 0 }
        if lowSegment.isSelected { return 1 }
        if middleSegment.isSelected { return 2 }
        if highSegment.isSelected { return 3 }
        if highestSegment.isSelected { return 4 }
        return -1
    }
    
    func tapIsDone() -> TODOListItemDetailPageObject {
        isDoneToggle.tap()
        return self
    }
    
    func tapOk() {
        okButton.tap()
    }
    
    func tapCancel() {
        cancelButton.tap()
    }
}
コード解説
  1. SegmentedControl の各要素へアクセスできるようにします
  2. プライオリティを設定するメソッドを用意
  3. 選択されているプライオリティを返すメソッドを用意

View のテストを追加

ビューテストの準備ができましたので、ビューのテストを作成します。これまでのテストにプライオリティ操作を追加しています。

Viewテスト


    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
        sleep(1)
        let itemDetailPageForNew = mainPage.addButtonTap()
        itemDetailPageForNew
            .typeTitle("TypedItemTitle")
            .typeDetail("TypedItemDetail")
            // (1)
            .selectPriority(1) // low
            .tapOk()
        
        XCTAssertEqual(mainPage.todoListRows.count, 1)
        
        // NOTE: it is hard to check elements in NavigationLink.
        // so let's use detailview for checking TODOItem properties
        
        let itemDetailPageForCheck = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()//  rowTapToDetailPage(index: 0)
        XCTAssertEqual(itemDetailPageForCheck.titleText, "TypedItemTitle")
        XCTAssertEqual(itemDetailPageForCheck.detailText, "TypedItemDetail")
        // (2)
        XCTAssertEqual(itemDetailPageForCheck.selectedPriority, 1)
        XCTAssertEqual(itemDetailPageForCheck.isDoneState, "0")
    }
コード解説
  1. TODOItem 編集時に、プライオリティを low に設定します
  2. 改めて詳細画面に行った時に、low が選択されていることをテストします

View の実装

以下の箇所に、due date を表示するように変更します。

  • MainView のリストの行に 追加表示
  • TODOItem の編集ビューに 追加表示

MainViewのリストの行に追加表示します。(現状では、テスト対象外です)

TODOItemView


struct TODOItemView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel
    let todoItem: TODOItem
    var body: some View {
        HStack {
            // (1)
            let imageInfo = viewModel.priorityImageInfoForItem(todoItem)
            // (2)
            Image(systemName: imageInfo.imageName)
                .resizable()
                .scaledToFit()
                .rotationEffect(imageInfo.angle)
                .frame(width: 25)
            VStack {
                Text(todoItem.title)
                    .font(.largeTitle)
//                    .accessibility(identifier: "TODOItemTitleText")
                Text(todoItem.detail)
                    .font(/*@START_MENU_TOKEN@*/.body/*@END_MENU_TOKEN@*/)
//                    .accessibility(identifier: "TODOItemDetailText")
            }
        }
//        .accessibility(identifier: "TODOItemView")
    }
}
コード解説
  1. プライオリティに応じて使用するイメージの情報を viewModel から取得します
  2. 取得した情報を使って、プライオリティに応じたイメージを表示します

ViewModel に以下のメソッドを追加して、プライオリティに応じたイメージを作れる情報を返します。

priorityImageInfoForItem


    func priorityImageInfoForItem(_ item: TODOItem) -> (imageName:String, angle:Angle) {
        switch item.priority {
            case .lowest:
                return ("chevron.left.2", Angle(degrees: -90))
            case .low:
                return ("chevron.down", Angle(degrees: 0))
            case .middle:
                return ("minus", Angle(degrees: 0))
            case .high:
                return ("chevron.up", Angle(degrees: 0))
            case .highest:
                return ("chevron.left.2", Angle(degrees: 90))
        }
    }

プライオリティのアイコンは、それぞれ以下のようにしました。

プライオリティアイコン
プライオリティアイコン

次に、TODOItem の詳細を表示するビューにも追加します。

詳細ビュー(編集ビュー)は、プライオリティを表示するために、Picker を追加しました。

example


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

    init(_ item:TODOItem? ) {
        self.item = item
        if let item = item {
            // (1)
            _editItem = State(wrappedValue: TODOItem(item.title, item.detail, item.isDone, item.priority))
        } 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()
            // (2)
            VStack(alignment: .leading) {
                Text("Priority")
                Picker("priority", selection: $editItem.priority) {
                    ForEach(TODOItem.Priority.allCases, id:\.self) { prio in
                        Text(prio.description)
                            .tag(prio)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
            }
            .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, priority: editItem.priority)
                    } else {
                        _ = viewModel.createTODOItem(editItem.title, editItem.detail, editItem.isDone, editItem.priority)
                    }
                    withAnimation {
                        presentationMode.wrappedValue.dismiss()
                    }
                }, label: {
                    Text("OK")
                })
                .accessibility(identifier: "ItemDetailViewOk")
                .padding()
                Button(action: {
                    withAnimation {
                        presentationMode.wrappedValue.dismiss()
                    }
                }, label: {
                    Text("Cancel")
                })
                .accessibility(identifier: "ItemDetailViewCancel")
                .padding()
            }
            .padding()
            Spacer()
        }
        .padding()
    }
}
コード解説
  1. 編集時には、プライオリティも編集対象として、editItem に値を保存します
  2. プライオリティを表示する Picker です
  3. 作成/編集終了時に、プライオリティも反映します

上記の修正を行うと、これまでのテスト全てをパスすることを確認できます。

これまで通りテストを行いました。

今回は、CoreData モデルに新しいバージョンを設定して変更を加えましたので、過去バージョンからの移行として以下を行うようにしてみます。

  • 過去バージョンからのマイグレーション設定
  • 過去バージョンからのマイグレーションのテスト

続きは次回です。

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

コメントを残す

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