02 Building Lists and Navigation - ly918/SwiftUI-Chinese-Documents GitHub Wiki
构建列表和导航
设置了基本地标详情视图后,需要为用户提供查看地标所有列表和查看每个地标详情的方法。
您将创建显示所有地标信息的视图,并动态生成一个滚动列表,用户可以点击该列表查看地标的详情视图。要微调UI,您将使用Xcode的画布以不同的设备大小呈现多个预览。
下载项目文件以开始构建此项目,并执行以下步骤。
学习时间:35分钟
第一节 了解示例数据。
在第一个教程中,我们将数据硬编码到所有自定义视图中。在这里,您将学习如何将数据传递到自定义视图中以动态显示。
首先下载示例项目并熟悉示例数据。

步骤1
在项目导航器中,选择Models>Landmark.swift。
Landmark.swift声明了一个Landmark结构,该结构存储应用程序需要显示的所有Landmark信息,并从landmarkData.json导入一组Landmark数据。
Landmark.swift
import SwiftUI
import CoreLocation
struct Landmark: Hashable, Codable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}
extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
步骤2
在项目导航器中,选择Resources>landmarkData.json。
您将在本教程的其余部分以及随后的所有内容中使用此示例数据。
landmarkData.json
[
{
"name": "Turtle Rock",
"category": "Featured",
"city": "Twentynine Palms",
"state": "California",
"id": 1001,
"park": "Joshua Tree National Park",
"coordinates": {
"longitude": -116.166868,
"latitude": 34.011286
},
"imageName": "turtlerock"
},
{
"name": "Silver Salmon Creek",
"category": "Lakes",
"city": "Port Alsworth",
"state": "Alaska",
"id": 1002,
"park": "Lake Clark National Park and Preserve",
"coordinates": {
"longitude": -152.665167,
"latitude": 59.980167
},
"imageName": "silversalmoncreek"
},
...
]
步骤3
请注意,我们将ContentView类型重命名为LandmarkDetail。
在本教程和以下每个教程中,您将创建多个视图类型。
LandmarkDetail.swift
import SwiftUI
struct LandmarkDetail: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)
CircleImage()
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}

第二节 创建行视图
本节中构建的第一个视图是行视图,用于显示每个地标的详细信息。此行视图声明了一个属性即landmark ,其用于存储地标信息,以便行视图可以显示任何地标。稍后,您将把多个行视图合并成一个地标列表。

步骤1
创建一个新的SwiftUI视图,名为LandmarkRow.swift。

步骤2
如果预览尚未显示,请通过选择Editor > Editor and Canvas,来显示画布,然后单击“Resume”。

步骤3
添加landmark作为LandmarkRow的存储属性。
添加landmark属性时,预览将停止工作,因为LandmarkRow类型在初始化期间需要landmark实例。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
Text("Hello World")
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow()
}
}
想要修复预览,需要修改
PreviewProvider。
步骤4
在LandmarkRow_Previews的static previews属性中,将landmarkData数组的第一个元素作为LandmarkRow参数添加到LandmarkRow初始值设定项中。
预览可以正常显示文本Hello World了。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
Text("Hello World")
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

修复后,可以为行视图生成布局。
步骤5
将文本视图嵌入到HStack中。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
Text("Hello World")
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤6
修改文本视图以使用landmark的name属性。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤7
在Text视图之前添加Image视图来完成行视图。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

第三节 自定义行预览
Xcode的画布自动识别并显示当前编辑器中遵守PreviewProvider协议的任何类型。PreviewProvider返回一个或多个视图,并提供配置大小和设备的选项。
您可以自定义PreviewProvider返回的内容,以准确呈现对您最有帮助的预览。

步骤1
在LandmarkRow_Previews中,将landmark参数更新为landmarkData数组中的第二个元素。
预览立即显示第二个示例地标,而不是第一个。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
}
}

步骤2
使用previewLayout(_:) 设置一个与列表中的行近似的大小。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}

可以使用
Group从PreviewProvider返回多个视图预览。
步骤3
将返回的行包装在一个Group中,然后再次添加第一行。
Group是用于对视图内容进行分组的容器。Xcode将组的子视图呈现为画布中的单独预览。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
.previewLayout(.fixed(width: 300, height: 70))
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
}

步骤4
要简化代码,请将previewLayout(:)调用移到Group的外部。
视图的子级视图继承视图的上下文设置,如预览配置。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

Tips:在
PreviewProvider中编写的代码只会改变Xcode在画布中显示的内容。
第四节 创建地标列表
使用SwiftUI的List类型时,可以显示特定于平台的视图列表。列表的元素可以是静态的,比如您目前创建的堆栈的子视图,也可以是动态生成的。您甚至可以混合静态和动态生成的视图。

步骤1
创建一个新的SwiftUI View,名为LandmarkList.swift。

步骤2
将默认Text视图替换为List,并提供前两个Landmark作为列表子级的LandmarkRow实例。
预览显示了两个地标,它们以适合iOS的列表样式呈现。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
List {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第五节 动态化列表
您可以直接从集合中生成行,而不是单独的指定列表中的元素。
通过传递数据集合并为集合中的每个元素提供视图的闭包,可以创建显示集合元素的列表。列表使用提供的闭包将集合中的每个元素转换为子视图。

步骤1
删除两个静态LandmarkRow,并将landmarkData 传递给Lisst初始值设定项。
List使用identifiable数据。您可以通过以下两种方式之一生产identifiable数据:1、使用key path属性标识每个元素;2、使数据遵守Identifiable协议。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
步骤2
通过从闭包返回LandmarkRow来动态生成列表。
这将为landmarkData数组中的每个元素创建一个LandmarkRow。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

接下来,您将通过使
Landmark遵守Identifiable来简化代码。
步骤3
切换到Landmark.swift并声明Identifiable协议。
由于Landmark类型已经具有Identifiable所需的id属性,因此没有其他工作要做。
Landmark.swift
import SwiftUI
import CoreLocation
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}
extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤4
切换回LandmarkList.swift并删除id参数。
从现在起,您将能够直接使用Landmark的集合。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第六节 在列表和详情之间设置导航
列表呈现正确,但您还不能轻触每个LandmarkRow来查看该地标的详情页面。
通过把List嵌入到NavigationView中使其具有导航功能,然后在NavigationLink中嵌入每一行LandmarkRow,并设置要跳转的目标视图。

步骤1
在NavigationView中嵌入动态生成的LandmarkList。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2
在显示列表时,调用navigationBarTitle(:)修饰符方法设置导航栏的标题。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3
在List的闭包中,将返回的Row包装在NavigationLink 中,指定LandmarkDetail视图作为跳转目标。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤4
通过切换到实时预览模式,您可以在预览中直接尝试导航功能。单击Live Preview按钮并点击地标行访问详情页。

第7节 将数据传递到子视图
LandmarkDetail视图仍然使用硬编码方式来显示地标。与LandmarkRow一样,LandmarkDetail类型及其包含的视图需要使用landmark属性作为其数据源。
从子视图开始,您将转换CircleImage、MapView和LandmarkDetail以显示传入的数据,而不是对每行进行硬编码。

步骤1
在CircleImage.swift中,将存储属性image添加到CircleImage。
这是使用SwiftUI构建视图时的常见模式。您的自定义视图通常会包装一系列修饰符。
CircleImage.swift
import SwiftUI
struct CircleImage: View {
var image: Image
var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}
struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}
步骤2
更新CircleImage_Preview 以传递名为Turtle Rock的Image。
CircleImage.swift
import SwiftUI
struct CircleImage: View {
var image: Image
var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}
struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}
步骤3
在MapView.swift中,向MapView添加一个coordinate属性,并将代码转换为使用该属性,而不是硬编码纬度和经度。
MapView.swift
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}
struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}
步骤4
更新MapView_Preview以传递数据数组中第一个地标元素的坐标。
MapView.swift
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}
struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}
步骤5
在LandmarkDetail.swift中,将Landmark属性添加到LandmarkDetail类中。
LandmarkDetail.swift
import SwiftUI
struct LandmarkDetail: View {
var landmark: Landmark
var body: some View {
VStack {
MapView()
.frame(height: 300)
CircleImage()
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}
struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail()
}
}
步骤6
更新LandmarkDetail_Preview以使用landmarkData数组中的第一个地标元素。
LandmarkDetail.swift
import SwiftUI
struct LandmarkDetail: View {
var landmark: Landmark
var body: some View {
VStack {
MapView()
.frame(height: 300)
CircleImage()
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}
struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}
步骤7
将所需的数据下传给自定义类型。
LandmarkDetail.swift
import SwiftUI
struct LandmarkDetail: View {
var landmark: Landmark
var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}
struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}
步骤8
最后,调用navigationBarTitle(_:displayMode:)修饰符,在显示详情视图时为导航栏提供标题。
LandmarkDetail.swift
import SwiftUI
struct LandmarkDetail: View {
var landmark: Landmark
var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()
Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}
struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}
步骤9
在SceneDelegate.swift中,将应用程序的根视图切换为LandmarkList。
当在模拟器中独立运行(不是预览模式)时,您的应用程序将从SceneDelegate中定义的根视图开始。
SceneDelegate.swift
import UIKit
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: LandmarkList())
self.window = window
window.makeKeyAndVisible()
}
}
// ...
}

步骤10
在LandmarkList.swift中,将当前Landmark传递到目标详情页。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤11
切换到实时预览以查看从列表导航到详情视图时,是否显示正确的地标。

第8节 动态生成预览
接下来,将向LandmarkList_Previews添加代码,以呈现不同设备大小的列表视图预览。默认情况下,预览以激活状态设备的大小呈现。可以通过调用previewDevice(:)修饰符方法来更改预览设备。

步骤1
首先,将当前列表预览更改为iPhone SE大小的渲染器。
您可以提供Xcode的scheme菜单中显示的任何设备的名称。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.previewDevice(PreviewDevice(rawValue: "iPhone SE"))
}
}

步骤2
在LandmarkList_Previews中,使用设备名称数组作为数据,将LandmarkList嵌入ForEach实例中。
ForEach对集合的操作方式与list相同,这意味着您可以在任何可以使用子视图的地方使用它,例如在stacks、list、group等中。当数据元素是简单的值类型(如您在这里使用的字符串)时,可以使用.self作为identifier的key path。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
}
}
}

步骤3
使用previewDisplayName(_:)修饰符将设备名称添加为预览的标签。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}

步骤4
您可以尝试使用不同的设备来比较视图的渲染,所有这些都来自画布。
