ikeh1024のブログ

ZennやQiitaに書くにはちょっと小粒なネタをこちらに書いています

iOS_Apprentice_Section2_Checklistsの解説(完成形)

概要

iOS Apprenticeの第2章で作成するチェックリストアプリのコード・構成の解説を忘備録として記載。 この本は作っては壊し、を繰り返すので(大幅なリファクタリング)、実用的であろうアプリ作成過程を体験できるので初心者→中級者の橋渡しとしてはいいなと感じる。

GitHub

github.com

以下、

<Code> <解説> の順番で記述していく。

データモデル

DataModel -> Checklist -> ChecklistItemの順に小さくなる

画面構成は下記の通り。1View1Controllerの構成。

-w827

Checklist.swift

class Checklist: NSObject, Codable {

ChecklistItem.swiftと同じ理由。

name:リストのタイトル itemsChecklistItem.swiftの配列

init(name: String, iconName: String = "No Icon") {
self.name = name
self.iconName = iconName
super.init()
}
  • nameiconNameを指定してオブジェクトの作成を行う。
  • iconNameはdefaultを与えているため、初期化の際には省略可能

countUncheckedItems() -> Int

return items.reduce(0) { cnt,
  item in cnt + (item.checked ? 0 : 1) }

ChecklistItem.swift

クラス設定

class ChecklistItem: NSObject, Codable {

NSObjectの継承:オブジェクトの比較のため。 Codable:NSUserDefaultsで保存時にバイナリ変換をするため

Property

var text         = ""
var checked      = false
var dueDate      = Date()
var shouldRemind = false
var itemID       = -1
text TODO項目のタイトル
text TODO項目のタイトル
checked チェックマークをつけるか否か
dueDate 通知時刻
shouldRemind 通知を行うか否か
itemID 通知のidentifierをして使用する。
各アイテムにユニークな数字。
UserDefaultsに保存した値をインクリメントしていく。
0からスタート。

init()

super.init()
itemID = DataModel.nextChecklistItemID()
  • DataModelのクラスプロパティからユニークなitemIDを取得する

deinit

removeNotification()
  • オブジェクトの開放時に呼ばれる特殊なメソッド。
    • 今回の例だとChecklistItem・Checklistが削除された場合に呼ばれる

toggleChecked()

checked = !checked

toggleCheckedのようにデータ側でプロパティを変更する作りにするのがオブジェクト指向的なポイント。

scheduleNotification()

  • object oriented programmin→自分のことはできるだけ自分で行う
removeNotification()  // すでにitemID名の通知がある場合は削除する
  • 上記の通りに実装することで、上書きやRemindスイッチをOFFにしたとき等色々と条件があるのが、簡潔にかける様になる。
if shouldRemind && dueDate > Date() {
  • 「RemindスイッチがON」かつ「通知時刻が未来の場合」
    • ここはearly returnにすると見通しが良くなるかと個人的に思う
// 通知の中身を作成
let content = UNMutableNotificationContent()
content.title = "Reminder"
content.body = text
content.sound = UNNotificationSound.default
// dueDateから日時の要素を取り出す
let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute],
                                         from: dueDate)
  • それぞれの地域に合わせた時刻に自動で変換される(時差の考慮は不要)
// いつ実行するかのトリガを作成
let trigger = UNCalendarNotificationTrigger(dateMatching: components,
                                            repeats: false)
// itemIDを通知のidentifierとして使っている。通知依頼書を作成的な…。
let request = UNNotificationRequest(identifier: "\(itemID)",
  content: content,
  trigger: trigger)
  • 通知のidentifierにはitemIDを使用する
// UNUserNotificationCenterに新しいnotificationを登録する
let center = UNUserNotificationCenter.current()
center.add(request)

removeNotification()

 let center = UNUserNotificationCenter.current()
 center.removePendingNotificationRequests(withIdentifiers: ["\(itemID)"])
  • 通知オブジェクトを取得
  • 自身のitemIDのidentifierを持つ通知を削除する

DataModel.swift

Property

var lists = [Checklist]()

listsChecklist.swiftの配列

// このメソッドでUserDefaultの値を読み書きできる(他クラスでUserDefaultを意識しないで良くなる)
var indexOfSelectedChecklist: Int {
  get {
    return UserDefaults.standard.integer(forKey: "ChecklistIndex")
  }
  set {
    UserDefaults.standard.set(newValue, forKey: "ChecklistIndex")
  }
}

Computedプロパティ。UserDefaultsをこのクラス内で隠蔽することで、疎な作りにすることができる(オブジェクト指向)。

使用例は下記の通り 書き込み:dataModel.indexOfSelectedChecklist = -1 読み込み:let index = dataModel.indexOfSelectedChecklist

UserDefaultは例えば以下の場所に保存されている。

/Users/<ユーザ名>/Library/Developer/CoreSimulator/Devices/8A2A393D-0538-48CF-8D0A-B9F72F50788C/data/Containers/Data/Application/94934470-B24A-4BAF-9928-B2C7757E55FD/Library/Preferences/com.razeware.Checklists.plist

init()

loadChecklists()
registerDefaults()
handleFirstTime()

色々初期化を行っている。(後述)

registerDefaults()

let dictionary = [ "ChecklistIndex": -1, "FirstTime": true ] as [String : Any]  // 異なる型を登録する場合はキャストが必要
UserDefaults.standard.register(defaults: dictionary)  // register:設定されていない場合に値を登録する。 set:上書き登録
  • UserDefaultsに、まだ保存されていない場合、作成したdictionaryを登録する。

  • "ChecklistIndex": -1:前回最後に選択されたチェックリスト

    • -1は選択なし
  • "FirstTime": true:初回起動かどうか

handleFirstTime()

let userDefaults = UserDefaults.standard
let firstTime = userDefaults.bool(forKey: "FirstTime")

if firstTime {
  let checklist = Checklist(name: "List")
  lists.append(checklist)
  
  indexOfSelectedChecklist = 0  // 初回起動時はindex0が開かれるようにする
  userDefaults.set(false, forKey: "FirstTime")
  userDefaults.synchronize()
}
  • UserDefaultsに登録されているFirstTimeの値を読み込み初回起動であれば、見本のリストを作成する。
  • Checklistのオブジェクトを作成し、配列へ追加
  • indexOfSelectedChecklist = 0はComputedプロパティを使用している。
  • 次回以降は初回起動ではないのでFirstTimeの値にfalseをセットする
  • userDefaults.synchronize()はすぐに書き込みをするため。

Get Plist path

func documentsDirectory() -> URL {
  let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
  return paths[0]
}

func dataFilePath() -> URL {
  return documentsDirectory().appendingPathComponent("Checklists.plist")
}

Checklist.plistのパスを取得するための関数。このplistにチェックリストの項目を保存する。

saveChecklists()

let encoder = PropertyListEncoder()
do {
  let data = try encoder.encode(lists)
  try data.write(to: dataFilePath(), options: Data.WritingOptions.atomic)
} catch {
  print("Error encoding list array: \(error.localizedDescription)")
}
  • Checklist.plistへの保存を行う。
  • データの書き込みは失敗する可能性があるのでtry-catch

loadChecklists()

let path = dataFilePath()
if let data = try? Data(contentsOf: path) {
  let decoder = PropertyListDecoder()
  do {
    lists = try decoder.decode([Checklist].self, from: data)
    sortChecklists()
  } catch {
    print("Error decoding list array: \(error.localizedDescription)")
  }
}
  • plistから保存してあるデータを読み込む
  • これをinitで呼び出すことで、前回のデータを読み込んでいる。
    • 実際にオブジェクトが作成されるタイミングは、AppDelegate内のlet dataModel = DataModel()である。

sortChecklists()

lists.sort(by: { list1, list2 in
  return list1.name.localizedStandardCompare(list2.name) == .orderedAscending
})
lists.sort(by: { /* the sorting code goes here */ })
  • {}はclosure
  • 大文字小文字を区別しない

nextChecklistItemID() -> Int

let userDefaults = UserDefaults.standard
let itemID = userDefaults.integer(forKey: "ChecklistItemID")
userDefaults.set(itemID + 1, forKey: "ChecklistItemID")
userDefaults.synchronize()
return itemID
  • userDefaultsから"ChecklistItemID"のキーの値を取り出す
    • ここには次に使用すべきitemIDの値が格納されている
    • 登録されていない場合は「0」が帰ってくるので問題ない(itemIDは0始まり)
  • 次に登録するitemIDをインクリメントで登録する
  • userDefaults.synchronize()ですぐにUserDefaultsへ書き込むよう

AppDelegate

AppDelegate.swift

クラス設定

Import library

import UserNotifications
  • ローカル通知用のライブラリをインポート

Protocol

class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
  • ローカル通知を受け取るためにUNUserNotificationCenterDelegateを設定

Property

let dataModel = DataModel() // インスタンスはAppDelegateで作成して、これをAllListsViewControllerへ渡す

ここでdataModelの作成を行い、後にAllListViewControllerへ渡す。

application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {

let navigationController = window!.rootViewController as! UINavigationController
let controller = navigationController.viewControllers[0] as! AllListsViewController
controller.dataModel = dataModel

まずnavigationControllerを取得する。

navigationControllerの一番始めのviewControllerAllListsViewControllerである。(これはStoryboardで一目瞭然だが、2番目以降はどう取るのだろうか?)

AllListsViewControllerdataModelに先程作成したdateModelを渡してやる。

// Notification set up
let center = UNUserNotificationCenter.current()
center.delegate = self
  • ローカル通知時にdelegateメソッドを呼び出せるように自身をdelegate元に設定

saveData()

dataModel.saveChecklists()
func applicationDidEnterBackground(_ application: UIApplication) {
  saveData()
}

func applicationWillTerminate(_ application: UIApplication) {
  saveData()
}

dataModel.saveChecklists()で情報をUserDefaultsに登録するだけのメソッド。これらをAppDelegateで、アプリがsuspendや終了するタイミングで呼ぶようにする。

userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)

print("Received local notification \(notification)")
  • User Notification Delegates
  • アプリが起動中にlocal notificationがポストされたときに呼ばれる
  • 今回はログを表示するだけ

ViewControllers

AllListViewController.swift

クラスの設定

class AllListsViewController: UITableViewController, ListDetailViewControllerDelegate, UINavigationControllerDelegate {
  • ListDetailViewControllerDelegate
    • ListDetailViewControllerからの変更を受け取るためのDelegate
  • UINavigationControllerDelegate
    • NavigationControllerでこの画面に戻ってきた通知を受け取るためのプロトコル
      • navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool)

Property

let cellIdentifier = "ChecklistCell"

tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier)のときに使用するためのidentifier

var dataModel: DataModel!

Checklist.swiftの配列。これをTableViewに表示している。

viewDidLoad()

super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier)
}

再利用するためのセルを登録

viewWillAppear(_ animated: Bool)

super.viewWillAppear(animated)
tableView.reloadData()
  • delegateを使う手もあるが、簡単な方法を考える。
  • reloadDataにより、tableViewにinsertするよりもぐっと楽に書けるようになった。
  • 今回はテーブルもせいぜい14個なので、tableViewのreloatDataで対応できる。(表示されているのが100よりも大きいのであればdelegateを考え始める)

viewWillAppear(_ animated: Bool)

navigationController?.delegate = self // navigationControllerDelegateメソッドを受け取るために自分をセット

navigationControllerのDelegateに自分を設定。 「!」だとnilの場合にCrashするので「?」で安全に、ない場合は移行が処理されないだけ。

let index = dataModel.indexOfSelectedChecklist

Computed PropertyによりUserDefaultから保存されているindexを取得。indexが保存されている場合は次のメソッドで遷移する。

if index >= 0 && index < dataModel.lists.count {  // indexが-1ではない、かつindexが範囲外ではない(plistとUserDefaultは同期していないため)= indexがvalidかどうか
  let checklist = dataModel.lists[index];
  performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}

checklistオブジェクトを取得し、遷移時に渡す。 遷移時の動作はprepareを参照。 またdataModelの読み込みはAppDelegateで行っている。

Segueの設定(Storyboard)

ShowChecklist

-w511

AddChecklist

「+」ボタンからListDetailViewControllerへバインドする。

Control-drag from this new bar button to the Add Checklist scene below to add a new Show segue.

EditChecklist

Control-drag from the yellow cirlce at the top of the All Lists scene to the new scene. Select Show from the Manual Segue section of the pop-up menu.

prepare(for segue: UIStoryboardSegue, sender: Any?)

if segue.identifier == "ShowChecklist" {
  let controller = segue.destination as! ChecklistViewController
  controller.checklist = sender as? Checklist
}

segueのidentifierによって遷移時の処理を分けている。 ShowChecklistの場合は、遷移先のコントローラにperformSeguesenderで受け取ったChecklistをセットしている。

例えば下記の通り。

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  ...
  performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}
else if segue.identifier == "AddChecklist" {
  let controller = segue.destination as! ListDetailViewController
  controller.delegate = self
}

AddChecklistの場合は新規項目追加なので、再びAllListsViewControllerに戻ってきたときにDelegateメソッドで項目を受け取るため、遷移先のコントローラのdelegateを自身に設定する。

// MARK:- Navigation Controller Delegates
// 戻るボタンが押されてAllListsViewControllerが表示される場合に呼ばれる
if viewController === self {
    dataModel.indexOfSelectedChecklist = -1
}

AllListsViewControllerが表示されているならば、UserDefaultにChecklistを選択されていない状態を登録する。

tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

return dataModel.lists.count

そのSectionのRowの数を返す。

tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

// Get cell
let cell: UITableViewCell!
if let c = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) {
  cell = c
} else {
  cell = UITableViewCell(style: .subtitle,
                         reuseIdentifier: cellIdentifier)
}
  • 今、subtitleのcellを使っているので、オプショナル型のdetailTextlaben!でunwrapできる
  • Optional Bindingでもかけるが、前者のほうがスッキリしていて筆者の好み
  • reusableCellがあれば利用するようにしている理由がちょっとわからない。
// Update cell information
let checklist = dataModel.lists[indexPath.row]
cell.textLabel!.text = checklist.name
cell.accessoryType = .detailDisclosureButton

let count = checklist.countUncheckedItems()
cell.detailTextLabel!.text = count == 0 ? "All Done" : "\(count) Remaining"
  • セルの更新
    • セルに対応したデータを取り出す
    • セルに上記のデータの値を設定する
cell.imageView!.image = UIImage(named: checklist.iconName)
return cell
  • 標準の.subtitleは左にUIImageViewを持つ

tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)

dataModel.lists.remove(at: indexPath.row)

let indexPaths = [indexPath]
tableView.deleteRows(at: indexPaths, with: .automatic)

セルをスライドしたときに削除できるようにするためのメソッド。

データから削除するだけでなく、tableViewからも削除する必要があることに注意。

tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)

dataModel.indexOfSelectedChecklist = indexPath.row  // 選択された列をUserDefaultsに記録しておく

let checklist = dataModel.lists[indexPath.row]
performSegue(withIdentifier: "ShowChecklist", sender: checklist)

セルが選択されたときに呼ばれるメソッド。 対応するChecklistオブジェクトを取得し、それを持ってsegueを行う。

tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath)

セルのaccessoryButtonがタップされたときに呼ばれるメソッド。

let controller = storyboard!.instantiateViewController(withIdentifier: "ListDetailViewController") as! ListDetailViewController
controller.delegate = self

let checklist = dataModel.lists[indexPath.row]
controller.checklistToEdit = checklist

navigationController?.pushViewController(controller, animated: true)

下記はstoryboard上でsegueでしていることと同じことを、コード内で行っている。

  • storyboardからIDがListDetailViewController(設定は下記の通り)のクラスを取得。
  • 戻ってくるときのためにdelegateを自分に設定。
  • Checklistオブジェクトを設定
  • ControllerのViewを表示する

-w1011

listDetailViewControllerDidCancel(_ controller: ListDetailViewController)

navigationController?.popViewController(animated: true)

ここから3つはメソッド名の通り、listDetailViewControllerDelegateプロトコルのメソッド。

Checklistの追加・編集キャンセル時のコードは1画面前に戻るだけ。(ListDetailViewControllerを閉じる)

listDetailViewController(_ controller: ListDetailViewController, didFinishAdding checklist: Checklist)

dataModel.lists.append(checklist)
dataModel.sortChecklists()
tableView.reloadData()
navigationController?.popViewController(animated: true)
  • Checklistが新しく追加された場合に呼び出されるメソッド。
  • 処理の流れは以下の通り
    • データにアイテムを追加
    • データをソート
    • tableViewを再読込
    • 終わったら1つ前の画面に戻る。

listDetailViewController(_ controller: ListDetailViewController, didFinishEditing checklist: Checklist)

dataModel.sortChecklists()
tableView.reloadData()
navigationController?.popViewController(animated: true)
  • 既存のChecklistが変更された場合に呼び出されるメソッド。
  • 前は編集されたオブジェクトのインデックスを取得して…とやっていたが、sortChecklistreloadDataによりそれすらも必要がなくなる。
    • 個別にcellのUIを編集をせずにtableVIew全体を更新しているため
  • 終わったら1つ前の画面に戻る。

ListDetailViewController.swift

Protocol

protocol ListDetailViewControllerDelegate: class {
  func listDetailViewControllerDidCancel(_ controller: ListDetailViewController)
  func listDetailViewController(_ controller: ListDetailViewController, didFinishAdding checklist: Checklist)
  func listDetailViewController(_ controller: ListDetailViewController, didFinishEditing checklist: Checklist)
}

それぞれキャンセル時、追加時、既存のChecklistオブジェクトの変更時に呼ばれる。

クラス設定

class ListDetailViewController: UITableViewController, UITextFieldDelegate, IconPickerViewControllerDelegate

UITextFieldDelegateはTextFieldの入力変更を受け取るため。今夏はテキストフィールドが空のときにDoneボタンをDisableにするために使う。

  • IconPickerViewControllerDelegateは、アイコン選択画面からアイコン選択情報を受け取るためのプロトコル

Property

@IBOutlet weak var textField: UITextField!
@IBOutlet weak var doneBarButton: UIBarButtonItem!
@IBOutlet weak var iconImage: UIImageView!
  
weak var delegate: ListDetailViewControllerDelegate?
var checklistToEdit: Checklist?
var iconName = "Folder"
  • delegateは強参照を避けるためにweakである。
  • checklistToEditは、これが呼び出し前にセットされていれば現在編集モードと判断するためにも使う(nilならば追加モード)。
  • iconNamecheckListオブジェクトが作成されていない場合に一時保存するための変数
    • デフォルトはFolderとしてある

viewDidLoad()

super.viewDidLoad()

if let checklist = checklistToEdit {
  title = "Edit Checklist"
  textField.text = checklist.name
  doneBarButton.isEnabled = true
  iconName = checklist.iconName
}
iconImage.image = UIImage(named: iconName)
  • checklistToEditがある場合は編集モード。
    • タイトルを変更
    • テキストフィールドに編集項目の名前をプリセット
    • doneボタンをEnableにする(textFieldDelegateメソッドはこのとき呼ばれないため)
    • アイコン名をセット
  • アイコンを表示する
    • 編集モードであれば既存の項目のアイコン、追加モードであればiconNameのデフォルトFonderアイコンが表示される

viewWillAppear(_ animated: Bool)

textField.becomeFirstResponder()

表示時にtextFieldを編集状態にする

@IBAction func cancel()

delegate?.listDetailViewControllerDidCancel(self)

cancelボタン押下時の動作。delegate先のメソッドを呼ぶ。 delegatenilの可能性があるのでオプショナル。

@IBAction func done()

if let checklist = checklistToEdit {
  checklist.name = textField.text!
  checklist.iconName = iconName
  delegate?.listDetailViewController(self, didFinishEditing: checklist)
} else {
  let checklist = Checklist(name: textField.text!, iconName: iconName)
  delegate?.listDetailViewController(self,didFinishAdding: checklist)
}
  • checklistToEditがある場合(編集モードの場合)
    • オブジェクトにテキストを設定
    • delegate先の編集完了時のメソッドを呼ぶ
    • iconNameには、iconPicker(_ picker: IconPickerViewController, didPick iconName: String)で取得した値を入れる
      • ここで初めて本ちゃんのデータであるプロパティに保存される
  • checklistToEditがない場合(新規追加の場合)
    • 新たにChecklistオブジェクトを作成
    • delegate先の追加完了時のメソッドを呼ぶ

tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {

return indexPath.section == 1 ? indexPath : nil
  • sectionが0のとき、セルの選択を許可しない
  • sectionが1、即ちアイコンのrowの場合は選択を許可する

textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool

let oldText = textField.text!
let stringRange = Range(range, in:oldText)!
let newText = oldText.replacingCharacters(in: stringRange, with: string)
doneBarButton.isEnabled = !newText.isEmpty
return true
  • textFieldで変更があったときに呼ばれるメソッド。
  • NSRangeRangeに変換する必要がある。
  • また、引数のstringは変更された文字のみが渡されている。なので、これをoldTextに挿入することで、newTextを作成する。
  • doneは空ならばdisable。ifを使わずにこう書けることは1つキーポイント。

textFieldShouldClear(_ textField: UITextField) -> Bool

doneBarButton.isEnabled = false
return true
  • textFieldの「x」ボタンが押されたときに呼ばれるメソッド。
  • doneボタンをdisableにする。
  • return trueでキーボードを再表示しないようになる。

TextFieldのバインド

-w627

  • delegateをコントローラに設定。
  • Did End On Exitはキーボードのdone押下時の設定。

iconPicker(_ picker: IconPickerViewController, didPick iconName: String)

self.iconName = iconName  // 追加モードのときはこの時点でchecklistオブジェクトが存在しないので、propertyに一時保存する
iconImage.image = UIImage(named: iconName)
navigationController?.popViewController(animated: true)
  • iconPickerViewControllerでアイコンが選択された場合に呼ばれる
  • アイコン名を一時的にプロパティに保存し、アイコンの画像をUIに表示する
  • その後iconPickerViewControllerを閉じる

prepare(for segue: UIStoryboardSegue, sender: Any?)

if segue.identifier == "PickIcon" {
  let controller = segue.destination as! IconPickerViewController
  controller.delegate = self;
}
  • 自分をIconPickerViewControllerdelegateに設定してsegueで遷移する

ChecklistViewController.swift

クラス設定

class ChecklistViewController: UITableViewController, ItemDetailViewControllerDelegate {

ItemDetailViewControllerからの返答を取得するために、ItemDetailViewControllerDelegateプロトコルを設定。

Property

var checklist: Checklist!
  • ChecklistItemを配列に持つChecklistをプロパティとして有する。
  • オプショナルなのは、ShowChecklistで遷移時に常にオブジェクトは設定されているが、viewを作成した直後、例えばviewDidLoad以前にはnilの可能性があるため。

viewDidLoad()

title = checklist.name
  • 画面上部のタイトルをリストの名前に変更
  • ViewControllerはいくつかプリセットとしてpropertyを持っており、titleはその内の1つ。
  • NavigationBarはこれを探してnavigation bar内のテキスト自動で書き換える。

Segueの設定(Storyboard)

Addボタンを押したときのSegue設定

-w781

セルのAccessoryボタンを押下時のSegue設定

-w790

  • Accessory Action >> Showを選択すること、
    • Selection Segue >> Showではない

-w1116

prepare(for segue: UIStoryboardSegue, sender: Any?)

if segue.identifier == "AddItem" {
  let controller = segue.destination as! ItemDetailViewController
  controller.delegate = self
} 
  • segue実行時、identifierによって処理を分岐させる。
  • AddItemのとき、遷移先のItemDetailViewControllerdelegateを設定するのみ。
else if segue.identifier == "EditItem" {
  let controller = segue.destination as! ItemDetailViewController
  controller.delegate = self
  if let indexPath = tableView.indexPath(for: sender as! UITableViewCell) {
    controller.itemToEdit = checklist.items[indexPath.row]
  }
}
  • EditItemのとき、上記と同様に遷移先のItemDetailViewControllerdelegateを設定する。
    • またsenderで受け取ったUITableViewCell、これはtableViewの選択されたcellである。

configureCheckmark(for cell: UITableViewCell, with item: ChecklistItem)

let label = cell.viewWithTag(1001) as! UILabel

if item.checked {
  label.text = "√"
} else {
  label.text = ""
}
  • cellのチェックマークを表示するかどうかの設定をするためのメソッド。
  • このメソッドによりデータとviewの表示間で同期を取れる。
  • 引数のcellは例えば、下記の通りResuableCellにより作成している。
let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)
  • cell内のオブジェクトにtagを予め振り分けておき(下図)、viewWithTagにより、cell内のオブジェクトを取得している。

-w1100

configureText(for cell: UITableViewCell, with item: ChecklistItem)

let label = cell.viewWithTag(1000) as! UILabel
label.text = item.text
  • configureCheckmarkと同様、cellのテキストを設定をするためのメソッド。
  • このメソッドによりデータとviewの表示間で同期を取れる。

tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

return checklist.items.count
  • tableViewのrowの数を設定する

tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)

let item = checklist.items[indexPath.row]

configureText(for: cell, with: item)
configureCheckmark(for: cell, with: item)
return cell
  • tableViewに表示するcellを設定する。
  • ReusableCellを使用するために、予めPrototypeCellsにidentifierを設定しておく。(下図)
  • データから該当するデータを抜き出す
  • configureTextconfigureCheckmarkによりデータとセルの状態を同期させる

-w1102

tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)

checklist.items.remove(at: indexPath.row)

let indexPaths = [indexPath]
tableView.deleteRows(at: indexPaths, with: .automatic)
  • cellをスライドして削除できるようにする
  • データとtableViewの両方から削除を行うこと

tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)

if let cell = tableView.cellForRow(at: indexPath) {
  let item = checklist.items[indexPath.row]
  item.toggleChecked()
  configureCheckmark(for: cell, with: item)
}
tableView.deselectRow(at: indexPath, animated: true)
  • セルが選択されたときに呼ばれるメソッド
  • 選択されたcellを取得
  • 選択されたcellのデータを取得し、チェックマークの状態を反転させる
  • configureCheckmarkでcellの状態を書き換え、データとcell表示の同期をとる
  • 最後にtableViewのcell選択を解除する

itemDetailViewControllerDidCancel(_ controller: ItemDetailViewController)

navigationController?.popViewController(animated:true)
  • キャンセルボタン押下時
  • 画面を閉じる処理だけ

itemDetailViewController(_ controller: ItemDetailViewController, didFinishAdding item: ChecklistItem)

let newRowIndex = checklist.items.count
checklist.items.append(item)

let indexPath = IndexPath(row: newRowIndex, section: 0)
let indexPaths = [indexPath]
tableView.insertRows(at: indexPaths, with: .automatic)

navigationController?.popViewController(animated:true)
  • itemDetailViewControllerにて項目の追加が行われた場合に呼ばれる
  • データとtableViewの両方に項目を追加する
  • newRowIndexは追加する項目のindexになる、これはちょうどcountで返される数
  • insertRowsIndexPathの配列を渡すことが必要
  • 処理が終わればitemDetailViewControllerのviewを閉じる

itemDetailViewController(_ controller: ItemDetailViewController, didFinishEditing item: ChecklistItem)

if let index = checklist.items.index(of: item) {
  let indexPath = IndexPath(row: index, section: 0)
  if let cell = tableView.cellForRow(at: indexPath) {
    configureText(for: cell, with: item)
  }
}
navigationController?.popViewController(animated:true)
  • itemDetailViewControllerにて項目の編集が行われた場合に呼ばれる
  • checklist.items.index(of: item)により、戻ってきたデータが、checklistプロパティのどのindexに相当するかを調べる
  • tableViewのcellの参照をとってきて、configureTextでcellの表示を変更する
    • データの編集はitemDetailViewControllerに参照渡しをしている?ので向こうで編集済み
  • 処理が終わればitemDetailViewControllerのviewを閉じる

ItemDetailViewController.swift

Import Library

import UserNotifications
  • 通知オブジェクトを作成するため

Protocol

protocol ItemDetailViewControllerDelegate: class {
  func itemDetailViewControllerDidCancel(_ controller: ItemDetailViewController)
  func itemDetailViewController(_ controller: ItemDetailViewController, didFinishAdding item: ChecklistItem)
  func itemDetailViewController(_ controller: ItemDetailViewController, didFinishEditing item: ChecklistItem)
}

それぞれキャンセル時・項目追加時・項目編集時にdelegate先から呼び出すためのメソッドをProtocolで用意する

クラス設定

class ItemDetailViewController: UITableViewController, UITextFieldDelegate {

textFieldの変更を取得するために、UITextFieldDelegateプロトコルを設定。

Property

@IBOutlet weak var doneBarButton      : UIBarButtonItem!  // 編集完了ボタン
@IBOutlet weak var textField          : UITextField!      // item名の入力欄
@IBOutlet weak var shouldRemindSwitch : UISwitch!         // リマインド有無のスイッチ
@IBOutlet weak var dueDateLabel       : UILabel!          // 期限日を表示するラベル
@IBOutlet weak var datePickerCell     : UITableViewCell!  // datePickerCellのオブジェクト
@IBOutlet weak var datePicker         : UIDatePicker!     // datePickerのオブジェクト
weak var delegate : ItemDetailViewControllerDelegate?
var itemToEdit    : ChecklistItem?
var dueDate           = Date()  // UILabelから簡単に情報を取れないので、変数で保持する
var datePickerVisible = false
  • delegateプロパティはweakかつオプショナル型
  • itemToEdit、これが存在すれば編集モード、無ければ追加モードである
  • dueDateはshouldRemindSwitchと異なりUILabelから簡単に情報を取れないので、変数で保持する

viewDidLoad()

super.viewDidLoad()
if let item = itemToEdit {  // 編集モードであれば、UIをその情報に変更する
  title                   = "Edit Item"
  textField.text          = item.text
  doneBarButton.isEnabled = true
  shouldRemindSwitch.isOn = item.shouldRemind
  dueDate                 = item.dueDate
}
updateDueDateLabel()
  • itemToEdit、これが存在すれば編集モード、無ければ追加モード
  • 編集モードの場合
    • doneボタンをenableにする(textFieldDelegateが呼ばれないため)
  • 日付はitemのpropertyからは簡単に変更できないので、更新用に作成したupdateDueDateLabel()を使用する

viewWillAppear(_ animated: Bool)

super.viewWillAppear(animated)
textField.becomeFirstResponder()
  • viewの表示時にtextFieldを選択状態にする

// MARK:- Actions

@IBAction func cancel()

delegate?.itemDetailViewControllerDidCancel(self)
  • キャンセルボタン押下時に呼ばれる
  • delegateは設定されていない場合があるのでオプショナル型

@IBAction func done()

  • Doneボタン押下時
if let itemToEdit = itemToEdit {
  itemToEdit.text         = textField.text!
  itemToEdit.shouldRemind = shouldRemindSwitch.isOn
  itemToEdit.dueDate      = dueDate
  itemToEdit.scheduleNotification()
  delegate?.itemDetailViewController(self, didFinishEditing: itemToEdit)
}
  • 編集モードの場合
    • 渡されたitemToEditオブジェクトを、UI上の情報に編集
    • スケジュールの通知設定を行う
    • delegate先のメソッドを呼び出す
else {
  let item          = ChecklistItem()
  item.text         = textField.text!
  item.checked      = false
  item.shouldRemind = shouldRemindSwitch.isOn
  item.dueDate      = dueDate
  item.scheduleNotification()
  
  delegate?.itemDetailViewController(self, didFinishAdding: item)
}
  • 追加モードの場合
    • 新規にChecklistItemオブジェクトを作成して値を設定
    • スケジュールの通知設定を行う
    • delegate先のメソッドを呼び出す。

dateChanged(_ datePicker: UIDatePicker)

dueDate = datePicker.date
updateDueDateLabel()
  • データピッカーの値が変更された場合に呼ばれる
  • Propertyの期限日時を変更し、UIも更新する

shouldRemindToggled(_ switchControl: UISwitch)

textField.resignFirstResponder()

if switchControl.isOn {
  let center = UNUserNotificationCenter.current()
  center.requestAuthorization(options: [.alert, .sound]) {
    granted, error in
    // do nothing
  }
}
  • Remindのスイッチが押された場合に呼ばれる
  • textFieldの編集状態を解除する
  • RemindスイッチがONの場合
    • 通知の権限を取得するダイアログを表示する

// MARK:- Table View Delegates

tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath?

if indexPath.section == 1 && indexPath.row == 1 {
  return indexPath  // dateのrowのみ選択可能とする
} else {
  return nil
}
  • dueDateLabelのあるcellの選択のみ許可をする
    • このcellが選択された場合にdatePickerを表示するため

tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)

  • cellが選択された場合に呼ばれる
    • 今回はdueDateLabelのcellが選択された場合に限られる
tableView.deselectRow(at: indexPath, animated: true)
textField.resignFirstResponder()
  • 選択状態を解除
  • textFieldの編集状態を解除
if indexPath.section == 1 && indexPath.row == 1 {
  if !datePickerVisible { // datePickerが表示されていない場合
    showDatePicker()
  } else {
    hideDatePicker()
  }
}
  • datePickerの表示状態を反転させる

tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

if indexPath.section == 1 && indexPath.row == 2 {
  return datePickerCell   // データピッカーセルを返す
} else {
  return super.tableView(tableView, cellForRowAt: indexPath)  // 他のcellひは影響がないようにする
}
  • static cellの場合はcellForRowAtのメソッドを通常持たないが、overrideすることができる(取扱は注意が必要)
  • datePickerが表示されるcellの場合、特別に予め別に用意していたcellを返してやる

tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

if section == 1 && datePickerVisible {
  return 3
} else {
  return super.tableView(tableView, numberOfRowsInSection: section)
}
  • 「sectionが1」かつ「datePicker」が表示されている場合
    • rowは3である

tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat

if indexPath.section == 1 && indexPath.row == 2 {
  return 217
} else {
  return super.tableView(tableView, heightForRowAt: indexPath)
}
  • datePickerのcellに関して、自動ではresizeされないのでハードコーディングで指定してやる必要がある

tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int

var newIndexPath = indexPath
if indexPath.section == 1 && indexPath.row == 2 {
  newIndexPath = IndexPath(row: 0, section: indexPath.section)
}
return super.tableView(tableView, indentationLevelForRowAt: newIndexPath)
  • static cellのdata sourceをいじったのでoverrideする必要のあるメソッド
  • storyboard上で設定されていないstatic cellがあたかも存在するかのように処理を書き換える(インデントに関する設定っぽい)
    • indexPathの指定した値の理由はわからないが…

// MARK:- Text Field Delegates

TextFieldの設定(storyboard)

-w618

textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool

let oldText = textField.text!
let stringRange = Range(range, in:oldText)!
let newText = oldText.replacingCharacters(in: stringRange,
                                          with: string)
doneBarButton.isEnabled = !newText.isEmpty
return true
  • textFieldで変更があったときに呼ばれるメソッド。
  • NSRangeRangeに変換する必要がある。
  • また、引数のstringは変更された文字のみが渡されている。なので、これをoldTextに挿入することで、newTextを作成する。
  • doneは空ならばdisable。ifを使わずにこう書けることは1つキーポイント。

textFieldShouldClear(_ textField: UITextField) -> Bool

doneBarButton.isEnabled = false
return true
  • textField内の「x」ボタンの押下時
  • doneボタンをdisableにする
  • return trueで再度キーボードを表示しない

textFieldDidBeginEditing(_ textField: UITextField)

hideDatePicker()  // textFieldの編集中はdatePickerを隠す

// MARK:- Helper Methods

updateDueDateLabel()

let formatter = DateFormatter() // for converting the Date to String
formatter.dateStyle = .medium
formatter.timeStyle = .short
dueDateLabel.text = formatter.string(from: dueDate)
  • プロパティに保存されているdueDateの値でUIの表示を更新する

showDatePicker()

datePickerVisible = true
let indexPathDatePicker = IndexPath(row: 2, section: 1)
tableView.insertRows(at: [indexPathDatePicker], with: .fade)
datePicker.setDate(dueDate, animated: false)  // dueDateプロパティの日時をdatePickerに表示させる
dueDateLabel.textColor = dueDateLabel.tintColor // 編集中のときのみ日付ラベルをtintColorにする
  • datePickerのセルを表示させるためのメソッド
  • tableViewinsertRowsすることでUIが更新される?
    • 通常のデータ配列に項目を追加する代わりに、datePickerVisible = trueで処理を分岐させている
  • datePickerにはdueDateの値を表示させておく
  • datePickerの表示中は日時ラベルをtintColorにする

hideDatePicker()

if datePickerVisible {
  datePickerVisible = false
  let indexPathDatePicker = IndexPath(row: 2, section: 1)
  tableView.deleteRows(at: [indexPathDatePicker], with: .fade)
  dueDateLabel.textColor = UIColor.black // 日付ラベルを元の色に戻す
}
  • showDatePickerと逆の動き

Assets.xcassets

Icons

-w698

  • アイコンをimport
  • iOS12以上はRetinaなので1xは必要ない

IconPickerViewController.swift

Protocol

protocol IconPickerViewControllerDelegate: class {
  func iconPicker(_ picker: IconPickerViewController,
                  didPick iconName: String)
}

Property

weak var delegate: IconPickerViewControllerDelegate?
  
let icons = [ "No Icon", "Appointments", "Birthdays", "Chores",
                "Drinks", "Folder", "Groceries", "Inbox", "Photos", "Trips" ]
  • アイコンは不変なのでlet。これがtableViewに表示する内容

tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

return icons.count

tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

let cell = tableView.dequeueReusableCell(withIdentifier: "IconCell", for: indexPath)
let iconName = icons[indexPath.row]
cell.textLabel!.text = iconName
cell.imageView!.image = UIImage(named: iconName)
return cell
  • ReusableCellを用いる

tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)

if let delegate = delegate {
  let iconname = icons[indexPath.row]
  delegate.iconPicker(self, didPick: iconname)
}
  • 選択されたindexPathrowからiconNameを取得し、delegate先に渡して処理する。

起動時の画面

-w877

  • 今回はLaunchboardファイルは削除し、Main画面を起動時の画面とした
    • 読み込みが爆速な感じを出せる