Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 398 - Implement Edit and Delete Behavior

チュートリアル索引に戻る→
2016年1月22日改訂:コメントで指摘された間違いを修正しました。あんどうさん、ありがとうございました。

2015年10月25日改訂:環境環境:Xcode7.1Swift2.1

developer.apple.com

このレッスンで習う項目は以下の通りです。

  • pushとmodalナビゲーションの相違点
  • プレゼンテーションスタイルに合わせてview controllerを終了
  • ダウンキャスト時のタイプキャスト・オペレータの使い分け
  • 複雑な条件式を判定するためのオプショナル・バインディングの影響力
  • セグエのidentifierを使って、どのセグエを発動させるか決定

「+」ボタンを押したらMealViewControllerに切り替えて新しい入力を受け付けることができるようになりました。リストにある既存の項目をタップすることで同じMealViewControllerに切り替えてデータを編集できるようにします。

Main.storyboardにあるMealTableVIewの「cellを選択して、「Ctrl + ドラッグ」して、MealViewControllerに引っぱってきます。

f:id:yataiblue:20151028194404j:plain

MealViewControllerの上でドラッグをリリースすると、セグエのメニューが出てくるので「show」を選びます。

f:id:yataiblue:20151028194450j:plain

これで新しいセグエが作成されます。しかし、そのままでは確認しにくいので、間にあるNavigation Controllerをちょっと下にずらすと下のように新しくできたセグエが見えてきます。

f:id:yataiblue:20151028194707j:plain

セグエは「Ctrl + ドラッグ」で作ることができるので、コードで作る必要はありません(コードで作れない訳ではないです)。セグエの利用にIdentifierは不可欠です。今作ったセグエを選択します。選択するとセグエが関係づけられているオブジェクトが選択状態になります。

f:id:yataiblue:20151028212629j:plain

その状態でアトリビュート・インスペクタを開いて、Identifierに「ShowDetail」を設定します。

f:id:yataiblue:20151028212722j:plain

セグエを利用するためにインスタンス化がされました。次にMealTableViewControllerが持っているメソッドを使ってセグエ・インスタンスを使用します。このメソッドprepareForSegue(_:sender:)です。

// MARK: - Navigationの下に 「/*」と「*/」でコメントアウトしているprepareForSegue(_:sender:)メソッドを復活させます。「override」キーワードが付いているので既にこのメソッドは継承されているということで、ViewControllerへの実装と少し異なります。

このメソッドに引数として与えるのが「segue」と「sender」です。「segue」には、2つ設定がされています。前章で設定したセグエのIdentifierは「AddItem」で、この章で設定したセグエは「ShowDetail」です。したがって、メソッドで区別する必要があります。

// このチュートリルスタイル(本家アップル)
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if segue.identifier == "ShowDetail" {
  // コーディング
 }
 else if segue.identifier == "AddItem" {
  // コーディング
 }
}

上記のアップルが使っている「if」は選択肢が少ないときにはいいのですが、やはり自分で開発する場合は、スタンフォード大学のポール先生が勧めるように「switch」が望ましいと思います。

// 私はこれでいきます。
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if let identifier = segue.identifier {
  switch identifier {
   case "ShowDetail":
    // コーディング
   case "AddItem" :
    // コーディング
   default: return
  }
 }
}

ここからしばらくアップルのコードと私のコードを並べて行きます。

前にも説明したようにセグエの目的は新しいViewControllerを生成させることです。この場合、MealViewControllerを生成しなければならないので、let mealDetailViewController = segue.destinationViewController as! MealViewControllerを最初のifブロックに入れます。

// アップル
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if segue.identifier == "ShowDetail" {
  let mealDetailViewController = 
segue.destinationViewController as! MealViewController
 }
 else if segue.identifier == "AddItem" {
  // コーディング
 }
}

// 私のスタイル
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if let identifier = segue.identifier {
  switch identifier {
   case "ShowDetail":
      guard let mealDetailViewController = 
segue.destinationViewController as? MealViewController else {
        return
      }
// 「!」強制アンラップを使いたくないので「?」に切り替えて「guard」を使う。
   case "AddItem" :
    // コーディング
   default: return
  }
 }
}

ここで「as!」の説明が入ります。segueのプロパティ、destinationViewControllerはオプショナル型なので、必ずアンラップしないとプロパティとして使えません。また、destinationViewControllerはViewController型なので、MealViewControllerにダウンキャストしなければならないので「as」を使って、オプショナル型からアンラップするために「!」を使うので、「as!」を「強制アンラップ(foreced type case operator)」と呼びます。

「?」でなく「!」を使うのは、必ずオブジェクトが存在することが分かっている時です。もしnilの可能性がある場合は「?」にした方が無難でしょう。この場合Main.storyboardにMealTableViewControllerは存在するので「!」でも構わないということです。しかし、私は「!」を使いたくないので「?」を「guard」との組み合わせで使います。

次に、senderはAnyObjectなので必ずダウンキャストしないと使えません。senderは当然、MealTableViewCellになるので次のコードを加えます。

// アップル本家
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if segue.identifier == "ShowDetail" {
  let mealDetailViewController = 
segue.destinationViewController as! MealViewController
  if let selectedMealCell = sender as? MealTableViewCell {
   // コーディング
  }
 }
 else if segue.identifier == "AddItem" {
  // コーディング
 }
}


// 私のスタイル
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if let identifier = segue.identifier {
  switch identifier {
   case "ShowDetail":
      guard let mealDetailViewController = 
segue.destinationViewController as? MealViewController else {
        return
      }
    guard let selectedMealCell = 
sender as? MealTableViewCell else {
        return
      }
   // コーディング
   case "AddItem" :
    // コーディング
   default: return
  }
 }
}

アップルのifブロックは、iOS Frameworks利用では常套手段の「if let」を使ってオプショナル型のnilチェックとアンラップを同時にするやり方です。私は「guard」を使っています。

// これはアップルのチュートリアルのオリジナルコード
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if segue.identifier == "ShowDetail" {
  let mealDetailViewController = 
segue.destinationViewController as! MealViewController
  if let selectedMealCell = sender as? MealTableViewCell {
   let indexPath = tableView.indexPathForCell(selectedMealCell)!
   let selectedMeal = meals[indexPath.row]
   mealDetailViewController.meal = selectedMeal
  }
 }
 else if segue.identifier == "AddItem" {
  // コーディング
  print("Adding new meal.")
 }
}

「if-let」のオプショナルチェックとアンラップの方法は、API利用時のオプショナルが入り乱れる場合、複雑な入れ籠をつくりコードの可読性が損なわれてしまいます。それを解消するためにSwift2から「guard」が実装されました。

// Swift2スタイル、私のスタイルです。
override func prepareForSegue(segue: UIStoryboardSegue, 
                 sender: AnyObject?) {
 if let identifier = segue.identifier {
  switch identifier {
   case "ShowDetail":
   guard let mealDetailViewController = 
segue.destinationViewController as? MealViewController else {
    return
   }
   guard let selectedMealCell = 
sender as? MealTableViewCell else {
    return
   }
   guard let indexPath = 
tableView.indexPathForCell(selectedMealCell) else {
    return
   }
   let selectedMeal = meals[indexPath.row]
   mealDetailViewController.meal = selectedMeal
        
  case "AddItem":
   print("Adding new meal.")
   default: return
  }
 }
}

MealTableCellの位置決めが必要なので、tableViewのインデックスパスを抜き取ります。indexPathForCell()メソッドもオプショナル型のIndexPathを返すので「guard」を使ってアンラップします。

食事内容を抜き出すステップとしてインデックスパスを使ってアレー型のmealsから情報をゲットします。そしてこの食事データをセグエで生成されたMealTableViewControllerに渡します。

MealTableViewControllerのセグエで生成するMealTableViewにデータを渡すだけでは画面上に反映されません。ここでラン(Cmd + R)してcellをタップしてMealViewControllerに画面が移り変わっても内容は反映されていません。

どこでMealTableViewControllerの情報をアップデイトさせるのか? そうです。viewDidLoad()ですね。

override func viewDidLoad() {
 super.viewDidLoad()
        
 nameTextField.delegate = self       
        
 checkValidMealName()
}

この中でテキストフィールドのデリゲーションの下に情報アップデイトの手順を組み込む。

override func viewDidLoad() {
 super.viewDidLoad()
        
 nameTextField.delegate = self       
   
 if let meal = meal {
  navigationItem.title = meal.name
  nameTextField.text = meal.name
  photoImageView.image = meal.photo
  ratingControl.rating = meal.rating
 }
     
 checkValidMealName()
}

[重要] このステップも上手くコーディングしてます。感心しているだけじゃいけないんですよね。こういうコーディングの仕方を私は覚えなければならないんですが忘れてしまいます。何度も繰り返してチュートリアルを繰り返さないと覚えることができない情けない50オヤジの頭です(T_T) MealViewControllerのプロパティmealはオプショナルです。ということは、「if let」を使うことで、mealに値があるかどうか判断して、値があればアンラップを同時にさせるステップを組み込めばいいわけです。もし新規にMealViewControllerを作った時は、mealがnilなのでこのステップは無視されます。

ここでラン(Cmd + R)させて、既存のcellをタップすると、編集できるようになりました。でも、注意してください。「save」を押すと新しい項目が増えて内容が重複してしまうので次の課題です。

f:id:yataiblue:20151029144053j:plain

編集画面にセグエで切り替えられるようになったのですが、MealViewControllerを使ってデータの編集をして「saveButton」を押すと、MealTableViewControllerでtableViewに新しい値が加えられています。当然編集して元のデータを書き換える必要があります。

セグエで、source view controllerdestination view controllerを使い分けて繋いでいかないといけません。MealViewControllerで編集をした後MealTableViewControllerにデータを返します。このとき新規のデータか既存データなのか判断するのはMealTableViewControllerです。

MealTableViewControllerにあるメソッドunwindToMealList()メソッドで新規データか既存データの上書きなのか判断させます。

@IBAction func unwindToMealList(sender: UIStoryboardSegue) {
        
 if let sourceViewController = 
sender.sourceViewController as? MealViewController,
                   meal = sourceViewController.meal {
¡
 // Add a new meal.
 let newIndexPath = 
NSIndexPath(forRow: meals.count, inSection: 0)
 meals.append(meal)
 tableView.insertRowsAtIndexPaths([newIndexPath], 
                       withRowAnimation: .Bottom)
 }
}

このメソッドには新しいcellを加えるステップだけコーディングされています。

既存データの編集かどうかは、テーブルビューのインデックスパス(IndexPath)が存在するかどうかで判定させます。

特定のcellをクリックするとインデックスパスが発生します。これ「if-let」を使って判断させるんです。

if let selectedIndexPath = tableView.indexPathForSelectedRow { }

このifブロックは、選択されたcellが存在すれば、「{}」内が実行されます。

ブロック内に次のコードを加えます。

meals[selectedIndexPath.row] = meal
tableView.reloadRowAtIndexPaths([selectedIndexPath], witshRowAnimation: .None)

ここで耳慣れないメソッドが出てきました。reloadRowsAtIndexPaths()という行単位でリロードするメソッドです。

func reloadRowsAtIndexPaths(_ indexPaths: [NSIndexPath],
           withRowAnimation animation: UITableViewRowAnimation)

2つ目のパラメーターは「UITableViewRowAnimation型」なのでどういうものか調べてみると次のようなものでした。

enum UITableViewRowAnimation : Int {
    case Fade
    case Right
    case Left
    case Top
    case Bottom
    case None
    case Middle
    case Automatic
}

このメソッドでリロードさせると表示もアップデイトされます。これをunwindToMealList()メソッドにコードする。

// MealTableViewControllerの実装です。
@IBAction func unwindToMealList(sender: UIStoryboardSegue) {
        
 if let sourceViewController = 
sender.sourceViewController as? MealViewController,
                      meal = sourceViewController.meal {
 if let selectedIndexPath = tableView.indexPathForSelectedRow {
     meals[selectedIndexPath.row] = meal
     tableView.reloadRowsAtIndexPaths([selectedIndexPath], 
                 withRowAnimation: .None)
  } else {
 // Add a new meal.
 let newIndexPath = NSIndexPath(forRow: meals.count, inSection: 0)
 meals.append(meal)
 tableView.insertRowsAtIndexPaths([newIndexPath], 
            withRowAnimation: .Bottom)
  }
 }
}

これで問題なく編集ができてセーブできるようになりました。withRowAnimation:の値を変えると色々な効果が試せます。しかし、テーブルビューを取り扱うメソッド群を覚えるのが大変です...

次はキャンセルボタンの実装です。

MealViewControllerのキャンセルボタンを押した時に、元の状態に戻すのは、どのタイプの表示をしているかに依存します。もし、「Presented Modally」、「Add」ボタンを押してMealViewControllerに移ってきたのなら、「dismissViewControllerAnimated(_: completion: )メソッドを使ってキャンセルします。テーブルビューのcellをクリックして「Push Navigation」でMealViewControllerに移ってきたのならナビゲーションコントローラーを使ってキャンセルさせます。

まずMealViewController.swiftを開きます。下記のcancel(_: )メソッドを拡張させます。

@IBAction func cancel(sender: UIBarButtonItem) {
 let isPresentingInAddMealMode = 
presentingViewController is UINavigationController
  if isPresentingInAddMealMode {
   dismissViewControllerAnimated(true, completion: nil)
  } else {
   navigationController!.popViewControllerAnimated(true)
  }     
}

本当に色々なプロパティやメソッドがあります。「isPresentingInAddMealMode = presentingViewController is UINavigationController」も覚えるしかないでしょう(T_T)

presentingViewControllerがUINavigationControllerだったら、isPresentingInAddMealModeは「true」です。

ということは、現在表示しているMealViewControllerの持っているメソッド「dismissViewControllerAnimated(_, completion:)」を使って自分自身を消します。するとそれをコントロールしていたNavigation ControllerによってMealTableViewControllerに戻るということでしょうか?

isPresentingInAddMealModeが「false」ということは、MealTableViewCellがクリックされてセグエ経由でMealViewControllerのインスタンス化が生じているので、MealTableViewControllerは消されているはずです。navigationController(MealTableViewControllerをコントロールしているやつ)を使って1番上の階層にあるMealViewControllerを取り除いて下に隠れているMealTableViewControllerを表示させているということだろうか???? 復習をしていても良くわからないコーディングです。

最後の課題は「消去」ボタンの実装です。

[重要] ナビゲーション・バー(Navigation Ba)rにBar Button Itemの「+」ボタンを組み込む時、オブジェクト・ライブラリから選んで、ドラッグ&ドロップして、アトリビュート・インスペクタの「System Item」から「Add」を選んで実装しました。

コーディングにはバリエーションがあります。今回はナビゲーション・バー(Navigation Bar)に「Edit」ボタンをコードを使って実装させます。

MealTableViewControllerを開いて、viewDidLoad()に「navigationItem.leftBarButtonItem = editButtonItem()」を加えます。

override func viewDidLoad() {
 super.viewDidLoad()
    
 navigationItem.leftBarButtonItem = editButtonItem()
        
 // Load the sample data.
 loadSampleMeals()
}

たった1行のコードを書いてラン(Cmd + R)して下さい。何も反応はしませんが左隅に「Edit」ボタンができています。

f:id:yataiblue:20151029154907j:plain

編集モードは、UITableViewControllerのデリゲーションメソッドで扱うのが最も簡単で分かりやすいようです。

tableView(_:commitEditingStyle:forRowAtIndexPath:)」というメソッドを実装したら編集をすべて引き受けてくれるようです。理屈を考えるのではなく利用するというスタンスに立たなければならないのがデリゲーションメソッドです。

当然overrideキーワード付きのメソッドコメントアウトしたテンプレートが用意されています(理由は以前説明しました)。/* */コメントを消すと以下のコードが出てきます。

override func tableView(tableView: UITableView, 
      commitEditingStyle editingStyle: UITableViewCellEditingStyle, 
           forRowAtIndexPath indexPath: NSIndexPath) {
 if editingStyle == .Delete {
  // Delete the row from the data source
  tableView.deleteRowsAtIndexPaths([indexPath], 
                             withRowAnimation: .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」のコメントの所に次のコードを入力する。

meals.removeAtIndex(indexPath.row)

そしてもう一つデリゲーションメソッドの「override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {}」をコメントアウトします。

この領域は理屈じゃないです。利用するという立場を貫かないといつまで経ってもiOSアプリは作れないでしょう。

結局のところMealTableViewControllerの実装すべきデリゲーションメソッドは2つ以下の通りです。

override func tableView(tableView: UITableView, 
  commitEditingStyle editingStyle: UITableViewCellEditingStyle, 
    forRowAtIndexPath indexPath: NSIndexPath) {
  if editingStyle == .Delete {
   // Delete the row from the data source
   meals.removeAtIndex(indexPath.row)
   tableView.deleteRowsAtIndexPaths([indexPath], 
                           withRowAnimation: .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
 }    
}
    
override func tableView(tableView: UITableView, 
     canMoveRowAtIndexPath indexPath: NSIndexPath) -> Bool {
 // Return false if you do not want the item to be re-orderable.
 return true
}

f:id:yataiblue:20151029160450j:plain

これだ課題終了です。

次に進む→Swiftで遊ぼう! - 405 - Persist Data - Swiftで遊ぼう! on Hatena