Start Developing iOS Apps (Swift
在本课中,你要关注两个功能——允许用户编辑和删除FoodTracker应用中的菜品。
学习目标
在本课结束的时候,你将能够:
- 区分压栈(push)导航和模态(modal)导航
- 基于视图控制器的呈现方式移除该控制器
- 使用segue identifier(segue 标识符)确定哪个segue正在执行
- 启用table view controller的编辑模式。
启用现有菜品的编辑
当前,FoodTracker应用给用户添加新菜品到菜品列表的功能。在本课中,你将启用已有菜品的编辑。
当用户点击一个在表格场景中的菜品时,你将在详情场景显示这个菜品。用户能够改变这个菜品。如果他们点击Save按钮,你将同时更新菜品的数据以及它在菜品列表中的呈现。注意,应用没有保存模型数据。每当应用重启,它总是初始化样本数据。不过,当应用运行的时候,用户仍能修改数据。
通过设置菜品列表和菜品详情场景之间的segue开始。
配置table view cell
-
如果打开了助理编辑器,回到标准编辑器。
image: ../Art/standard_toggle_2x.png - 打开storyboard。
- 在画布上,选择在菜品列表场景中的table view cell。
-
按住Control键拖拽这个cell到菜品详情场景。
image: ../Art/IEDB_drag_tabletomealscene_2x.png
image: ../Art/IEDB_seguemenu_2x.png - 在弹出的 选择segue菜单 选择Show。这个会使导航控制器将菜品详情场景压栈到导航控制器。
-
把菜品列表场景和菜品详情场景中键的导航控制器向下拉,就能看到这个新segue。
image: ../Art/IEDB_drag_navcontroller_2x.png
如果愿意,你能够使用在画布底部的缩放命令来缩放。
-
在画布上,选择新添加的segue。
image: ../Art/IEDB_selectsegue_2x.png -
在Attributes inspector,在Identifier键入ShowDetail。按下回车键。
image: ../Art/IEDB_firstcheckpoint_2x.png创建新菜品和编辑现有菜品是非常相似的操作。因为,完成这两个任务你使用的是同一个界面。当然,你需要为场景的呈现和它的行为做一些修改。当用户添加新菜品和编辑一个现有菜品时,你需要一种方法来识别。
回想一下,prepare(for:sender:)方法是在任何segue执行之前被调用的。你可以使用这个方法来识别哪个segue正在使用,并且在菜品详情场景中显示合适的信息。你是基于你给segue分配的标识符来区分它们的:当添加新菜品时是AddItem,而当编辑已有菜品的时是ShowDetail。
辨别哪个segue正在使用
- 打开MealTableViewController.swift
- 在文件的顶部,紧跟着导入UIKit下面,导入统一日志系统:
import os.log
- 在MealTableViewController.swift中,找到prepareForSegue(_:sender:)并取消注释。
然后,你的模版实现方法看上去是这样的:
//MARK: - Navigation // In a storyboard-based application, you will often want to do a little preparation before navigation override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { // Get the new view controller using segue.destinationViewController. // Pass the selected object to the new view controller. }
因为MealTableViewController是UITableViewController的子类,所以模版实现带有prepare(for:sender:)骨架。
- 删除两行注释,并且用调用超类的实现来替代它们。
super.prepare(for: segue, sender: sender)
- 在调用super.prepare(for:sender:)后,添加下面的switch语句:
switch(segue.identifier ?? "") { }
Switch语句考虑一个值,并将它和几种可能的匹配模式进行比较。然后基于第一个匹配成功的模式,执行合适的代码块。当要在多个选项中进行选择的时候使用switch语句代替if语句。
上面的代码检查segue的标识符。如果标识符为nil, nil-coalescing 运算符(??)空字符串(“”)代替它。在本例中你不需要处理多个选项,这件化了switch语句的逻辑。- 添加AddItem分支到switch。
case "AddItem": os_log("Adding a new meal.", log: OSLog.default, type: .debug)
如果用户添加一个项目到菜品列表,你不需要改变菜品详情场景的外观。只是在控制台记录一条简单的调试信息。如果你调试代码,这将帮助你跟踪应用的工作流。
- 添加ShowDetail分支到switch。
case "ShowDetail": guard let mealDetailViewController = segue.destination as? MealViewController else { fatalError("Unexpected destination: \(segue.destination)") } guard let selectedMealCell = sender as? MealTableViewCell else { fatalError("Unexpected sender: \(sender)") } guard let indexPath = tableView.indexPath(for: selectedMealCell) else { fatalError("The selected cell is not being displayed by the table") } let selectedMeal = meals[indexPath.row] mealDetailViewController.meal = selectedMeal
如果你正在编辑已有册菜品,你需要在菜品详情场景中显示这个菜品的数据。这个代码以获取目标视图控制器开始,然后选择菜品cell,然后获取选中cell的index path。guard 语句检查所有执行的降级工作、以及所有包含非空值的可选类型。这里,guard语句只是进行简单的合理检查。如果你的storyboard被正确的设置,guard语句将不会失败。
一旦你又了index path,你可以查找这个路径上的菜品对象,并传递给目标视图控制器。- 添加默认分支。
default: fatalError("Unexpected Segue Identifier; \(segue.identifier)")
如果你的storyboard被正确设置,这个默认分支永远不会被执行。但是,如果你稍后从你的菜品场景添加另外的segue而忘了更新 prepare(for:sender:)方法,新segue的标识符和两个分支标识符(AddItem、ShowDetail)将都不匹配。这种情况,switch语句会在控制台打印错误信息,并终止应用。
你的 prepare(for:sender:)方法现在看起来是这样的:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { super.prepare(for: segue, sender: sender) switch(segue.identifier ?? "") { case "AddItem": os_log("Adding a new meal.", log: OSLog.default, type: .debug) case "ShowDetail": guard let mealDetailViewController = segue.destination as? MealViewController else { fatalError("Unexpected destination: \(segue.destination)") } guard let selectedMealCell = sender as? MealTableViewCell else { fatalError("Unexpected sender: \(sender)") } guard let indexPath = tableView.indexPath(for: selectedMealCell) else { fatalError("The selected cell is not being displayed by the table") } let selectedMeal = meals[indexPath.row] mealDetailViewController.meal = selectedMeal default: fatalError("Unexpected Segue Identifier; \(segue.identifier)") } }
你现在已经有了逻辑实现,那么打开 MealViewController.swift,来确保UI正确的更新。具体来说,当一个MealViewController(菜品详情场景)的实例被被创建的时候,它的视图应该被菜品属性的数据所占据,如果这些数据存在的话。
你要在viewDidLoad()方法中做这一步的工作。
更新viewDidLoad()的实现
- 打开MealViewController.swift。
- 在MealViewController.swift.中,找到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. updateSaveButtonState() }
- 在nameTextField.delegate行下面,添加下面的代码。如果meal属性非空,这个代码会设置MealViewController中的每个视图,以便显示来自meal属性的数据。meal属性只有在非空的时候,现有的菜品才能被编辑。
// 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 }
你的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. updateSaveButtonState() }
检查点:运行应用。从菜品列表点击菜品导航到菜品详情场景。这个详情场景应该使用相关菜品的数据进行了预填充。不幸的是,Save按钮还不能工作。如果点击Save,应用不会更新这个菜品,它只会添加一个新的菜品。接下来,你要修复这个问题。
image: ../Art/IEDB_sim_editmeal_2x.png为了更新已存在的菜品,你要对unwindToMealList(sender:)方法进行修改,让它能够处理两种不同的情况:添加一个新菜品和修改一个已有的菜品。回顾一下,这个方法只是在用户点击Save按钮的时候调用,所以你不需要在这个方法中考虑Cancel按钮。
更新unwindToMealList(sender:)的实现方法以便既能添加又能编辑菜品
- 打开 MealTableViewController.swift。
- 在 MealTableViewController.swift中,找到unwindToMealList(sender:)方法。
@IBAction func unwindToMealList(sender: UIStoryboardSegue) { if let sourceViewController = sender.source as? MealViewController, let meal = sourceViewController.meal { // Add a new meal. let newIndexPath = IndexPath(row: meals.count, section: 0) meals.append(meal) tableView.insertRows(at: [newIndexPath], with: .automatic) } }
- 在if语句开始的地方,添加下面的if语句:
if let selectedIndexPath = tableView.indexPathForSelectedRow { }
这个代码检查在这个table view中是否有一个行杯选中。如果有,这就意味着用户点击了一个cell用以编辑菜品。换句话说,这个if语句会在你编辑已有菜品的时候被执行。
- 在这个if语句中,添加如下代码:
// Update an existing meal. meals[selectedIndexPath.row] = meal tableView.reloadRows(at: [selectedIndexPath], with: .none)
第一行更新meals数组。它使用新的菜品对象替换了旧的。第二行冲加载了table view中的相应的行。这使用一个包含更新了菜品的数据的新cell代替了当前的cell,结果就是,当table view再次出现是,这个用户选择的行现在显示的是编辑过的菜品。
- 在if语句后面,添加一个else分支,并用花括号包裹这个方法中的最后四行。确保这些行都在else分支语句正确的缩紧,可以在选中这些行的情况下按下Control-I实现。
else { // Add a new meal. let newIndexPath = IndexPath(row: meals.count, section: 0) meals.append(meal) tableView.insertRows(at: [newIndexPath], with: .automatic) }
这个else分支语句会在没有选中table view 的行时执行,这也就是说用户点击的是菜品详情场景中的加号按钮。换句话说,这个else分支语句是当用户添加新菜品的时候调用。
现在你的unwindToMealList(sender:)方法看上去是这样的。
@IBAction func unwindToMealList(sender: UIStoryboardSegue) { if let sourceViewController = sender.source as? MealViewController, let meal = sourceViewController.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: .automatic) } } }
检查点:运行应用。点击table view cell导航到菜品详情场景,并且看到它被菜品数据预填充。如果你点击Save,你所做的改变就会显示在菜品列表。
image: ../Art/IEDB_sim_overwritemeal_2x.png取消编辑已有菜品
用户可能决定不保存已编辑的菜品,并且不做任何改变的返回到菜品列表。为此,你将更新Cancel按钮的行为来恰当的移除场景。
移除样式是基于场景被呈现的样式的。当用户点击Cancel按钮的时候,你将实现一个检查来确定当前场景是如何被呈现的。如果是模态方式呈现(用户点击加号按钮),它将使用dismissViewControllerAnimated(_:completion:)方法移除。如果它是使用push导航呈现的(用户点击table view cell),它将通过呈现它的导航控制器来移除它。
进一步探索
不同的呈现方式有不同的用处。当表达一个任务在继续之前用户必须完成或取消它时(例如添加一个新菜品),使用模态方式呈现场景。当用户要浏览分级数据时(例如,从菜品列表中选择一个菜品),使用导航控制器呈现场景。
更多信息,查看iOS Human Interface Guidelines.中的Interaction > Modality and Interaction > Navigation。改变cancel方法的实现
- 打开MealViewController.swift。
- 在MealViewController.swift中,找到cancel(_:)方法。
@IBAction func cancel(_ sender: UIBarButtonItem) { dismiss(animated: true, completion: nil) }
这个实现方法现在只使用 dismiss(animated:completion:)来移除菜品详情场景,因为迄今为止你只使用加号按钮。
- 在 cancel(_:)方法中,在已存在的代码之前,添加如下按钮:
// 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
这代码创建了一个布尔值,它表明呈现这个场景的视图控制器是否是UINavigationController类型。就像变量名isPresentingInAddMealMode表示的,菜品详情场景是通过用户点击加号按钮而被呈现的。这是因为在这中情况下菜品详情场景嵌套在它自己的导航控制器内,也就是导航控制器呈现了它。
- 在你刚添加的代码下面,添加下面的if语句,并把dismissViewControllerAnimated方法移到它里面:
if isPresentingInAddMealMode { dismiss(animated: true, completion: nil) }
之前,当用户点击Cancel按钮的时候你总是调用dismiss(animated:completion:)方法;但是只有当用户添加新菜品的时候dismiss(animated:completion:)才会工作。因此,现在代码在调用dismiss(animated:completion:)之前先检查用户是否要添加一个新菜品。注意,当用户是在编辑菜品的时候,场景仍然不会移除。接下来你会添加该代码。
- 紧跟着if语句,添加else分支语句:
else if let owningNavigationController = navigationController{ owningNavigationController.popViewController(animated: true) }
如果用户正在编辑一个已存在的菜品,这个else块会被调用。这也意味着当用户选择菜品列表上的菜品的时候,菜品详情场景会被压栈道导航栈上。这个else语句使用一个if let语句来安全的解包视图控制器的navigationController属性。如果视图控制器已经被压栈到导航栈,这个属性会包含一个指向栈的导航控制器的引用。
在else分支语句中的代码执行一个名为popViewController(animated:)的方法,这个方法会把当前视图控制器(菜品详情场景)从导航栈中弹出,并进行动画转换。这会移除菜品详情场景,返回到菜品列表。- 紧接着第一个else语句立刻添加第二个else分支语句:
else { fatalError("The MealViewController is not inside a navigation controller.") }
这个else分支语句只有在菜品详情场景既不在模态当行控制器(例如添加一个新菜品)中也不在导航栈(例如编辑菜品)的时候才会被执行。如果你的应用导航流设置正确,这个else分支不会被执行。如果它执行,代表你的应用有错误。这个else分支会在控制台打印一个错误信息,并终止应用。
你的 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 if let owningNavigationController = navigationController{ owningNavigationController.popViewController(animated: true) } else { fatalError("The MealViewController is not inside a navigation controller.") } }
检查点:运行应用。当你选择菜品的时候,你能点击Cancel来返回到菜品列表而不需要保存任何改变。另外,当你点击加号按钮然后点击Cancel按钮的时候,会带你回到菜品列表而不添加新菜品。
支持删除菜品
接下来,你将给用户一个从菜品列表删除菜品的功能。你需要一个方式让用户把table view设置到编辑模式,从中它们可以删除cell。你可以通过添加一个Edit按钮到table view的导航栏来实现它。
添加Edit按钮到table view
- 打开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
这个代码创建了一个特定类型的bar button item,它内建了编辑行为。然后把它添加到菜品列表场景的导航栏的左侧。
你的viewDidLoad()方法看起来是这样的:
override func viewDidLoad() { super.viewDidLoad() // Use the edit button item provided by the table view controller. navigationItem.leftBarButtonItem = editButtonItem // Load the sample data. loadSampleMeals() }
检查点:运行应用。注意有一个Edit按钮在table view的导航栏左侧。如果你点击这个按钮,table view就进入到了编辑模式——但是你还不能删除cell,因为你还没有实现它。
image: ../Art/IEDB_sim_editbutton_2x.png
要想在table view上执行任何类型的编辑,你需要实现一个它的委托方法,tableView(_:commit:forRowAt:)。这个委托方法管理行在它处于编辑模式时的改变。
去除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 sourc注释下面,添加:
meals.remove(at: indexPath.row)
这段代码从meals删除这个Meal对象。接下来的那行代码用来从table view删除行。
- 在 MealTableViewController.swift中,找到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 }
你的tableView(_:commitEditingStyle:forRowAtIndexPath:)方法看上去是这样的:
// 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 meals.remove(at: indexPath.row) 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 } }
检查点:运行应用。如果你点击Edit按钮,table view进入编辑模式。你通过左侧的指示器来选择要删除cell,并通过点击cell中的Delete按钮来确定删除。或者,向左滑动cell快速显示出Delete按钮;这是个table view内建的行为。当你点击Delete按钮的时候,cell从列表中被移除。
image: ../Art/IEDB_sim_deletebehavior_2x.png
进一步探索
当在编辑模式的时候,评分控件扩展到了删除按钮中。这时因为cell的布局并没有使用Auto Layout的关系。这个控件适合正常分配的控件,但当控件减小时控件不能适应。
为了修好它,你需要使用嵌套的栈视图并使用Auto Layout的约束来布局;这是个留个读者的练习。
更多信息,参见Auto Layout Guide.小结
在本课中,你为菜品列表添加编辑和删除的菜品的支持。因为编辑一个菜品和创建一个新菜品非常相似,所以应用使用了同一个菜品详情场景。结果是,在你呈现视图控制器的时候你需要区分呈现的方式,模态的还是压栈的。你要基于它的呈现方式来修改菜品详情场景的外观和行为。
你能添加、编辑、和删除菜品。但是这些数据没有保存。每次重启应用的时候,你总是从初始样本数据开始。在下一课中,你将添加代码来保存和加载菜品列表。
注意
想看本课的完整代码,下载这个文件并在Xcode中打开。
下载文件