SwiftUI二列表和导航
代码下载
地标详情页视图已经创建完成,需要提供一种方式让用户可以查看完整的地标列表,并且可以查看每一个地标的详情
下面会创建一个可以展示任何地标信息的视图,并动态生成一个可滚动列表,用户可以点击列表项去查看地标的详细信息。优化视图显示时,可以使用Xcode画布来渲染多个不同设备大小下的预览视图。
样本数据
1、新建项目,在项目中创建 Resources->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"
}…
]
2、创建 Model->ImageStore.swift,用来加载图片:
final class ImageStore {
// 缓存
private static var images = [String: CGImage]()
private static var scale: CGFloat = 2.0
private static func loadImage(name: String) -> CGImage {
guard
let path = Bundle.main.url(forResource: name, withExtension: "jpg"),
let imgSource = CGImageSourceCreateWithURL(path as CFURL, nil),
let img = CGImageSourceCreateImageAtIndex(imgSource, 0, nil)
else { fatalError("图片加载失败!") }
return img
}
static func image(name: String) -> Image {
let img = images[name] ?? loadImage(name: name)
images[name] = img
return Image(img, scale: scale, label: Text(name))
}
}
3、在 Model 目录创建 Data.swift,用来加载数据:
func load<T: Decodable>(_ filename: String) -> T {
guard let path = Bundle.main.url(forResource: filename, withExtension: nil),
let data = try? Data(contentsOf: path),
let result = try? JSONDecoder().decode(T.self, from: data) else {
fatalError("数据加载失败!")
}
return result
}
4、在 Model 目录创建 Landmark.swift,用来承载、组织数据:
/// 数据模型
struct Landmark: Decodable {
let name: String
let category: String
let city: String
let state: String
let id: Int
let park: String
private let coordinates: Coordinates
private let imageName: String
// 只读属性
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: coordinates.latitude, longitude: coordinates.longitude)
}
var image: Image {
ImageStore.image(name: imageName)
}
// 类型属性 数据列表
static let list: [Landmark] = load("landmarkData.json")
}
/// 坐标数据模型
struct Coordinates: Decodable {
let longitude: Double
let latitude: Double
}
5、创建 View 目录,将之前创建的 CircleImage,MapView复制到 View 目录,将之前的 ContentView 更名为 LandmarkDetail。
创建行视图
第一个视图就是用来显示每个地标的行视图,行视图把地标的相关信息存储在一个属性中,一行就可以代表一个地标,稍后就会把这些行组合成为一个列表。
- 创建一个名为 LandmarkRow.swift 的 SwiftUI 视图
- 如果预览视图没有出现,可以选择菜单编辑器->画布,打开画布,并点击Resume进行预览,或者使用Command+Option+Enter快捷键调出画面,再使用Command+Option+P快捷键开始预览模式。
- 添加landmark属性做为LandmarkRow视图的一个存储属性。当添加landmark属性后,预览视图可能会停止工作,因为LandmarkRow视图初始化时需要有一个landmark实例。要想修复预览视图,需要修改Preview Provider
- 在LandmarkRow_Previews的静态属性previews中给LandmarkRow初始化器中传入landmark参数,这个参数使用 Landmark.list 数组的第一个元素。预览视图当前显示Hello, World
- 在一个HStack中嵌入一个Text,修改这个Text,让它使用landmark属性的name字段
- 在Text视图前面添加一个图片视图,在Text视图后面添加Spacer视图
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: Landmark.list[0])
}
}
自定义行预览
Xcode的画布会自动识别当前代码编辑器中遵循PreviewProvider协议的类型,并将它们渲染并展示在画面上。一个视图预览提供者(preview provider)返回一个或多个视图,这些视图可以配置不同的大小和设备型号。可以定制从preview provider中返回的视图被渲染在何种场景下。
1、在LandmarkRow_Previews中,把landmark参数更新为 Landmark.list 数组的第二个元素,预览视图会立即刷新反映第二个元素的渲染情况:
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: Landmark.list[1])
}
}
2、使用previewLayout(_:)修改器设置一个行视图在列表中显示的尺寸大小。可以使用Group的方式,返回多个不同场景下的预览视图:
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: Landmark.list[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
3、把预览的行视图包裹在Group中,把之前的第一个行视图也加进去。Group是一个容器,它可以把视图内容组织起来,Xcode会把Group内的每个子视图当作画布内一个单独的预览视图处理:
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: Landmark.list[0])
.previewLayout(.fixed(width: 300, height: 70))
LandmarkRow(landmark: Landmark.list[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
}
4、为了简化代码,可以把previewLayout(_:)这个修改器应用到外层的Group上,Group的每一个子视图会继承自己所处环境的配置。对preivew provider的修改只会影响预览画布的表现,对实际的应用不会产生影响:
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: Landmark.list[0])
LandmarkRow(landmark: Landmark.list[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
创建地标列表
使用SwiftUI列表类型可以展示平台相关的列表视图。列表的元素可以是静态的,类似于栈内部的子视图,也可以是动态生成的视图,也可以混合动态和静态的视图。
- 创建SwiftUI视图,命名为LandmarkList.swift
- 用List替换默认创建的Text,并将前两个LandmarkRow实例做为列表的子元素,预览视图中会以列表的形式展示出两个地标
List {
LandmarkRow(landmark: Landmark.list[0])
LandmarkRow(landmark: Landmark.list[1])
}
创建动态列表
除了单独列出列表中的每个元素外,列表还可以从一个集合中动态的生成。创建列表时可以传入一个集合数据和一个闭包,闭包会针对每一个数据元素返回一个视图,这个视图就是列表的行视图。
1、从列表中移除两个静态指定的行视图,给列表初始化器传入 Landmark.list 数据,列表要配合可辨别的数据类型使用。想让数据变成可辨别的数据类型有两种方法:
- 传入一个keypath指定数据中哪一个字段用来唯一标识这个数据元素。
- 让数据遵循Identifiable协议
2、在闭包中返回一个LandmarkRow视图,List初始化器中指定数据集合landmarkData和唯一标识符 keypath:.id
,这样列表就会动态生成,如下所示
struct LandmarkList: View {
var body: some View {
NavigationView {
List(Landmark.list, id: .id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}
#Preview {
LandmarkList()
}
3、切换到文件Landmark.swfit,声明Landmark类型遵循Identifiable协议,因为Landmark类型已经定义了id属性,正好满足Identifiable协议,所以不需要添加其它代码:
/// 数据模型
struct Landmark: Decodable, Identifiable {
let name: String
let category: String
let city: String
let state: String
let id: Int
let park: String
private let coordinates: Coordinates
private let imageName: String
// 只读属性
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: coordinates.latitude, longitude: coordinates.longitude)
}
var image: Image {
ImageStore.image(name: imageName)
}
// 类型属性 数据列表
static let list: [Landmark] = load("landmarkData.json")
}
/// 坐标数据模型
struct Coordinates: Decodable {
let longitude: Double
let latitude: Double
}
4、现在切换回文件LandmarkList.swift,移除 keypath.id
,因为landmarkData数据集合的元素已经遵循了Identifiable协议,所以在列表初始化器中可以直接使用,不需要手动标明数据的唯一标识符了:
struct LandmarkList: View {
var body: some View {
NavigationView {
List(Landmark.list) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}
#Preview {
LandmarkList()
}
设置从列表页到详情页的页面导航
地标列表可以正常渲染展示,但是列表的元素点击后没有反应,跳转不到地标详情页。现在就要给列表添加导航能力,把列表视图嵌套到NavigationView视图中,然后把列表的每一个行视图嵌套进NavigationLink视图中,就可以建立起从地标列表视图到地标详情页的跳转。
- 把动态生成的列表视图嵌套进一个NavigationView视图中,调用 navigationBarTitle(_:) 修改器设置地标列表显示时的导航条标题
- 在列表的闭包中,将每一个行元素包裹在NavigationLink中返回,并指定LandmarkDetail视图为目标视图。切换到实时预览模式下可以直接点击地标列表的任意一行,现在就可以跳转到地标详情页了
struct LandmarkList: View {
var body: some View {
NavigationView {
List(Landmark.list) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}.navigationTitle(Text("Landmarks"))
}
}
}
#Preview {
LandmarkList()
}
子视图传入数据
LandmarkDetail视图目前还是使用写死的数据进行展示,与LandmarkRow视图一样,LandmarkDetail视图及它内部的子视图也需要传入landmark数据,并使用它来进行实际的展示。从LandmarkDetail的子视图(CircleImage、MapView)开始,需要把它们都改造成为使用传入的数据进行展示,而不是在布局代码中写死数据展示。
1、改造CircleImage为使用传入的数据进行展示:
- 在CircleImage.swift文件中,添加一个存储属性,命名为image。这是一种在构建SwiftUI视图中很常用的模式,常常会包裹或封装一些属性修改器
- 更新CirleImage的预览结构体,并传入Turtle Rock这个图片进行预览
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_Previews: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}
2、改造MapView为使用传入的数据进行展示:
- 在MapView.swift中添加一个coordinate属性,并使用这个属性来替换写死的经纬度坐标
- 更新MapView的预览结构体,并传入每一个地标的经纬度数据
struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
typealias UIViewType = MKMapView
func makeUIView(context: Context) -> MKMapView {
return MKMapView(frame: .zero)
}
func updateUIView(_ uiView: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
uiView.setRegion(region, animated: true)
}
}
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011286, longitude: -116.166868))
}
}
2、改造LandmarkDetail为使用传入的数据进行展示:
- 在LandmarkDetail.swift中添加landmark属性
- 更新LandmarkDetail预览结构体,并传入第一个地标的数据,把对应子视图的数据传入
- 最后调用navigationBarTitle(_:displayMode:)修改器为地标详情页展示时在导航条上设置一个标题
struct LandmarkDetail: View {
var landmark: Landmark
var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate).edgesIgnoringSafeArea(.top).frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
}
}
.padding()
Spacer()
}.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}
#Preview {
LandmarkDetail(landmark: Landmark.list[0])
}
3、设置数据,预览效果
- 在LandmarkList.swift中,传入当前行的地标数据到地标详情页LandmarkDetail
- 切换到实时预览模式下去查看从地标列表页对应的行跳转到对应地标详情页是否正常
struct LandmarkList: View {
var body: some View {
NavigationView {
List(Landmark.list) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}.navigationTitle(Text("Landmarks"))
}
}
}
#Preview {
LandmarkList()
}
预览视图
要在不同尺寸设备上展示不同的预览视图,默认情况下,预览视图会选择当前Scheme选中的设备尺寸进行渲染,可以使用previewDevice(_:)修改器来改变预览视图的设备。
1、改变当前预览列表,让它渲染在iPhone SE设备上。可以使用画布中 Preview Device 上的设备名称来指定渲染设备
2、在列表的预览视图中,还可以把LandmarkList嵌套进入ForEach实例中,使用设备数组名作为数据:
- ForEach运算作用在集合类型的数据上,就和列表使用集合类型数据一样,可以在子视图使用的任何场景下使用ForEach,例如:stack、list、group等。当元素数据是简单值类型时(例如字符串类型),可以使用
.self
作为keypath去标识 - 使用previewDisplayName(_:)修改器可以给预览视图添加设备标签
- 可以在画布上多设置几个设备进行预览,比较不同设备下视图的展示情况
struct LandmarkList: View {
var body: some View {
NavigationView {
List(Landmark.list) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}.navigationTitle(Text("Landmarks"))
}
}
}
#Preview {
ForEach(["iPhone SE 3rd generation", "iPhone 15", "iPhone 15 Plus"], id: .self) { deviceName in
LandmarkList().previewDevice(PreviewDevice(rawValue: deviceName))
}
}
3、在SceneDelegate.swift中把应用的根视图替换为LandmarkList。应用在模拟器中独立启动时使用SceneDelegate的根视图做为第一个展示的视图:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// 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()
}
}
}