Sponsor Link
環境&対象
- macOS Catalina 10.15.7
- Xcode 12.2
- iOS 14.2
既存 TOODItem の編集
既存の TODOItem を編集できるようにする
モデルのアップデート
既存の TODOItem のプロパティをアップデートするメソッドを作ります。
まずは、モデルテストから作ります。
モデルテスト
既存の TODOItem の Title, Detail, isDone を変更できることを確かめます。
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) も作ります。
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 を使うようにします。
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")
}
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 としては、行をタップされても動作していました)
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()
}
}
- isDone の状態を表す Toggle を取得できるようにします
- Title, Detail, isDone の状態を返すメソッド
- isDone の状態を表す Toggle をタップするメソッド
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))
}
}
- 新規作成にも、同じビュー View の使うようにします
- メインビューの行を表す Page Object を返します。この TODOItemRowPageObject が tapToDetailVew メソッドを提供します
コンパイルは通る状態になります。(テストをパスはしません)
View を変更
テストができたので、変更を行います。
- NavigationLink を使って、行タップ時に、TODOItem の詳細ビューへ遷移
- 詳細ビュー(NewTODOItemView)に isDone を表現する Toggle を追加
- NavigationLink の Label 要素内をうまく XCUIElement で走査できないので、チェックボックスを NavigationLink 外へ移動
- 上記と同じ理由で、テストを変更。プロパティの妥当性は、詳細ビューに遷移して確認(メインビューでの表示の正しさは目視しかできなくなりました)
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")
}
}
- NavigationLink の Label 内に含まれると 走査不能になってしまうので、チェックボックス用の Image を外部に出しました
- Accessibility ID で取得できなくなったので、ID 設定も止めています
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()
}
}
- 編集時には、渡される TODOItem を保持します
- 新規作成時には、nil が渡されますが、既存要素編集時には、該当 item が渡されます。内部の State 変数に必要に応じて値をコピーしておきます。このビュー内での編集対象は、editItem です。
- OK が押された時には、新規作成/既存編集に応じて、新規作成・プロパティアップデートを行います。
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)
}
}
}
- チェックボックスイメージを NavigationLink 外部に出しています
- NavigationLink から TODOItemDetailView に遷移するときは、該当 TODOItem を渡します。
- 新規作成時には、TODOItemDetailView に nil を渡します。
- GUI 要素の配置を少し調整しました。
これで、TODOItem について、 いわゆる CRUD することができるようになりました。
動かすと以下のような感じのアプリになっています。
普通に使える気がするので、これを Version1 にして、追加機能は、データ互換性も考慮しつつ実装していくことにします。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link