[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その12:Priority に沿ったソートの追加)

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

環境&対象

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

  • maxOS Catalina 10.15.7
  • Xcode 12.3
  • iOS 14.2

ソートの追加

TODOItem に Priority を追加したので、リストのソートも、タイトル順だけでなく、プライオリティ順のソートも追加してみます。

ソート実装方針

Model が ソート順(NSSortDescriptor) を受け取り、ソート済み配列を返しているので、Model に渡す NSSortDescriptor を 外部から調整すれば良いことになります。

ViewModel で 適切な NSSortDescriptor を管理し、Model に渡すことにします。

テストは、Model を対象にしたものと、View を対象にしたもので行います。

ソート順を指定する UI は、Toolbar を使ってみます。

ソートの実装

Model のテスト

CoreData を疑うわけではないですが、以下のようなテストを作成しました。

# 自分で作成する NSSortDescriptor のチェックも兼ねてます

test_variousSort_PriorityOrName_sortAccordingly

    func test_variousSort_PriorityOrName_sortAccordingly() throws {
        let model = TODOItemStore(true)
        // (1)
        let prioSort = NSSortDescriptor(key: "priority", ascending: false)
        let titleSort = NSSortDescriptor(key: "title", ascending: true)
        // (2)
        _ = model.createTODOItem("Middle", "Middle", false, .middle)
        _ = model.createTODOItem("Low", "Low", false, .low)
        _ = model.createTODOItem("Highest", "Highest", false, .highest)
        // (3)
        let sortedWithPrio = model.filteredItems(nil, [prioSort, titleSort])
        XCTAssertEqual(sortedWithPrio[0].title, "Highest")
        XCTAssertEqual(sortedWithPrio[1].title, "Middle")
        XCTAssertEqual(sortedWithPrio[2].title, "Low")
        // (4)
        let sortedWithTitle = model.filteredItems(nil, [titleSort, prioSort])
        XCTAssertEqual(sortedWithTitle[0].title, "Highest")
        XCTAssertEqual(sortedWithTitle[1].title, "Low")
        XCTAssertEqual(sortedWithTitle[2].title, "Middle")
    }
コード解説
  1. NSSortDescriptor を準備
  2. 複数のプライオリティでモデルを作成
  3. Title でソートして順番をテスト
  4. Priority でソートして順番をテスト

Model の実装

Priority 順ソートの時にも2番目のソート種類として Title を渡したいので、Model の filteredItems の引数として、複数の NSSortDescriptor を受け取れるようにします。

TODOItemStore.filteredItems

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

上記のコードで、Model のテストコードはパスするようになります。

View の テスト

以下のようなテストを作成しました。 プライオリティ順だけでなく、タイトル順でも適切にソートされるかをテストします。

リスト上の表示を取得できないため、個別 Item の詳細ページに遷移してテストしています。

test_variousSort_nameSortOrPrioSort_shouldBeSortedAccordingly

    func test_variousSort_nameSortOrPrioSort_shouldBeSortedAccordingly() 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("0MiddleItem").typeDetail("MiddleDetail").selectPriority(2).tapOk()
        
        sleep(1)
        mainPage.addButtonTap().typeTitle("1LowItem").typeDetail("LowDetail").selectPriority(1).tapOk()
        
        sleep(1)
        mainPage.addButtonTap().typeTitle("2HighestItem").typeDetail("HighestDetail").selectPriority(4).tapOk()
        // check Title sort
        _ = mainPage.changeToTitleSort()
        // check top item
        var topItemDetailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        sleep(1)
        XCTAssertEqual(topItemDetailPage.titleText, "0MiddleItem")
        topItemDetailPage.tapOk()
        
        var bottomItemDetailPage = mainPage.rowPageObjectAtIndex(at: 2).tapToDetailVew()
        sleep(1)
        XCTAssertEqual(bottomItemDetailPage.titleText, "2HighestItem")
        bottomItemDetailPage.tapOk()

        // check Priority sort
        _ = mainPage.changeToPrioritySort()
        topItemDetailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        sleep(1)
        XCTAssertEqual(topItemDetailPage.titleText, "2HighestItem")
        topItemDetailPage.tapOk()
        
        bottomItemDetailPage = mainPage.rowPageObjectAtIndex(at: 2).tapToDetailVew()
        sleep(1)
        XCTAssertEqual(bottomItemDetailPage.titleText, "1LowItem")
        bottomItemDetailPage.tapOk()

    }

View の実装を行う前に、テストコードを整備する必要があります。
具体的には、Toolbar に追加するソートボタンを TODOListPageObject に追加する必要があります。

TODOListPageObject

class TODOListPageObject: PageObject {
    var app: XCUIApplication
    
    init(_ app: XCUIApplication) {
        self.app = app
    }
    
    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) }
    private var toggleButton: XCUIElement { navBar.buttons.element(boundBy: 2)}
    // (1)
    private var titleSortButton: XCUIElement { app.buttons["Title"]}
    private var prioSortButton: XCUIElement { app.buttons["Priority"]}
    
    
    func addButtonTap() -> TODOListItemDetailPageObject {
        addButton.tap()
        return TODOListItemDetailPageObject(app)
    }
    // (2)
    func changeToTitleSort() -> TODOListPageObject {
        titleSortButton.tap()
        return self
    }
    // (3)
    func changeToPrioritySort() -> TODOListPageObject {
        prioSortButton.tap()
        return self
    }
    
    func removeItemWithEdit(index: Int) -> TODOListPageObject {
        editButton.tap()
        todoListRows.element(boundBy: index).buttons["Delete "].tap()
        todoListRows.element(boundBy: index).buttons["Delete"].tap()
        doneButton.tap()
        return self
    }
    
    func removeItemWithSwipe(index:Int) -> TODOListPageObject {
        todoListRows.element(boundBy: index).swipeLeft()
        todoListRows.element(boundBy: index).buttons["Delete"].tap()
        return self
    }
    
    func rowPageObjectAtIndex(at: Int) -> TODOItemRowPageObject {
        return TODOItemRowPageObject(self.app, cell: todoListRows.element(boundBy: at))
    }
    
    func toggleFilter() -> TODOListPageObject {
        toggleButton.tap()
        return self
    }
}
コード解説
  1. タイトル順ソートボタン、プライオリティ順ソートボタンを取得
  2. タイトル順ソートボタン押下メソッド
  3. プライオリティ順ソートボタン押下メソッド

これで、View のテストも完成しました。

View/ViewModel の実装

View としては、MyTODOMainView にソートを指定するボタンを追加します。

MyTODOMainView が大きくなってきたので、Toolbar 部分のみ抜粋します

ToolbarItemGroup

                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: viewModel.changeToTitleFirstSort, label: { Text("Title")})
//                        .accessibility(identifier: "TitleSortButton")
                    Button(action: viewModel.changeToPrioFirstSort, label: { Text("Priority")})
//                        .accessibility(identifier: "PrioSortButton")
                }
[SwiftUI] toolbar の使い方

なお、現時点では、Toolbar に配置した時の AccessibilityID で UI 要素の取得はできませんでした。

ViewModel は、ソート指定用の NSSortDescriptor を保持し、View から指定されたソート方法を使って、Model から TODOItem のリストを受け取れるよう設定します。

example

class MyTODOViewModel : ObservableObject {
    var todoItemStore: TODOItemStore
    @Published var todoItems:[TODOItem] = []
    @Published var showUndoneItems:Bool = true
    var predicate: NSPredicate {
        if showUndoneItems {
            return NSPredicate(format: "isDone == false")
        }
        return NSPredicate(format: "isDone == true")
    }
    var sortDescs:[NSSortDescriptor] = [NSSortDescriptor(key: "title", ascending: true),
                                               NSSortDescriptor(key: "priority", ascending: false)]

    // .. snip ..

    func changeToPrioFirstSort() {
        self.sortDescs = [NSSortDescriptor(key: "priority", ascending: false),
                          NSSortDescriptor(key: "title", ascending: true)]
        updateTodoItems(nil)
    }

    func toggleDisplayFilter() {
        showUndoneItems.toggle()
        updateTodoItems(nil)
    }

    // .. snip ..
}

今回の実装で、タイトルだけでなく、プライオリティ順に表示することもできるようになりました。

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

コメントを残す

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