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
您可以尝试使用不同的设备来比较视图的渲染,所有这些都来自画布。