跳转至

创建和组合视图

此部分将指引你构建一个发现和分享您喜爱地方的 iOS app —— Landmarks 。首先我们来构建显示地标详细信息的视图。

Landmarks 使用 stacksimagetext 等组件进行组合和分层,以此来给视图布局。如果想给视图添加地图,我们需要引入标准 MapKit 组件。在我们调整设计时,Xcode 可以作出实时反馈,以便我们看到这些调整是如何转换为代码的。

下载项目文件并按照以下步骤操作。

  • 预计完成时间:40 分钟
  • 初始项目文件:下载

1. 创建一个新项目并且浏览画布

创建一个使用 SwiftUI 的 Xcode 项目,先浏览一下画布,预览区和 SwiftUI 的模版代码。

要在 Xcode 中使用画布,需要确保你的 Mac 系统为 macOS Catalina 10.15。

1.1 打开 Xcode ,在 Xcode 的启动窗口中单击 Create a new Xcode project ,或选择 File > New > Project

1.2 选择 iOS 平台, Single View App 模板,然后单击 Next

1.3 输入 Landmarks 作为项目名,勾选 Use SwiftUI 复选框,然后单击 Next 。选择一个位置保存此项目。

1.4 在项目导航栏中,选中 ContentView.swift

SwiftUI view 文件默认声明了两个结构体。第一个结构体遵循 View 协议,描述视图的内容和布局。第二个结构体声明该视图的预览。

ContentView.swift

import SwiftUI

//
struct ContentView: View {
    var body: some View {
        Text("Hello World")
    }
}
//

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

1.5 在画布中,单击 Resume 来显示预览。

Tip:如果画布没有出现,可以选择 Editor > Editor and Canvas 来显示。

1.6 在 body 属性中,将 Hello World 更改为自己的问候语。

更改代码的同时,预览也会实时更新。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        //
        Text("Hello SwiftUI!")
        //
    }
}

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

2. 自定义文字视图

我们可以更改代码,或者使用检查器帮助我们编写代码,来自定义视图的显示。

在构建 Landmarks 的过程中,我们可以使用任何方式来实现:编写源码、修改画布、或者通过检查器,无论使用哪种工具,代码都会保持更新。

接下来,我们使用检查器来自定义文字视图。

2.1 在预览中,按住 Command 并单击问候语来显示编辑窗口,然后选择 Inspect

编辑窗口会显示可以修改的不同属性,具体取决于其视图类型。

2.2 用检查器将文本改为 Turtle Rock ,这是在 app 中显示的第一个地标的名字。

2.3 将 Font 修饰符改为 Title

这个修改会让文本使用系统字体,之后它就能正确适应用户的偏好字体大小和设置。

为了自定义 SwiftUI 视图,我们可以调用称为 修饰符(modifier)的方法。修饰符会包装视图来更改其显示或其他属性。每个修饰符都会返回一个新视图,因此常常链式调用多个修饰符。

2.4 在代码中添加 foregroundColor(.green) 修饰符,将文本的颜色更改为绿色。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Turtle Rock")
            .font(.title)
            //
            .foregroundColor(.green)
            //
    }
}

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

视图是代码的真实反馈,所以当我们使用检查器修改或删除修饰符时,Xcode 也会立即更新我们的代码。

2.5 这次我们在代码编辑区按住 Command ,单击 Text 的声明来打开检查器,然后选择 Inspect 。单击 Color 菜单并且选择 Inherited ,这样文字又变回了黑色。

2.6 注意,Xcode 会自动针对修改来更新代码,例如删除了 foregroundColor(.green) 修饰符。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Turtle Rock")
            .font(.title)
            //
            //
    }
}

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

3. 用 Stack 组合视图

在上一节创建标题视图后,我们来添加用来显示地标的详细信息的文字视图,比如公园的名称和所在的州。

在创建 SwiftUI 视图时,我们可以在视图的 body 属性中描述其内容、布局和行为。由于 body 属性仅返回单个视图,所以我们可以使用 Stack 来组合和嵌入多个视图,让它们以水平、垂直或从后到前的顺序组合在一起。

在本节中,我们使用水平的 stack 来显示公园的详细信息,再用垂直的 stack 将标题放在详细信息的上面。

我们可以使用 Xcode 的结构编辑功能将一个视图嵌入到一个容器里,也可以使用检查器或 help 找到更多帮助。

3.1 按住 Command 并单击文字视图的初始化方法,在编辑窗口中选择 Embed in VStack

接下来,我们从 Library 中拖一个 Text view 添加到 stack 中。

3.2 单击 Xco​​de 右上角的加号按钮 (+) 打开 Library ,然后拖一个 Text view ,放在代码中 Turtle Rock 的后面。

3.3 将 Placeholder 改成 Joshua Tree National Park

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Turtle Rock")
                .font(.title)
            //
            Text("Joshua Tree National Park")
            //
        }
    }
}

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

调整地点视图以达到布局需求。

3.4 将地点视图的 font 设置成 subheadline

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Turtle Rock")
                .font(.title)
            Text("Joshua Tree National Park")
                //
                .font(.subheadline)
                //
        }
    }
}

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

3.5 编辑 VStack 的初始化方法,将 view 以 leading 方式对齐。

默认情况下, stacks 会将内容沿其轴居中,并设置适合上下文的间距。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        //
        VStack(alignment: .leading) {
        //
            Text("Turtle Rock")
                .font(.title)
            Text("Joshua Tree National Park")
                .font(.subheadline)
        }
    }
}

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

接下来,我们在地点的右侧添加另一个文字视图来显示公园所在的州。

3.6 在画布中按住 Command ,单击 Joshua Tree National Park ,然后选择 Embed in HStack

3.7 在地点后新加一个 text view,将 Placeholder 修改成 California ,然后将 font 设置成 subheadline

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("Turtle Rock")
                .font(.title)
            HStack {
                Text("Joshua Tree National Park")
                    .font(.subheadline)
                //
                Text("California")
                    .font(.subheadline)
                //
            }
        }
    }
}

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

3.8 在水平 stack 中添加一个 Spacer 来分割及固定 Joshua Tree National ParkCalifornia ,这样它们就会共享整个屏幕宽度。

spacer 能撑开 stack 所包含的视图,使它们共用其父视图的所有空间,而不是仅通过其内容定义其大小。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("Turtle Rock")
                .font(.title)
            HStack {
                Text("Joshua Tree National Park")
                    .font(.subheadline)
                //
                Spacer()
                //
                Text("California")
                    .font(.subheadline)
            }
        }
    }
}

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

3.9 最后,用 padding() 修饰符给地标的名称和信息留出一些空间。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("Turtle Rock")
                .font(.title)
            HStack {
                Text("Joshua Tree National Park")
                    .font(.subheadline)
                Spacer()
                Text("California")
                    .font(.subheadline)
            }
        }
        //
        .padding()
        //
    }
}

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

4. 自定义图片视图

搞定名称和位置视图后,我们来给地标添加图片。

这不需要添加很多代码,只需要创建一个自定义视图,然后给图片加上遮罩、边框和阴影即可。

首先将图片添加到项目的 asset catalog 中。

4.1 在项目的 Resources 文件夹中找到 turtlerock.png ,将它拖到 asset catalog 的编辑器中。 Xcode 会给图片创建一个 image set

接下来,创建一个新的 SwiftUI 视图来自定义图片视图。

4.2 选择 File > New > File 打开模板选择器。在 User Interface 中,选中 SwiftUI View ,然后单击 Next 。将文件命名为 CircleImage.swift ,然后单击 Create

现在准备工作已完成。

4.3 使用 Image(_:) 初始化方法将文字视图替换为 Turtle Rock 的图片。

CircleImage.swift

import SwiftUI

struct CircleImage: View {
    var body: some View {
        //
        Image("turtlerock")
        //
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}

4.4 调用 .clipShape(Circle()) ,将图像裁剪成圆形。

Circle 可以当做一个蒙版的形状,也可以通过 strokefill 绘制视图。

CircleImage.swift

import SwiftUI

struct CircleImage: View {
    var body: some View {
        Image("turtlerock")
            //
            .clipShape(Circle())
            //
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}

4.5 创建另一个 gray strokecircle ,然后将其作为 overlay 添加到图片上,形成图片的边框。

CircleImage.swift

import SwiftUI

struct CircleImage: View {
    var body: some View {
        Image("turtlerock")
            .clipShape(Circle())
            //
            .overlay(
                Circle().stroke(Color.gray, lineWidth: 4))
            //
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}

4.6 接来下,添加一个半径为 10 点的阴影。

CircleImage.swift

import SwiftUI

struct CircleImage: View {
    var body: some View {
        Image("turtlerock")
            .clipShape(Circle())
            .overlay(
                Circle().stroke(Color.gray, lineWidth: 4))
            //
            .shadow(radius: 10)
            //
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}

4.7 将边框的颜色改为 white ,完成图片视图。

CircleImage.swift

import SwiftUI

struct CircleImage: View {
    var body: some View {
        Image("turtlerock")
            .clipShape(Circle())
            .overlay(
                //
                Circle().stroke(Color.white, lineWidth: 4))
                //
            .shadow(radius: 10)
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}

5. 同时使用 UIKit 和 SwiftUI

至此,我们已准备好创建地图视图了,接下来使用 MapKit 中的 MKMapView 类来渲染地图。

SwiftUI 中使用 UIView 子类,需要将其他视图包装在遵循 UIViewRepresentable 协议的 SwiftUI 视图中。 SwiftUI 包含了和 WatchKitAppKit 视图类似的协议。

首先,我们创建一个可以呈现 MKMapView 的自定义视图。

5.1 选择 File > New > File ,选择 iOS 平台,选择 SwiftUI View 模板,然后单击 Next 。将新文件命名为 MapView.swift ,然后单击 Create

5.2 给 MapKit 添加 import 语句,声明 MapView 类型遵循 UIViewRepresentable

可以忽略 Xcode 的错误,接下来的几步会解决这些问题。

MapView.swift

import SwiftUI
//
import MapKit

struct MapView: UIViewRepresentable {
//
    var body: some View {
        Text("Hello World")
    }
}

struct MapView_Preview: PreviewProvider {
    static var previews: some View {
        MapView()
    }
}

UIViewRepresentable 协议需要实现两个方法: makeUIView(context:) 用来创建一个 MKMapViewupdateUIView(_:context:) 用来配置视图并响应修改。

5.3 用 makeUIView(context:) 方法替换 body 属性,该方法创建并返回一个空的 MKMapView

MapView.swift

import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    //
    func makeUIView(context: Context) -> MKMapView {
        MKMapView(frame: .zero)
    //
    }
}

struct MapView_Preview: PreviewProvider {
    static var previews: some View {
        MapView()
    }
}

5.4 实现 updateUIView(_:context:) 方法,给地图视图设置坐标,使其在 Turtle Rock 上居中。

MapView.swift

import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    func makeUIView(context: Context) -> MKMapView {
        MKMapView(frame: .zero)
    }

    //
    func updateUIView(_ view: MKMapView, context: Context) {
        let coordinate = CLLocationCoordinate2D(
            latitude: 34.011286, longitude: -116.166868)
        let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        view.setRegion(region, animated: true)
    }
    //
}

struct MapView_Preview: PreviewProvider {
    static var previews: some View {
        MapView()
    }
}

当预览处于 static mode 时仅显示 SwiftUI 视图 。因为 MKMapView 是一个 UIView 的子类,所以需要切换到实时模式才能看到地图。

5.5 单击 Live Preview 可将预览切换为实时模式,有时也会用到 Try AgainResume 按钮。

片刻之后,你会看到 Joshua Tree National Park 的地图,这是 Turtle Rock 的故乡。

6. 编写详情视图

现在我们完成了所需的所有组件:名称、地点、圆形图片和地图。

继续使用目前的工具,将这些组件组合起来变成符合最终设计的详情视图。

6.1 在项目导航中,选中 ContentView.swift 文件。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("Turtle Rock")
                .font(.title)
            HStack {
                Text("Joshua Tree National Park")
                    .font(.subheadline)
                Spacer()
                Text("California")
                    .font(.subheadline)
            }
        }
        .padding()
    }
}

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

6.2 把之前的的 VStack 嵌入到另一个新 的 VStack 中。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        //
        VStack {
            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)
                HStack(alignment: .top) {
                    Text("Joshua Tree National Park")
                        .font(.subheadline)
                    Spacer()
                    Text("California")
                        .font(.subheadline)
                }
            }
            .padding()
        }
        //
    }
}

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

6.3 将自定义的 MapView 添加到 stack 顶部,使用 frame(width:height:) 方法来设置 MapView 的大小。

如果仅指定了 height 参数,视图会自动调整其内容的宽度。此节中, MapView 会展开并填充所有可用空间。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            MapView()
                .frame(height: 300)

            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)
                HStack(alignment: .top) {
                    Text("Joshua Tree National Park")
                        .font(.subheadline)
                    Spacer()
                    Text("California")
                        .font(.subheadline)
                }
            }
            .padding()
        }
    }
}

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

6.4 单击 Live Preview 按钮,查看渲染的地图。

在此过程中,我们可以继续编辑视图。

6.5 将 CircleImage 添加到 stack 中。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            MapView()
                .frame(height: 300)

            //
            CircleImage()
            //

            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)
                HStack(alignment: .top) {
                    Text("Joshua Tree National Park")
                        .font(.subheadline)
                    Spacer()
                    Text("California")
                        .font(.subheadline)
                }
            }
            .padding()
        }
    }
}

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

6.6 为了将图片视图盖在地图视图上面,我们需要给图片设置 -130 点的偏移量,并从底部填充 -130 点。

图片向上移动后,就为文本腾出了空间。

ContentView.swift

import SwiftUI

struct ContentView: 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()
        }
    }
}

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

6.7 在外部 VStack 的底部添加一个 spacer ,将内容推到屏幕顶端。

ContentView.swift

import SwiftUI

struct ContentView: 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()
            //
        }
    }
}

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

6.8 最后,为了将地图内容扩展到屏幕的上边缘,需要给地图视图添加 edgesIgnoringSafeArea(.top) 修饰符。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            MapView()
                //
                .edgesIgnoringSafeArea(.top)
                //
                .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 ContentView_Preview: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}