Day19-构建一个带导航栏的列表

几乎不存在一个没有列表的 iOS 应用程序。我们将之前完成的地标页面当做详情页面,然后创建一个列表页面用来展示多个地标数据。
本文是继承于上一个教程的,请先阅读上一个教程。

学习目标

学完本篇以后,你至少应该具有以下几个技能

  • 定义数据模型
    • 建新的 Model 文件
    • 完成对本地 JSON 文件的解析
  • 创建 Table View
    • 在   main.storyboard
      之间创建一个包含 Table View 的场景
    • 创建并设计一个自定义 TableViewCell
    • 在 TableView 中显示动态数据
  • 连接列表和详情页面
    • 创建一个 NavigationController,并将其作为项目的 RootViewController
    • 在 ViewController 之间创建 segue
    • 使用  prepare(for:sender:)
      方法在 ViewController 之间传递数据

定义数据模型

现在需要创建一个名叫 Landmark
的数据模型用来存储地标需要显示的信息。

创建数据模型类

  1. 选择  File > New > File
    或者使用快捷键  Command + N
    ,或者在文件列表页面点击右键选择  New File…

  2. 在出现的对话框的顶部,选择 iOS
  3. 选择  Swift File
    ,单击  Next

  4. 在  Save as
    ,键入文件名  Landmark
    ,并在项目位置中新增一个文件夹并命名为  Models
  5. 保持默认值不变,点击  Create

同理按照上述步骤创建一个名为 ModelData
的数据文件。

新增本地 JSON 文件

点击 navigation area
文件模块的右下角的 + 号按钮,选择  New Group
,并修改文件名为  Resource
。然后将准备好的  landmarkData.json
文件拖拽至 Resource
文件中,这里说的是拖拽至当前文件模块。

你也可以直接创建一个名称为 Resource
的文件夹,并添加   landmarkData.json
文件, 然后选择 File > Add File To “Landmark”…
添加一个名为  Resource
且包含 JSON 文件的文件夹

添加图片文件

我们为项目添加其他图片文件至 Assets.xcassets
中。你可以在公众号回复 地标资源
获取图片和  landmarkData.json
也可以去官网选择 SwiftUI 项目下载对应资源文件。

定义地标数据模型

你可以直接使用下面代码来覆盖你的 Landmark.swift
文件

  1. 打开  Landmark.swift
    文件
  2. 在文件顶部新增  import UIKit
    和  import CoreLocation
  3. 由于我们的数据来源于   landmarkData.json
    文件,所以我们定义一个 Landmark 的 Struct 对象并且默认使用 逐一成员构造器
  4. 由于我们需要使用经纬度来获取地理位置,我们实现一个自定义 Coordinates 的 Struct 对象并且默认使用 逐一成员构造器

最终你的文件如下所示,注意这里我们给我们的对象遵循了两个协议 Hashable
和  Codable

import Foundation
import UIKit
import CoreLocation

struct Landmark: Hashable, Codable {
var id: Int
var name: String
var park: String
var state: String
var description: String

private var imageName: String
var image: UIImage? {
UIImage(named: imageName)
}

private var coordinates: Coordinates
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude
)
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
}

解析JSON文件

这一步的主要目标是创建一个 load(_:)
方法,以从应用程序的  Main Bundle
中获取具有给定名称的 JSON 数据。load 方法依赖于返回类型是否符合可编码协议(Codable)。
同样你可以直接使用下面的代码,但是我建议你按照顺序敲一遍。

  1. 打开 ModelData.swift
    文件
  2. 定义  func load(_ filename: String) -> T
    方法以便于可以解析对象和数组并返回对应的泛型。
  3. 定义一个 var landmarks: [Landmark]
    以便于获取 JSON 解析后的值
import Foundation

var landmarks: [Landmark] = load("landmarkData.json")

func load(_ filename: String) -> T {
let data: Data

guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}

do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}

do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}

到这里我们完成了数据源的配置。

创建 Table View

在此之前,Landmark 是一个 单场景
的项目,而现在,我们有了一个新的场景用于展示我们已有的地标列表数据。

Main.storyboard
中新增场景

  1. 打开 Main.storyboard

  2. 点击顶部工具栏的 + 号按钮打开 Library
    ,或者你也可以选择  View > Show Library
    ,当然也可以使用快捷键  Shift + Command + L

  3. 这里我们搜索 Table View Controller 并选择。
    之所以选择 Table View Controller 而不是选择 View Controller 并添加 Table View 是因为使用 Table View Controller 包含 Table View 实例并且充满了场景的整个空间。

  4. 然后将 Table View Controller 拖拽到画布区域。我们一般先有列表然后再有详情页面,你可以调整两者的位置,并将 View Controller 的 Storyboard Entry Point
    移到 Table View Controller 上。这样 Table View Controller 将成为 KeyWIndow 的 RootViewController。此时完成将 Table View Controller 设置成为默认场景。

运行程序,你将会看到一个空白的列表页面。

Table View 设置

  1. 选中 Main.storyboard
    中的 Table View。Table View 嵌套在 Table View Controller 场景>Table View Controller
    下。你可能需要单击这些对象旁边的三角形才能看到嵌套的 Table View。

  2. 选择 Size 检查器
    ,并将 Row Height 大小调整为  88

自定义 Table Cells

Table View 中的单独行由 Table View Cell(UITableViewCell)管理,这些 Cell 负责绘制它们的内容。Table View Cell 具有多种预定义的行为和默认的样式;但是,由于每个 Cell 中要显示的内容比默认样式所允许的要多,因此需要定义一个自定义的 Cell 样式。

创建 Table View Cell 的子类

  1. 选择 File > New > File
    或者使用快捷键  Command + N
    ,或者在文件列表页面点击右键选择  New File…

  2. 在出现的对话框的顶部,选择 iOS
  3. 选择 Cocoa Touch Class
    ,并将  Subclass of
    修改为  UITableViewCell
    ,然后输入  LandmarksListCell

    如果你先输入名称 Landmarks 然后在修改类属性,其名称会变成 LandmarksTableViewCell。Xcode意图通过此举来表明你正在创建一个 Table View Cell。我们应该给我们定义的类附上这种类型的名称,尽量使语义清晰。

  4. 确保将语言选项设置为 Swift。
  5. 点击 Next
    ,在 Targets 部分,确保应用程序被选中,而应用程序的测试未被选中。然后点击  Create

现在重新打卡你的 Main.storyboard
文件,然后在 Table View Controller 场景中 Table View 下选择 Table View Cell。

设置 Table View Cell

  1. 选择 Table View Cell 之后,选择 Identity 检查器
    ,将 Table View Cell 的类别设置为我们创建的  LandmarksListCell

  2. 切换到属性检查器,修改 Accessory
    为  Disclosure Indicator
    ,目的是为了让 Table View Cell 的右侧出现一个  >
    。并且将 Identifier 设置为  LandmarksListCell
    。一般来说 Identifier 的名称应于文件名对应。

  3. 然后我们切换到 Size 检查器
    并调整 Row Height 为  88

  4. 在 Table View Cell 中新增两个控件,一个 ImageView 一个 Label。然后我们简单进行一下布局。大致如下图所示

  5. 然后将这两个控件与源代码,也就是 LandmarksListCell.swift
    文件连接起来。在源代码中生成两个对应的属性,你可以跟我一样定义名称为  headImageView
    和  landmarkNameLabel

    class LandmarksListCell: UITableViewCell {

    @IBOutlet weak var headImageView: UIImageView!
    @IBOutlet weak var landmarkNameLabel: UILabel!

    override func awakeFromNib() {
    super.awakeFromNib()
    // Initialization code
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)

    // Configure the view for the selected state
    }

    }

到此,我们完成了关于 Table View Cell 的相关配置项。

在 TableView 中显示动态数据

现在我们需要创建一个 UIViewController 的子类来管理我们的 Table View Controller 的场景的展示。

创建 UITableViewController 的子类以及 Table View Controller 场景的配置

  1. 选择 File > New > File
    或者使用快捷键  Command + N
    ,或者在文件列表页面点击右键选择  New File…

  2. 在出现的对话框的顶部,选择 iOS
  3. 选择 Cocoa Touch Class
    ,并将  Subclass of
    修改为  UITableViewController
    ,然后输入  LandmarksListViewController

    注意不要选 Also create XIB file
    ,因为我们是使用 storyboard 处理

  4. 确保将语言选项设置为 Swift。点击 Next
    ,在 Targets 部分,确保应用程序被选中,而应用程序的测试未被选中。然后点击  Create

在完成创建 LandmarksListViewController.swift
后,我们开始配置  Main.storyboard
中 Table View Controller 场景。

在这里我们简化操作步骤,类似于我们设置 Table View Cell 一样,将 Identity 检查器
检查其中的 Class 设置成  LandmarksListViewController

展示数据

现在我们已经有了一个 landmarks
数组并且已经配置好我们的 Table View Controller 和 Table View Cell。现在让我们的列表展示  landmarks
数组中的数据。
要显示动态数据, Table View 需要两个重要的辅助:DataSource 和 Delegate。TableViewDataSource ,顾名思义,为Table View提供需要显示的数据。TableViewDelegate 则完成诸如 Table View Cell 选择、行高和其他与显示数据相关的其他操作。默认情况下,UITableViewController 及其子类采用必要的协议,使 TableViewController 为其关联的 Table View 的提供数据源(UITableViewDataSource 协议)和 Delegate (UITableViewDelegate 协议)。你通过重载TableViewController 子类中的具体实现来展示数据。
展示数据需要实现 DataSource 中三个重要的方法

func numberOfSections(in tableView: UITableView) -> Int
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

func numberOfSections(in tableView: UITableView) -> Int
方法告诉 Table View 中需要展示多少个  section
而  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
方法则是告诉 Table View 中的每一个  section
中 需要展示多少  row
。最终用一个  IndexPath
来满足组合 Table View 中需要展示的  section
和  row

现在我们在 LandmarksListViewController.swift
中完成以下三步操作,展示一个完整的单  section
的多  row
的列表:

在 Table View 中显示 section

我们需要展示的是一个**单 section
的 Table View **,修改一下 func numberOfSections(in tableView: UITableView) -> Int
方法,将默认的  section
数量从  0
改为  1

override func numberOfSections(in tableView: UITableView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1
}

返回 Table View 中的 Row

我们需要展示从 JSON 文件解析出来的地标数组。数组具有一个 count 的属性,用来返回数组中的项目数,我们的列表需要展示具体的地标数量。

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of rows
return landmarks.count
}

在 Table View 中配置和显示 Cell

然后我们先取消 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
的注释,也就是删除方法最邻近的  /*
和  */

然后我们在该方法中插入我们的配置代码

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


let cellIdentifier = "LandmarksListCell"

guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? LandmarksListCell else {
fatalError("The dequeued cell is not an instance of LandmarksListCell.")
}
// Configure the cell...
let landmarkModel = landmarks[indexPath.row]

cell.headImageView.image = landmarkModel.image
cell.landmarkNameLabel.text = landmarkModel.name

return cell
}

注意这里的 cellIdentifier
的值,需要与我们在配置 Cell 是在 Identifier 中输入的值保持一致。
另一个值得注意的点是我们使用

guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? LandmarksListCell  else {
fatalError("The dequeued cell is not an instance of LandmarksListCell.")
}

来确保获取的 cell 使我们自定义的 LandmarksListCell
的实例。

这里我们将 Cell 的 headImageView 的 image 设置为 landmarkModel
的 image 属性而将 landmarkNameLabel 的 text 设置为  landmarkModel
的 name。
然后我们运行应用程序,就会得到大致如同下图的结果。


现在我们已经完成了我们的第二个目标,然后我们将列表页面和详情页面联系起来。

连接列表和详情页面

Navigation Controller

在细致的学习之前,我们先大致了解一下什么是 Navigation Controller。Navigation Controller 是一种 View Controller 容器 ,它在导航界面中管理一个或多个子视图控制器。
在这种类型的界面中,一次只能看到一个子 View Controller。在 View Controller 中选择一个项目会使用动画在屏幕上推送一个新的 View Controller,从而隐藏先前的View Controller。在界面顶部的导航栏中点按后退按钮可删除顶 View Controller,从而在下方显示 View Controller。
Navigation 顾名思义,就是导航。类似于网页类应用管理页面栈的方法,Navigation 管理的是多场景(ViewController)情况下的场景栈——即多场景之间的的跳转。多场景之间的跳转的方式有很多,但我们经常使用 Navigation 来进行管理。


UINavigationController 其大概结构类型如上图所示。下面我们用 iOS 模拟器的设置页面来简述一下具体的操作。


示图与目前设置页面展示的不一致,但是足够例述 UINavigationController 如何管理 View Controller。我们一般将 Navigation Controller 的第一个 View Controller 称为 Root View Controller。

Navigation Bars

导航控件,显示在屏幕顶部,状态栏下面,通常与导航控制器一起使用。我们一般用来简单描述页面主题(Title)然后分割状态栏和底部内容。通常与 Navigation Controller 一起使用。
现在我们大概看一下 Navigation Bars 的结构,通常包括一个左(返回)按钮,中间标题和一个可设置的右侧按钮。


在初学阶段我们仅需要知道我们默认的返回按钮的 Title 是前一个 View Controller 设定的 Title,并且当我们本身为场景栈的最底端时也就是第一个时,返回按钮不展示。如果需要自定义返回按钮的 Title,记住是在前一个 View Controller 自定义而不是当前 View Controller。
现在将我们的列表页面嵌入到 Navigation Controller 中

为场景添加 Navigation Controller

我们按照下述步骤给将地标列表场景添加 Navigation Controller

  1. 选中 Main.storyboard
    中 LandmarksListViewController ,并点击 scene dock。

  2. 然后选择编辑区域底部工具栏的最右边的嵌入按钮或者是选择 Editor > Embed In > Navigation Controller
    将列表页面  Embed In
    一个 Navigation Controller 中

    Xcode 添加了一个新的 Navigation Controller,并将 
    Storyboard Entry Point
     移到 Navigation Controller上,这样 Navigation Controller 将成为 KeyWIndow 的 RootViewController 而 LandmarksListViewController 将成为 Navigation Controller 的 RootViewController。

  3. 然后保持 LandmarksListViewController 的 scene dock 被选中,右击第一个按钮——步骤 1 所示的。然后选择 Triggered Segues
    并将其连接到详情页面,并将类型选择了 Push


    这样你会发现详情页面也增加了一个 Navigation Bar。

  4. 下面来给我们的列表页面进行 Navigation Bar 的配置。选中 LandmarksListViewController 的 Navigation Item 模块。然后切换到属性检查器,进行下面的配置,将 Title 改成 地标列表并将返回按钮的 Title 设置成 返回

  5. 选中 Main.storyboard
    中列表页面和详情界面之间的生成的  segue
    ,并进行配置,这里主要是是配置 Identifier 的值。

到这里我们完成了Navigation Controller 的配置,紧接着我们将利用 segue
实现页面间的跳转和数据传递。

页面间的跳转和传值

在这里我们将使用 prepare(for:sender:)
实现界面间的传值并使用  performSegue(withIdentifier:sender:)
进行界面间的跳转。
当然我们有很多种方法来实现页面间的跳转和传值,这里我们仅通过述两个方法来实现。

使用 segue
管理会让页面之间的关系变得更加的直观。

  1. ViewController.swift
    中新增一个用于接收传递数据的属性  landMark
    并在  LandMarksListController.swift
    中新增一个用于接收点击某一行 row
    所显示的 landMark
    的  landMark

    class ViewController: UIViewController {
    ...
    public var landmark: Landmark?
    ...
    }
    class LandmarksListViewControlle: UITableViewController {
    ...
    var landmark: Landmark?
    ...
    }

  2. LandmarksListViewController.swift
    中实现 Delegate (UITableViewDelegate 协议)的  didSelectRowAt
    方法,再此方法中进行  landMark
    属性的赋值

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    landmark = landmarks[indexPath.row]
    self.performSegue(withIdentifier: "pushToLandMarksDetail", sender: self)
    }

  3. 然后我们实现 prepare(for:sender:)
    方法,并将赋过值  landMark
    属性赋值给 ViewController 的  landMark
    属性

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    super.prepare(for: segue, sender: sender)
    if let sourceViewController = segue.destination as? ViewController {
    sourceViewController.landmark = landmark
    }
    }

  4. ViewController.swift
    中利用获取的数据配置页面。利用之前配置Image View 和 Label 的方法新增一些内容配置页面。记住是对应其展示的值给予定义属性名称

    class ViewController: UIViewController {

    @IBOutlet weak var circleImage: UIImageView!
    @IBOutlet weak var mapView: MKMapView!
    @IBOutlet weak var landmarkName: UILabel!
    @IBOutlet weak var landmarkPark: UILabel!
    @IBOutlet weak var landmarkState: UILabel!
    @IBOutlet weak var descriptionTitle: UILabel!
    @IBOutlet weak var landmarkDescription: UILabel!

    public var landmark: Landmark?

    override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.

    if let landmark = landmark {
    circleImage.image = landmark.image
    circleImage.layer.cornerRadius = 125/2.0
    circleImage.clipsToBounds = true
    circleImage.layer.borderWidth = 3.0
    circleImage.layer.borderColor = UIColor.white.cgColor

    let region = MKCoordinateRegion(center:landmark.locationCoordinate, span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2))
    mapView.setRegion(region, animated: false)
    self.title = landmark.name

    self.landmarkName.text = landmark.name
    self.descriptionTitle.text = "About " + landmark.name
    self.landmarkPark.text = landmark.park
    self.landmarkState.text = landmark.state
    self.landmarkDescription.text = landmark.description
    //为了显示多行,加上去,不要忘记了
    self.landmarkDescription.numberOfLines = 0
    }
    }
    }

  5. 运行程序,可能需要你在详情页面的场景中进行微调,尽量展示跟多的文字出来。

总结

在这节课中,我们完成了如何新建一个场景以及通过 Navigation Controller 进行场景间的转换包括进行数据的单向传递。还学习了如何解析 JSON 文件以及自定义 Table View Cell。
本文算是这个系列写法的新尝试,带着目标去梳理内容。如果你有什么更好的建议的话,欢迎点赞评论。