「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その9[編集機能を実装する]

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その9[編集機能を実装する]

公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、というシリーズの第九回目です。前回はこちら

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その8[ナビゲーションを実装する]
公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、と...

前回はナビゲーション機能を実装し、segueを使ってview controller間でのデータのやり取りが出来るようになりました。前回は新しく料理を追加するという機能に焦点を当てましたが、今回は既存の料理を編集・削除出来るような機能を追加していきます。

この記事を書いている時点でのXcodeのバージョンは8.1です。

編集や削除機能を実装する | Implement Edit and Delete Behavior

いつものLearning Objectives(学習目的)です。このパートが終わったら、

  • 「Push」と「modal」ナビゲーションを区別する
  • 画面表示(遷移)の違いに応じてview controllerを閉じる
  • ダウンキャスト演算子の使い方を理解する
  • Optional bindingを使って複雑な条件をチェックする
  • Segue識別子を使って、どの遷移が起こっているかを判断する

ことが出来るようになるのが目標です。

編集機能が追加されることで、

sc-xcode-simulator-attempt-to-delete-meal-from-list
例えば、このように料理リストから既存の料理を削除することが可能になります。

既存の料理項目を編集する | Allow Editing of Existing Meals

前回のPart8では、新しい料理を追加して料理リストに表示させる機能を実装しました。今回は、最初に述べたように、既存の料理項目の編集機能(削除を含む)を追加していきます。

料理のリストが表示されているテーブルビューのsceneでは、

リスト表示されている料理をタップすると、それを表示する料理sceneに遷移するような機能

が必要です。また、遷移先である料理sceneでは、

データの更新後「Save」ボタンをタップすることで、料理リスト画面に戻って、新しい情報でリストを上書きするような機能

が必要です。

先ずは前者の「リスト表示されている料理をタップして、その料理sceneに遷移する」という部分を実装するため、新しくsegueを追加します。

テーブルビューのセルを設定する

もしassistant editor表示になっている場合はstandard editor表示に戻し、ストーリーボード(Main.storyboard)を開きます。

テーブルビューセルと料理用sceneをsegueで繋ぐ

Xcode: Control+ドラッグでテーブルビューセルと料理sceneを繋ぐ
ストーリーボード上でtable view controllerのテーブルビューのセルを選択、control+ドラッグして料理sceneでドロップします(上の画像参照)。

Xcode: Selection SegueでShowを選択
ドロップするとsegueの種類を選択するメニューがポップアップするので、一番上の「Selection Segue」の中にある「Show」を選択します(上の画像参照)。

新しくsegueを追加すると、segueの線とnavigation controllerが重なっているので、
Xcode: Navigation controllerを下にずらしてsegueを表示
上の画像のように、navigation controllerを下にドラッグして新しいsegueが見えるようにします。

次に、今作ったsegueを選択してattributes inspector(utility areaの左から4つ目or右から3つ目のメニュー)を開きます。

Xcode: Show segue、名前をShowDetailにセット
Attributes inspectorの「Identifier」に「ShowDetail」という名前をセットします(上の画像参照)。名前は何でも良いですが、後で使うので覚えやすい名前にします。

シミュレータで挙動を確認

シミュレータで挙動を確認してみます。既存の料理をリストからタップしてみると、

Xcode: 既存料理をタップしたのに新規追加画面が表示
このように新規料理追加の画面になってしまい、タップした料理の画面が表示されません。

Segueが2つあるのが問題

Xcode: 2つのsegueが同じsceneを指す
改めてストーリーボードを見ると明らかですが、料理リストsceneから伸びているsegueは2つあります。1つは今作ったsegueで直接料理用sceneに繋がっています。もう1つは前回作ったsegueで、navigation controllerを通して料理用sceneに繋がっています。2本のsegueが同じsceneを指していますが、何らかの方法でユーザがどっちのsegueを使うのかを識別しないといけません。

という訳で、次のセクションではsegueを識別するソースコードを実装します。

どのsegueが使われているのかを特定する

Segueが使われる時は必ずprepare(for:sender)が呼び出されるので、このメソッド(methods)を利用します。Segueの識別には、attributes inspectorの「Identifier」で指定した名前を利用します。前回追加したmodal segueのidentifierは「AddItem」で、今回追加したshow segueのidentifierは「ShowDetail」です。

ここでは先ず、テーブルビューから料理の情報(Mealオブジェクト)を取り出し、それをMealViewControllerへ渡すコードを実装します。

prepare(for:sender)メソッドのコメントを外す

MealTableViewController.swiftを開いて、prepare(for:sender)を探します。// MARK: Navigationコメントの下にあるので、function menuから「Navigation」で探すと早いかもしれません。

/* */のコメントで囲まれているので、コメントを外します。

// MARK: - Navigation

// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    // Get the new view controller using segue.destinationViewController.
    // Pass the selected object to the new view controller.
}

デフォルトのメソッドは上記のようになっているはずです。

条件分岐でsegueを区別する

メソッド内部にあるコメントを消して、以下のif文を追加します。

if segue.identifier == "ShowDetail" {
} else if segue.identifier == "AddItem" {
}

segue.identifierでIdentifierを取り出して、それがsegueの名前と一致するかどうか判定しています。

SegueからMealViewControllerを取り出す

実装したいのは既存の料理情報を編集する機能なので、"ShowDetail"のsegueの方にコードを追加していきます。先ずsegueからdestination view controller(終点側のview controller)を取り出します。

if segue.identifier == "ShowDetail" {
    let mealViewController = segue.destination as! MealViewController
}

segue.destinationの型はUIViewControllerなので、該当する型にダウンキャスト(downcasting)する必要があります。今segueの終点の型がMealViewControllerなので、これにダウンキャストします。

ここではas!で強制的にMealViewControllerにダウンキャストしていますが、もしキャストするサブクラスの型が間違っている場合は実行時エラーを起こしてクラッシュします。確実にMealViewControllerかどうか分からない場合は、as?optional bindingを組み合わせてダウンキャストします。

senderをテーブルセルの型MealTableViewCellにダウンキャスト

タイトル通りですが、次は2つ目のパラメータsenderMealTableViewCellにダウンキャストします。

// Get the cell that generated this segue.
if let selectedMealCell = sender as? MealTableViewCell {
}

senderMealTableViewCellにダウンキャスト出来た場合は、それをローカル定数selectedMealCellに格納して、if文の中身を実行します。ダウンキャストに失敗した場合(senderMealTableViewCellじゃなかった場合)は、if文がスキップされます。

Meal配列要素をMealViewControllerが持つMealオブジェクトに渡す

If文の中には以下のコードを追加します。

if let selectedMealCell = sender as? MealTableViewCell {
    let indexPath = tableView.indexPath(for: selectedMealCell)!
    let selectedMeal = meals[indexPath.row]
    mealViewController.meal = selectedMeal
}

If文内部1行目では、optional bindingで取り出したセルの指す行(index path)を取り出しています。メソッドindexPath(for:)の返り値はオプショナル型のIndexPathIndexPath?)なので、forced unwrappingしています。

2行目では1行目で取り出した行の情報を使って、Mealオブジェクトの配列(Array)から該当する配列要素を取り出し、定数selectedMealに入れています。

3行目ではMealViewControllerのインスタンスが持つmeal変数(mealViewController.meal)に、2行目で取り出したselectedMealを代入しています。

MealTableViewControllerのprepare(for:sender:)メソッド実装後

ここまでで実装したprepare(for:sender:)メソッド全体は

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "ShowDetail" {
        let mealViewController = segue.destination as! MealViewController

        // Get the cell that generated this segue.
        if let selectedMealCell = sender as? MealTableViewCell {
            let indexPath = tableView.indexPath(for: selectedMealCell)!
            let selectedMeal = meals[indexPath.row]
            mealViewController.meal = selectedMeal
        }
    } else if segue.identifier == "AddItem" {
        print("Adding new meal.")
    }
}

このようになっているはずです。else if構文の方へは、特に何も実装する必要がないようですが、公式ページと同様にprint(_:)を挿入しておきます。

MealViewControllerのviewDidLoad()メソッドを編集する

ここまでで、料理リストsceneのテーブルセルに表示されている料理情報(Meal配列の要素)を、MealViewControllerが持っているMealオブジェクトに渡しました。

次は、渡したMealオブジェクトの内容を、MealViewControllerのUIに表示させる必要がありますので、viewDidLoad()メソッドを編集して行きます。

Mealオブジェクトの中身をアウトレットに渡す

MealViewController.swiftを開き、viewDidLoad()メソッドを探します。viewDidLoad()

override func viewDidLoad() {
    super.viewDidLoad()

    // Handle the text field’s user input through delegate callbacks.
    nameTextField.delegate = self

    // Enable the Save button only if the text field has a valid Meal name.
    checkValidMealName()
}

このようになっているはずです。nameTextField.delegate ...の下に、以下のコードを追加します。

// Set up views if editing an existing Meal
if let meal = meal {
    navigationItem.title = meal.name
    nameTextField.text   = meal.name
    photoImageView.image = meal.photo
    ratingControl.rating = meal.rating
}

mealプロパティはオプショナル型なので、optional bindingでチェックしてnilかどうかのチェックを実行しています。ここでmealnilにならないのは、既存の料理を編集している場合だけで、新規料理追加の時はmealnilになっています。したがって、mealの中身を取り出して対応するアウトレットに代入するのは、既存の料理を編集する時のみということになります。

編集後のviewDidLoad()全体を載せると、

override func viewDidLoad() {
    super.viewDidLoad()

    // Handle the text field’s user input through delegate callbacks.
    nameTextField.delegate = self

    // Set up views if editing an existing Meal
    if let meal = meal {
        navigationItem.title = meal.name
        nameTextField.text   = meal.name
        photoImageView.image = meal.photo
        ratingControl.rating = meal.rating
    }

    // Enable the Save button only if the text field has a valid Meal name.
    checkValidMealName()
}

このようになっているはずです。

シミュレータで動作確認

シミュレータを起動して、既存の料理を選択、適当に編集して「Save」ボタンをタップすると、

Xcode: シミュレータで既存料理を編集して保存、新規料理が追加される
このように、既存料理を編集したのに、編集した内容が新規料理として新たに保存されてしまいます(上の画像で4行目の項目)。次のセクションでは、「Save」をタップしたら上書きされるようにソースコードを変更します。

MealTableViewControllerのunwindToMealList(_:)に料理情報を更新する機能を実装する

料理リストに表示されている料理情報を上書きするには、MealTableViewControllerに実装したunwindToMealList(_:)を2つのケースに条件分岐させます。1つは新規料理の追加で、もう1つが既存料理の上書きです。

MealTableViewController.swiftを開いて、unwindToMealList(_:)を探します。

@IBAction func unwindToMealList(_ sender: UIStoryboardSegue) {
    if let source = sender.source as? MealViewController, let meal = source.meal {
        // Add a new meal.
        let newIndexPath = IndexPath(row: meals.count, section: 0)
        meals.append(meal)
        tableView.insertRows(at: [newIndexPath], with: .bottom)
    }
}

新規料理追加機能はすでに実装しているので、既存料理を編集するコードを条件分岐を使って実装します。

選択しているセルのインデックスを取得する

既にあるif文(if let source = ...)の下に、

if let selectedIndexPath = tableView.indexPathForSelectedRow {
}

という新しいif文を追加します。テーブルビューのプロパティtableView.indexPathForSelectedRowは、今選択されている行(row)を指すプロパティです。したがって、このif文はユーザが料理リストの中からある料理(セル)をタップした、という条件になります。

新しい料理情報に更新してテーブルビューをリロードする

先程実装したif文の中にコードを追加します。分かりやすくするためにif文も一緒に載せています。

if let selectedIndexPath = tableView.indexPathForSelectedRow {
    // Update an existing meal.
    meals[selectedIndexPath.row] = meal
    tableView.reloadRows(at: [selectedIndexPath], with: .none)
}

ユーザが選択しているテーブルセルの情報(selectedIndexPath)を取得したので、1行目ではそれを使って配列要素の情報を更新しています。

    meals[selectedIndexPath.row] = meal

また2行目では、情報を更新したのでテーブルビューセルをリロードしています。

    tableView.reloadRows(at: [selectedIndexPath], with: .none)

1つ目のパラメータは選択されたテーブルビューのインデックスを指定(配列要素として)、2番目のパラメータはアニメーションの設定ですが、ここではnoneを指定しているので「アニメーション無し」になっています。

既存のコードをelse構文で囲む

最後に既存のコード(新規料理を追加するコード)をelse構文で囲みます。

else {
    // Add a new meal.
    let newIndexPath = IndexPath(row: meals.count, section: 0)
    meals.append(meal)
    tableView.insertRows(at: [newIndexPath], with: .bottom)
}

既存のコードにelseを追加したので、インデント(字下げ)が必要ですが、インデントはコードを選択した状態でcontrol+Iです。このelse文に対応するのは、新たに追加した方のif文です。したがって、else文はテーブルビューでセルが一つも選択されなかった場合(つまり、新規料理を追加する場合)に実行されます。

最終的に、unwindToMealList(_:)

@IBAction func unwindToMealList(_ sender: UIStoryboardSegue) {
    if let source = sender.source as? MealViewController, let meal = source.meal {
        if let selectedIndexPath = tableView.indexPathForSelectedRow {
            // Update an existing meal.
            meals[selectedIndexPath.row] = meal
            tableView.reloadRows(at: [selectedIndexPath], with: .none)
        }
        else {
            // Add a new meal.
            let newIndexPath = IndexPath(row: meals.count, section: 0)
            meals.append(meal)
            tableView.insertRows(at: [newIndexPath], with: .bottom)
        }
    }
}

このようになっているはずです。

シミュレータで動作確認

これで準備完了なのでビルドして、シミュレータを起動します。先程と同様に、既存の料理を適当に編集して「Save」をタップすると、

Xcode: 既存のエントリーを更新する機能を実装した後
今度はちゃんと編集対象の料理情報が更新されました。

既存料理の編集途中でキャンセルする | Cancel an Edit to an Existing Meal

既存の料理情報を編集して更新する機能を実装したので、次は編集途中に編集自体をキャンセルする機能を実装します。

Pushまたはmodalな遷移(segue)

試してみると分かりますが、既存の料理を編集する場合に、途中で「やっぱりやめた」と思って「Cancel」ボタンを押しても何も起こりません。これは「Cancel」ボタンに必要な機能が実装されていないからです。

The type of dismissal depends on the type of presentation. You’ll implement a check that determines how the current scene was presented when the user taps the Cancel button.

と公式ページにも書いてあるように、表示されている画面をどのように閉じるかは「type of presentation」に依るとのことですが、もっと具体的に言うと「segueの種類」によって違います。

新規料理追加の画面遷移にはmodal segueを使いましたが、この場合はdismiss(animated:completion:)でページを閉じました。既存料理の編集にはpushナビゲーションを使っているので、navigation controllerを使ってページを閉じる必要があるようです。

という訳で、pushナビゲーションに対応した「キャンセル」機能を実装していきます。

MealViewControllerのcancel(_:)アクションメソッドを編集

MealViewController.swiftを開いて、cancel(_:)アクションメソッドを探します。

// MARK: Navigations
@IBAction func cancel(_ sender: UIBarButtonItem) {
    dismiss(animated: true, completion: nil)
}

先程も述べましたが、dismiss(animated:completion:)は「Add」ボタン(+ボタン)用のアクションメソッドですから、編集用のメソッドを追加しないといけません。

Modal遷移時にセットされるpresentingViewController

先ず「新規料理を追加」なのか「既存料理を編集」なのかを区別するためのフラグを作ります。メソッドの一番上に、

// Depending on style of presentation (modal or push presentation),
// this view controller needs to be dismissed in two different ways.
let isPresentingInAddMealMode = presentingViewController is UINavigationController

を追加します。presentingViewControllerは「modal」な遷移で表示した場合にセットされるプロパティで、今見えているscene(view controller)を表示しているview controllerを指すようです。ややこしいですが、画面に見えているview controllerのことではありません。

表示されているview controllerの直前にあるview controller、と言った方が分かりやすいかもしれません

定数の名前から分かるように

let isPresentingInAddMealMode

は新規料理を追加する場合にtrueとなるようなフラグです。今modalな遷移を実装しているのは新規料理を追加する方の遷移ですから、

新規料理を追加 ↔ isPresentingInAddMealMode == true

という対応関係になります。つまり、presentingViewControllerに対する条件判定が真になれば良いということです。

改めてストーリーボードを見ると分かりますが、新規料理追加の遷移ではnavigation controllerがMealViewControllerを表示しているので、presentingViewControllerの型がUINavigationControllerかどうか、という条件判定が良さそうだということが分かります。このようなロジックで、

let isPresentingInAddMealMode = presentingViewController is UINavigationController

という条件判定が作られています。

Pushナビゲーションの場合はpresentingViewControllernilになるので、

let isPresentingInAddMealMode = presentingViewController != nil

でも良いと思います。

if文に新規料理追加をキャンセルするdismissメソッドを入れる

元々実装していたdismiss(animated:completion:)はif文の中に入れます。

if isPresentingInAddMealMode {
    dismiss(animated: true, completion: nil)
}

これでdismiss(animated:completion:)は、新規料理が追加された時のみ実行されます。

else文に既存料理編集をキャンセルするメソッドを追加する

既存の料理を編集するケースをキャンセルするため、今追加したif文に対応したelse文を実装します。

else {
    navigationController!.popViewController(animated: true)
}

pushナビゲーションで遷移した場合、遷移先のview controllerがnavigation stackに追加されます。表示されているview controllerを閉じる(非表示にする)には、そのview controllerをnavigation stackから取り出す(取り除く)必要があります。それを実行しているのが、popViewController(animated:)です。

「push」や「pop」という表現はstackへの出し入れに対して良く使われる表現で、シェル系なら「pushd」や「popd」コマンド、C言語などでは可変長配列vectorなどが持つ関数push_backやpop_backなどが、同じような意味合いで使われています。

これでメソッドcancel(_:)は、

@IBAction func cancel(_ sender: UIBarButtonItem) {
    // Depending on style of presentation (modal or push presentation),
    // this view controller needs to be dismissed in two different ways.
    let isPresentingInAddMealMode = presentingViewController is UINavigationController

    if isPresentingInAddMealMode {
        dismiss(animated: true, completion: nil)
    }
    else {
        navigationController!.popViewController(animated: true)
    }
}

このようになっているはずです。

シミュレータで動作確認

ここまでで一旦ビルドして動作確認してみます。料理を新規追加した場合、また既存の料理を編集した場合のどちらでも「Cancel」ボタンで料理リストに戻れることを確認します。

リストから料理を削除する機能を実装する | Support Deleting Meals

ここまでで、

  • 既存の料理を編集し、更新された情報を料理リストに反映する機能
  • 編集時にキャンセルして料理リストに戻る機能

を実装しました。次は

料理リストに表示されている料理を削除する機能

を実装します。これまでテーブルビューには「見る」機能しかありませんでしたが、テーブルセルを削除するためには「編集」する方法が必要です。

先ずはnavigation barに「編集」ボタンを追加する所から始めます。

テーブルビューにEditボタンを追加する

MealTableViewController.swiftを編集

MealTableViewController.swiftを開き、viewDidLoad()を探します。

override func viewDidLoad() {
    super.viewDidLoad()

    // Load the sample data.
    loadSampleMeals()
}

上記のコードのようになっているはずですが、super.viewDidLoad()の下に以下のコードを挿入します。

// Use the edit button item provided by the table view controller.
navigationItem.leftBarButtonItem = editButtonItem

editButtonItemはナビゲーションバーに「Edit」と「Done」という表示が切り替わるボタンを設置するプロパティです。

Xcode 7.xではeditButtonItemはメソッドでしたが、プロパティ(property)になっているので注意です。

それをnavigation barの左側のボタン(leftBarButtonItem)としてセットしています。

シミュレータで動作確認

ビルドしてシミュレータを起動すると、

Xcode: テーブルビューにEditボタンを追加
このようにnavigation barの左側に新たに「Edit」というボタンが追加されているはずです。「Edit」ボタンをタップすると、編集モードに切り替わりますが、まだ削除機能を実装していないのでリストから料理を削除することは出来ません。

次はテーブルビューの編集機能を有効化するために、メソッドを2つ実装します。

料理を削除する

tableView(_:commit:forRowAt:)メソッド

MealTableViewController.swiftでtableView(_:commit:forRowAt:)を探します。コメントアウトされているはずなので、コメント/* */を外すと、デフォルトでは

// Override to support editing the table view.
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        // Delete the row from the data source
        tableView.deleteRows(at: [indexPath], with: .fade)
    } else if editingStyle == .insert {
        // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
    }
}

のようになっているはずです。

指定した行のデータを配列から削除するコードを、コメント// Delete the row from the data sourceの下に挿入します。

// Delete the row from the data source
meals.remove(at: indexPath.row)

今選択している行のインデックスはindexPath.rowで取得します。この下にデフォルトで実装されている

tableView.deleteRows(at: [indexPath], with: .fade)

は、テーブルビューからindexPathで選択された行を削除するメソッドです。

tableView(_:canEditRowAt:)メソッド

また、メソッドtableView(_:canEditRowAt:)のコメントも外しておきます。

// Override to support conditional editing of the table view.
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    // Return false if you do not want the specified item to be editable.
    return true
}

このメソッドがtrueを返す場合は、全てのセルが編集可能(今回は削除)になるというメソッドです。

補足ですが、このメソッドをコメントアウトしたままだと(メソッドをオーバーライドしない場合)、デフォルトの振る舞いは「全てのセルが編集可能」になるようです。

tableView(_:canEditRowAt:)
The method permits the data source to exclude individual rows from being treated as editable. Editable rows display the insertion or deletion control in their cells. If this method is not implemented, all rows are assumed to be editable.

つまり、このメソッドをコメントアウトしたままでも同様に動くのではないかと思います。一応このメソッド有り・無しの場合で動作確認してみましたが、特に違いがありませんでした。

シミュレータで動作確認

ビルドしてシミュレータを起動、

Xcode: 料理リストから料理を削除
左端の「Edit」をタップすると、各テーブルセルの左側にマイナスが赤丸で囲まれたようなボタンが出てきます(上の画像参照)。それをタップすると、テーブルセルが左にスライドして、セルの右側に「Delete」ボタンが出てきます。この「Delete」ボタンをタップすると、該当するテーブルセルを削除することが出来ます。

また、「Edit」ボタンを押さずに、テーブルセルを左にスワイプしても「Delete」ボタンが出てきます。これはデフォルトでテーブルビューに実装されている機能のようです。

まとめ

今回は、既に登録してある料理の情報を編集する機能と、表示されているリストから料理を削除する機能を実装しました。次回はいよいよ最終回ですが、編集(新規追加も含む)した内容を保持するための機能を追加します。

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その10[データを保持する]
公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、と...
スポンサーリンク
広告1
広告1

シェアする

フォローする