Sponsor Link
環境&対象
- macOS Catalina 10.15.7
- Xcode 12.3
- iOS 14.2
Ver.2 として機能追加
以下の機能追加をしていきます。
- TODOItem が、優先度を持つ
- 優先度でソート可能にする
- UNDO/REDO 対応?
Model(TODOItem/CDTODOItem, TODOItemStore) へ機能追加
TODOItem, TODOItemStore のテスト追加
モデル等のファイルを変更する前に、テストを作成します。
以下のテストを追加します。
- 設定した priority が保存されているか
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)
}
- initializer に priority を指定できるようにします
- 設定した priority が取得できることをテスト
テストができたので、モデルに優先度(priority)を追加します。
CoreData モデルのバージョン追加
CoreData のモデルから、変更していきます。
既存のモデルを変更してしまうと、以前のモデルを読み込めなくなってしまいます。
新しい Version のモデルとして作成すれば、以前のモデルから migration することが可能となります。
新しい Version 設定
CoreData のモデルに新しい Version を設定します。以下の手順です。
- Xcode で モデルファイル(MyTODO.xcdatamodeld) を開く
- メニュー [Editor]-[Add Model Version…] を選択する
- Version name を設定する(ver2 としました)
新しい Version にプロパティ追加
Xcode の Project Navigator で CoreData ファイルを見ると、複数のファイルを内包するように変わっています。
先ほど作成した ver2 を選択して、新しいプロパティを追加していきます。
プライオリティ(Int型) を追加します。
作成後に、Current を、現在作った ver2 に設定します。
TODOItem, TODOItemStore の実装追加
次に、TODOItem に dueDate に対応するプロパティを追加します。
initializer 等も全て修正します。
// 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
}
}
}
}
- Priority を保存するプロパティを定義
- init の引数に priority を追加
- 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 は、以下のようになります。
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()
}
}
- SegmentedControl の各要素へアクセスできるようにします
- プライオリティを設定するメソッドを用意
- 選択されているプライオリティを返すメソッドを用意
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")
}
- TODOItem 編集時に、プライオリティを low に設定します
- 改めて詳細画面に行った時に、low が選択されていることをテストします
View の実装
以下の箇所に、due date を表示するように変更します。
- MainView のリストの行に 追加表示
- TODOItem の編集ビューに 追加表示
MainViewのリストの行に追加表示します。(現状では、テスト対象外です)
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")
}
}
- プライオリティに応じて使用するイメージの情報を viewModel から取得します
- 取得した情報を使って、プライオリティに応じたイメージを表示します
ViewModel に以下のメソッドを追加して、プライオリティに応じたイメージを作れる情報を返します。
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 を追加しました。
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()
}
}
- 編集時には、プライオリティも編集対象として、editItem に値を保存します
- プライオリティを表示する Picker です
- 作成/編集終了時に、プライオリティも反映します
上記の修正を行うと、これまでのテスト全てをパスすることを確認できます。
これまで通りテストを行いました。
今回は、CoreData モデルに新しいバージョンを設定して変更を加えましたので、過去バージョンからの移行として以下を行うようにしてみます。
- 過去バージョンからのマイグレーション設定
- 過去バージョンからのマイグレーションのテスト
続きは次回です。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link