[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その5:View の実装)

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

環境&対象

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

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

ViewModel の準備ができたところで、View を作っていきます。

View の実装

View のテストを作成する

View を実装するにあたり、View のテストから作り始めます。

View も in-memory でテストする

Model, ViewModel ともに、in-memory の CoreData でテストしました。

それまでに残っているデータを考慮せずにテストできるので、テストは、in-memory の方がやりやすいです。

Model テスト、ViewModel テスト どちらも API ベースでのテストなので、in-memory の指定が行いやすかったのですが、アプリ経由でのテストになると、引数指定して initializer を呼べません。

アプリの引数指定で、アプリ起動時に、in-memory の指定ができるようにすると便利です。

TDD[TDD] iOSアプリのUITestでのテスト用データの作り方

View(App) の実装 (初期化時に、in-memory を選択できるように)

テストでの実行時に、"TestWithInMemory" と引数に指定することで、InMemory の CoreData を使うようにします。

そのために、App を以下のようにしました。

MyTODOApp

//
//  MyTODOApp.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import SwiftUI
import os

@main
struct MyTODOApp: App {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOApp", category: "MyTODOApp")
    @StateObject var viewModel: MyTODOViewModel
    
    init() {
        // (1)
        let inMemory:Bool = ProcessInfo.processInfo.arguments.contains("TestWithInMemory")
        // (2)
        _viewModel = StateObject(wrappedValue: MyTODOViewModel(inMemory))
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(viewModel)
        }
    }
}
コード解説
  1. アプリ起動時の引数は、ProcessInfo.processInfo.arguments 経由で取得できます
  2. StateObject をこのように初期化することもできます

こうすることで、起動時の引数指定で、CoreData を In-Memory にすることができます。

View のテストコード

In-Memory で起動できるようになったので、View のテストコードを書いていきます。

まずは、In-Memory で起動して、List が空であり ボタン等が存在することをテストします。

UITest ように新しいファイル を作成しました。(ターゲットは、UITest です)

View_Tests

//
//  View_Tests.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/12
//  © 2020  SmallDeskSoftware
//

import XCTest

class ViewTests_UITests: XCTestCase {
    
    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        
        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false
        
        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }
    
    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    func test_launch_noData_EmptyList() throws {
        let app = XCUIApplication()
        // (1)
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        // (2)
        let todoList = app.tables["TODOList"]
        XCTAssertTrue(todoList.exists)
        // (3)
        let listRows = todoList.cells
        XCTAssertEqual(listRows.count, 0)
        
        // (4)
        let navBar = app.navigationBars["MyTODO"]
        XCTAssertTrue(navBar.exists)
        // (5)
        let addButton = navBar.buttons.element(boundBy: 1)
        XCTAssertTrue(addButton.exists)
        let editButton = navBar.buttons.element(boundBy: 0)
        XCTAssertTrue(editButton.exists)
    }

    func test_addAndRemoveItem_afterLaunch_todoItemsShouldBeUpdated() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let todoList = app.tables["TODOList"]
        let listRows = todoList.cells
        XCTAssertEqual(listRows.count, 0)
        
        let navBar = app.navigationBars["MyTODO"]
        let addButton = navBar.buttons.element(boundBy: 1)
        let editButton = navBar.buttons.element(boundBy: 0)
        
        // (6)
        // add
        addButton.tap()
        XCTAssertEqual(listRows.count, 1)
        addButton.tap()
        XCTAssertEqual(listRows.count, 2)
        
        // (7)
        // remove
        editButton.tap()
        listRows.element(boundBy: 1).buttons["Delete "].tap()
        listRows.element(boundBy: 1).buttons["Delete"].tap()
        XCTAssertEqual(listRows.count, 1)
    }
}
コード解説
  1. TestWithInMemory という引数を指定して、アプリを起動します
  2. TODOList という Accessibility ID が付与された List を取得します
  3. List に含まれる 行 を 取得します
  4. List に含まれる行が 0 であることをテストします
  5. NavigationBar に Edit ボタンと 追加ボタン があることをテストします
  6. 追加ボタンを2回押下し、リストが2行になることをテストします
  7. Edit ボタンを押下し、行に表示される削除ボタンを押して要素を削除、リストの行が1行になっていることをテストします

まだ、View を修正していないため、エラーとなります。

View の実装

まずは、以下の機能を実装します。

  • TODO item をリスト表示
  • TODO item を追加
  • TODO item を削除 (ForEach の .onDelete を利用)

以下のようなコードになります。

ContentView

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/10
//  © 2020  SmallDeskSoftware
//

import SwiftUI
import CoreData

struct ContentView: View {
    @EnvironmentObject var viewModel: MyTODOViewModel

    var body: some View {
        NavigationView {
            List {
                // (1)
                ForEach(viewModel.todoItems, id:\.self) { item in
                    Text("\(item.id?.uuidString ?? "noID") \(item.title)")
                }
                // (2)
                .onDelete(perform: deleteItems)
            }
            // (3)
            .accessibility(identifier: "TODOList")
            // (3)
            .navigationTitle("MyTODO")
            // (4)
            .toolbar {
                #if os(iOS)
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                #endif

                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }
    // (5)
    private func addItem() {
        withAnimation {
            _ = viewModel.createTODOItem("NewItem", "NewDetail")
        }
    }
    // (6)
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { viewModel.todoItems[$0] }.forEach(viewModel.deleteTODOItem)
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(MyTODOViewModel(false))
    }
}
コード解説
  1. ForEach を使って、TODOItem を表示します。Title と ID を表示しています
  2. 削除する機能は、SwiftUI の提供する onDelete を使います
  3. UITest からアクセスできるように Accessibility ID を設定します
  4. Toolbar に、EditButton と 追加するボタンを設定しています。
  5. 追加ボタンが押された時に、ViewModel の該当メソッドを呼びます
  6. 削除するときには、ViewModel の該当メソッドを呼びます

シミュレータで動かしてみると、以下のようになります。


テスト

この View の実装を使うと、先ほど作成したテストをパスすることがわかります。

ここまでの振り返り

これで、非常にシンプルな View の実装はできたことになります。

以下の点を改良します。

  • TODOItem 表示の改良(タイトル、詳細を表示)
  • TODOItem 追加時に、タイトル等を指定できるようにする
  • TODOItem に、完了フラグを追加する

いろいろと考えられると思いますが、まずは上記の点を改良していきます。

一度、コードのリファクタリングをしてから、上記の点を改良していきます。

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

コメントを残す

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