第二篇:SwiftUI Landmarks的列表和导航

531 阅读9分钟

用SwiftUI实现一个列表和导航

做完详情页后,您需要为用户提供一种方法来查看完整的地标列表,并查看每个位置的详细信息。

您将创建一个可以显示任何地标信息的视图,并动态生成一个滚动列表,用户可以点击该列表以查看地标的详细信息视图。

下载项目文件Landmarks开始构建此项目,并按照以下步骤操作

第1节 创建一个地标模型

在第一个教程中,我们把详情页的UI完成了,不过里面的数据是写死的。在这里,您将创建一个模型来存储可以传递到视图中的数据。

第1步 把模型数据json文件拖到项目工程

把上面下载的文件中Resources->landmarkData拖到功能目录,拖的时候会有一个弹窗提示,您选择Destination选项中勾选Copy items if needed,Add to targets勾选Landmarks即可。
本教程的其余部分以及接下来的所有内容都会使用这个示例数据

第2步 创建模型

选择文件>新建>文件以在项目中创建新的Swift文件,并将其命名为Landmark.swift
定义一个结构体Landmark,根据LandmarkData添加相应的属性。遵守Codable协议可以方便的在数据文件和结构体之间移动数据,在本节的后面部分,您将依靠Codable协议Decodable组件来从文件中读取数据。

import Foundation

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

第3步 添加图片资源

将JPG文件从Rescources文件夹拖到项目的Assets中。Xcode为每个图像创建一个新的图像集。新图像加入了您在上一个教程中添加的Turtle Rock的图像。

第4步 图片与模型绑定

为了方便使用,您对模型再添加一个image属性,该属性直接返回图片。此时将imageName设置为private,因为使用者只会关心图片本身,不会关心图片的名字

第5步

创建一个Coordinates结构体与json数据中的位置模型相对应。接着再创coordinates属性接收位置数据,此属性是private的,因为在后面使用的时候需要转换成Mapkit识别的位置信息 。
所以再添加一个CLLocationCoordinate2D类型的locationCoordinate属性,此属性是计算属性,需要从coordinates转换过来。

import Foundation
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
    
    var imageName: String
    var image: Image {
        Image(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
        
    }
}

第6步 创建json解析文件

在您的项目中创建一个新的Swift文件,并将其命名为ModelData.swift

第7步 添加json 加载函数load(:)

参数:json文件名
返回值:加载函数的返回一个数组,数组的成员类型需要遵守Decodable协议 。DecodableCodable协议的一种。

import Foundation

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

func load<T: Decodable>(_ 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)")
    }
}

第8步 工程目录分组归类

Views:ContentView.swiftCircleImage.swift,  MapView.swift Resources: landmarkData.json
Model:Landmark.swift, ModelData.swift 

第2节 创建行视图

您将在本教程中,构建第一个行视图用来显示每个地标详细信息。此行视图将信息存储在它显示的地标的属性中,因此一个视图可以显示任何地标。稍后,您将将多行合并到地标列表中。

第1步 创建LanmarkRow.swift

第2步 数据展示

  1. landmark添加为Landmark的存储属性。

  2. Landmark_Previewspreviews静态属性中,将地标参数添加到 LandmarkRow初始化器中,指定landmarks数组的第一个元素。
    预览显示文本“你好,世界!”。

  3. 将现有文本视图嵌入到HStack中。

  4. 修改文本视图以使用landmark属性name

  5. 通过在文本视图之前添加图像,在文本视图后面添加一个Spacer来完成该行。

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()
        }
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkRow(landmark: landmarks[0])
    }
}

第3节 创建地标列表

当您使用SwiftUI的List类型时,您可以显示特定于平台的视图列表。列表的元素可以是静态的,就像您到目前为止创建的堆栈的子视图一样,也可以是动态生成的。您甚至可以混合静态和动态生成的视图

第1步 创建LandmarkList.swift视图

将默认Text视图替换为List,并提供具有前两个地标作为列表子标记的Landmark实例。

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        VStack {
            LandmarkRow(landmark: landmarks[0])
            LandmarkRow(landmark: landmarks[1])
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

第4节 是列表动态展示

您可以直接从集合生成行,而不是单独指定列表的元素。

您可以通过传递数据集合和为集合中的每个元素提供视图的闭包来创建一个显示集合元素的列表。该列表使用提供的闭包将集合中的每个元素转换为子视图。

第1步 删除替换动态数据

删除两个静态地标行,而是将模型数据的landmarks数组传递给List初始化器。

列表适用于可识别的数据。您可以通过以下两种方式之一使数据可识别:通过将数据传递到唯一标识每个元素的属性的关键路径,或使您的数据类型符合Identifiable协议。

第2步 创建地标行

通过从闭包中返回LandmarkRow完成动态生成的列表。这为landmarks数组中的每个元素创建一个Landmark

第3步 简化list代码

切换到Landmark.swift并声明符合Identifiable协议。
Landmark数据已经具有Identifiable协议所需的id属性;您只需在读取数据时添加一个属性来解码它。

第4步 移除list 中的id

切换回Landmark.swift并删除id参数。

从现在开始,您将能够直接使用Landmark元素的集合。

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        List(landmarks) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

第5节 设置列表和详情页的导航

列表渲染正确,但您还不能点击单个地标来查看该地标的详细信息页面。

您将导航功能添加到列表中,方法是将其嵌入NavigationView中,然后在Navigation中嵌套每行以设置到目标视图的转换。 使用您在上一个教程中创建的内容准备一个详细视图,并更新主要内容视图以显示列表视图

第1步 创建LandmarkDetail.swift

body属性的内容从Content复制到LandmarkDetail.swift

import SwiftUI

struct LandmarkDetail: View {
    var body: some View {
        VStack {
            MapView()
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)
            CircleImage()
                .offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)
                    .foregroundColor(.black)
                
                HStack {
                    Text("Joshua Tree National Park")
                    Spacer()
                    Text("California")
                        
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About Turtle Rock")
                    .font(.title2)
                Text("Descriptive text goes here.")
            }
            .padding(.leading, 10)
            .padding(.trailing, 10)
            
            Spacer()
        }
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkDetail()
    }
}

第2步 将ContentView展示 LandmarkList

import SwiftUI

struct ContentView: View {
    var body: some View {
        LandmarkList()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

第3步 NavigationView 包裹 List

NavigationView中嵌入动态生成的地标列表。
显示列表时,调用navigationTitle(_:)修饰符方法来设置导航栏的标题。
在列表的闭包中,将返回的行包装在NavigationLink中,指定Landmark视图作为目标。

第4步 体验一下

您可以通过切换到实时模式直接在预览中尝试导航。单击实时预览按钮,然后点击地标以访问详细信息页面。

第6节 将数据传递到子视图中

Landmark视图仍然是固定的数据。就像Landmark一样,Landmark类型及其包含的视图需要使用landmark属性作为其数据的来源。

从子视图开始,您将转换CircleMap和LandmarkDetail以显示传入的数据,而不是对每行进行硬编码。

第1步 CircleImage 动态显示图片

Circle.swift中,将存储image属性添加到CircleImage

这是使用SwiftUI构建视图时的常见模式。您的自定义视图通常会为特定视图包装和封装一系列修饰符
更新预览提供程序以传递Turtle Rock的图像。
即使您修复了预览逻辑,预览也无法更新,因为构建失败。实例化圆形图像的细节视图也需要一个输入参数。

第2步 动态显示位置

  1. MapView.swift中,向MapView添加coordinate属性,并更新预览提供程序以传递固定坐标。
  2. 此更改也会影响构建,因为详细视图具有需要新参数的地图视图。您很快就会修复细节视图。
  3. 添加一种基于坐标值更新区域的方法。
  4. 在地图中添加一个onAppear视图修饰符,以触发基于当前坐标的区域计算。
import SwiftUI
import MapKit

struct MapView: View {
    var coordinate: CLLocationCoordinate2D

    @State private var region = MKCoordinateRegion()
    
    
    
    var body: some View {
        Map(coordinateRegion: $region)
            .onAppear {
                setRegion(coordinate)
            }
    }
    
    func setRegion(_ coordiante: CLLocationCoordinate2D) {
        region = MKCoordinateRegion(center: coordinate,
                                    span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2))
    }
    
}

struct Map_Previews: PreviewProvider {
    static var previews: some View {
        MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
    }
}

  1. LandmarkList.swift中,点击时将当前地标传递给详情页landmark
import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                NavigationLink {
                    LandmarkDetail(landmark: landmark)
                } label: {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
        
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
//        LandmarkList()
        LandmarkList()
                    .previewDevice(PreviewDevice(rawValue: "iPhone SE (2nd generation)"))
    }
}

  1. LandmarkDetail.swift中,添加一个属性landmark
  2. 将容器从VStack更改为aScrollView,以便用户可以滚动浏览描述性内容,并删除您不再需要的Spacer
  3. 在LandmarkDetail文件中,将所需数据传递给您的自定义类型。
  4. 最后,在显示详细视图时,调用navigationTitle(_:)修饰符为导航栏提供标题,并调用navigationMode(_:)修饰符使标题内联显示。
import SwiftUI

struct LandmarkDetail: View {
    var landmark: Landmark
    
    var body: some View {
        ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)
            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading) {
                Text(landmark.name)
                    .font(.title)
                    .foregroundColor(.black)
                
                HStack {
                    Text(landmark.park)
                    Spacer()
                    Text(landmark.state)
                        
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding(.leading, 10)
            .padding(.trailing, 10)
            
            Spacer()
        }
        .navigationTitle(landmark.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarks[0])
    }
}

总结

现在列表和详情页串起来了,下一篇继续