1. 准备工作
此 Codelab 会教您如何将 Maps SDK for iOS 与 SwiftUI 搭配使用。
前提条件
- 掌握 Swift 基础知识
- 基本熟悉 SwiftUI
您应执行的操作
- 启用并使用 Maps SDK for iOS,以使用 SwiftUI 将 Google 地图添加到 iOS 应用。
- 向地图添加标记。
- 将状态在 SwiftUI 视图和
GMSMapView
对象之间进行双向传递。
所需条件
- Xcode 11.0 或更高版本
- 启用了结算功能的 Google 帐号
- Maps SDK for iOS
- Carthage
2. 进行设置
为了完成以下启用步骤,请启用 Maps SDK for iOS。
设置 Google Maps Platform
如果您还没有已启用结算功能的 Google Cloud Platform 帐号和项目,请参阅 Google Maps Platform 使用入门指南,创建结算帐号和项目。
- 在 Cloud Console 中,点击项目下拉菜单,选择要用于此 Codelab 的项目。
3. 下载起始代码
为帮助您尽快入门,我们在下面提供了一些起始代码,帮助您顺利完成此 Codelab。您可以跳到解决方案部分,但如果您想要按照所有步骤自行构建,请继续阅读。
- 克隆代码库(如果您已安装
git
)。
git clone https://github.com/googlecodelabs/maps-ios-swiftui.git
或者,您也可以点击下面的按钮,下载源代码。
- 获取代码后,在终端
cd
中进入starter/GoogleMapsSwiftUI
目录。 - 运行
carthage update --platform iOS
以下载 Maps SDK for iOS - 最后,在 Xcode 中打开
GoogleMapsSwiftUI.xcodeproj
文件
4. 代码概览
您下载的入门级项目中已为您提供并实现以下类:
AppDelegate
- 应用的UIApplicationDelegate
。系统会在此处初始化 Maps SDK for iOS。City
- 表示城市的结构体(包含城市的名称和坐标)。MapViewController
- 一个简单的 UIKitUIViewController
,其中包含 Google 地图 (GMSMapView)SceneDelegate
- 应用的UIWindowSceneDelegate
,系统会在此处实例化ContentView
。
此外,以下类具有部分实现,您需要在此 Codelab 结束时完成这些实现:
ContentView
- 包含您应用的顶级 SwiftUI 视图。MapViewControllerBridge
- 用于将 UIKit 视图与 SwiftUI 视图桥接起来的类。具体而言,此类将使MapViewController
可在 SwiftUI 中访问。
5. 使用 SwiftUI 与使用 UIKit 对比
SwiftUI 是在 iOS 13 中作为 UIKit 的替代界面框架引入的,用于开发 iOS 应用。与其前身 UIKit 相比,SwiftUI 具有诸多优势。下面列举了一些:
- 当状态更改时,视图会自动更新。使用名为 State 的对象时,对其包含的底层值所做的任何更改都会使界面自动更新。
- 实时预览可提高开发效率。借助实时预览,可轻松地在 Xcode 上查看 SwiftUI 视图的预览,因此可最大限度地减少构建代码并将其部署到模拟器以查看视觉变化的需求。
- 可信来源是 Swift。SwiftUI 中的所有视图都在 Swift 中声明,因此不再需要使用 Interface Builder。
- 可与 UIKit 互操作。与 UIKit 的互操作性可确保现有应用能够为其现有视图逐步使用 SwiftUI。此外,尚不支持 SwiftUI 的库(如 Maps SDK for iOS)仍可在 SwiftUI 中使用。
SwiftUI 也有一些缺点:
- SwiftUI 仅适用于 iOS 13 或更高版本。
- 无法在 Xcode 预览中检查视图层次结构。
SwiftUI 状态和数据流
SwiftUI 提供了一种使用声明式方法创建界面的新方式,您只需告诉 SwiftUI 您希望视图如何随着其所有不同的状态而变化,系统将完成其余工作。每当底层状态因事件或用户操作而变化时,SwiftUI 都会更新视图。此设计通常称为“单向数据流”。虽然此设计的具体细节不在此 Codelab 的讲授范围内,不过我们建议您阅读 Apple 的“State and Data”(状态和数据流)文档,了解此设计的工作原理。
使用 UIViewRepresentable 或 UIViewControllerRepresentable 桥接 UIKit 和 SwiftUI
由于 Maps SDK for iOS 是在 UIKit 的基础上构建的,且尚不提供与 SwiftUI 兼容的视图,因此若要在 SwiftUI 中使用,必须符合 UIViewRepresentable
或 UIViewControllerRepresentable
。这两个协议分别可让 SwiftUI 添加基于 UIKit 构建的 UIView
和 UIViewController
。虽然您可以使用任一协议将 Google 地图添加到 SwiftUI 视图,但在下一步中,我们将了解如何使用 UIViewControllerRepresentable
添加包含地图的 UIViewController
。
6. 添加地图
在本部分中,您将向 SwiftUI 视图添加 Google 地图。
添加您的 API 密钥
您需要将在之前的步骤中创建的 API 密钥提供给 Maps SDK for iOS,以便将您的帐号与将在应用中显示的地图相关联。
若要提供 API 密钥,请打开 AppDelegate.swift
文件并找到 application(_, didFinishLaunchingWithOptions)
方法。目前,该 SDK 是通过包含字符串“YOUR_API_KEY”的 GMSServices.provideAPIKey()
初始化的。将该字符串替换为您的 API 密钥。完成此步骤后,系统会在应用启动时初始化 Maps SDK for iOS。
使用 MapViewControllerBridge 添加 Google 地图
现在,您的 API 密钥已提供给该 SDK,下一步就是在应用中显示地图。
起始代码中提供的视图控制器 MapViewController
目前在其视图中包含 GMSMapView
。不过,由于此视图控制器是在 UIKit 中创建的,您将需要把此类桥接至 SwiftUI,它才可以在 ContentView
内使用。为此,请执行以下操作:
- 在 Xcode 中打开
MapViewControllerBridge
文件。
此类符合 UIViewControllerRepresentable(封装 UIKit UIViewController
所需的协议),因此可用作 SwiftUI 视图。换句话说,符合此协议可让您将 UIKit 视图桥接到 SwiftUI 视图。为了符合此协议,需要实现两种方法:
makeUIViewController(context)
- SwiftUI 会调用此方法来创建底层UIViewController
。您可以在此处实例化UIViewController
并传入其初始状态。updateUIViewController(_, context)
- 每当状态变化时,SwiftUI 都会调用此方法。您将在此处对底层UIViewController
进行任何修改,以响应状态变化。
- 创建
MapViewController
在 makeUIViewController(context)
函数内,实例化一个新的 MapViewController
并将其作为结果返回。完成此操作后,您的 MapViewControllerBridge
现在应如下所示:
MapViewControllerBridge
import GoogleMaps
import SwiftUI
struct MapViewControllerBridge: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> MapViewController {
return MapViewController()
}
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
}
}
在 ContentView 中使用 MapViewControllerBridge
现在,MapViewControllerBridge
正在创建 MapViewController
的实例,下一步是在 ContentView
中使用此结构体来显示地图。
- 在 Xcode 中打开
ContentView
文件。
ContentView
会在 SceneDelegate
中实例化,且包含顶级应用视图。系统将从此文件内添加地图。
- 在
body
属性中创建MapViewControllerBridge
。
此文件的 body
属性中已为您提供并实现了 ZStack
。ZStack
目前包含一个可互动且可拖动的城市列表,您将在后续步骤中用到它。现在,在 ZStack
中创建一个 MapViewControllerBridge
作为 ZStack
的第一个子视图,这样,地图就会在应用中显示在城市列表视图的后面。完成此操作后,ContentView
中 body
属性的内容应如下所示:
ContentView
var body: some View {
let scrollViewHeight: CGFloat = 80
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge()
// Cities List
CitiesList(markers: $markers) { (marker) in
guard self.selectedMarker != marker else { return }
self.selectedMarker = marker
self.zoomInCenter = false
self.expandList = false
} handleAction: {
self.expandList.toggle()
} // ...
}
}
}
- 接下来运行应用,您应该会看到地图在您的设备屏幕上加载,同时屏幕底部会显示一个可拖动的城市列表。
7. 向地图添加标记
在上一步中,您添加了一个地图以及一个显示一系列城市的可互动列表。在本部分中,您将为该列表中的每个城市添加标记。
标记作为 State
ContentView
目前声明了一个名为 markers
的属性,该属性是一个 GMSMarker
列表,表示 cities
静态属性中声明的每个城市。请注意,此属性带有 SwiftUI 属性封装容器 State 注解,用于指明它应由 SwiftUI 管理。因此,如果系统检测到此属性发生任何变化(例如添加或移除标记),就会更新使用此状态的视图。
ContentView
static let cities = [
City(name: "San Francisco", coordinate: CLLocationCoordinate2D(latitude: 37.7576, longitude: -122.4194)),
City(name: "Seattle", coordinate: CLLocationCoordinate2D(latitude: 47.6131742, longitude: -122.4824903)),
City(name: "Singapore", coordinate: CLLocationCoordinate2D(latitude: 1.3440852, longitude: 103.6836164)),
City(name: "Sydney", coordinate: CLLocationCoordinate2D(latitude: -33.8473552, longitude: 150.6511076)),
City(name: "Tokyo", coordinate: CLLocationCoordinate2D(latitude: 35.6684411, longitude: 139.6004407))
]
/// State for markers displayed on the map for each city in `cities`
@State var markers: [GMSMarker] = cities.map {
let marker = GMSMarker(position: $0.coordinate)
marker.title = $0.name
return marker
}
请注意,ContentView
会使用 markers
属性将城市列表传递给 CitiesList
类,以渲染该列表。
CitiesList
struct CitiesList: View {
@Binding var markers: [GMSMarker]
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// ...
// List of Cities
List {
ForEach(0..<self.markers.count) { id in
let marker = self.markers[id]
Button(action: {
buttonAction(marker)
}) {
Text(marker.title ?? "")
}
}
}.frame(maxWidth: .infinity)
}
}
}
}
通过 Binding 将 State 传递给 MapViewControllerBridge
除了显示 markers
属性中的数据的城市列表外,还应将此属性传递给 MapViewControllerBridge
结构体,以便将其用于在地图上显示这些标记。为此,请执行以下操作:
- 在
MapViewControllerBridge
中声明一个带有@Binding
注解的新markers
属性
MapViewControllerBridge
struct MapViewControllerBridge: : UIViewControllerRepresentable {
@Binding var markers: [GMSMarker]
// ...
}
- 在
MapViewControllerBridge
中,更新updateUIViewController(_, context)
方法以使用markers
属性
如上一步中所述,每当状态发生变化时,SwiftUI 都会调用 updateUIViewController(_, context)
。我们需要在此方法内更新地图,以显示 markers
中的标记。为此,您将需要更新每个标记的 map
属性。完成此步骤后,您的 MapViewControllerBridge
应如下所示:
import GoogleMaps
import SwiftUI
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var markers: [GMSMarker]
func makeUIViewController(context: Context) -> MapViewController {
return MapViewController()
}
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
// Update the map for each marker
markers.forEach { $0.map = uiViewController.map }
}
}
- 将
markers
属性从ContentView
传递给MapViewControllerBridge
由于您在 MapViewControllerBridge
中添加了一个新属性,现在需要将此属性的值传递给 MapViewControllerBridge
的初始化程序。如果您尝试构建应用,应该会注意到应用无法编译。若要解决此问题,请更新 ContentView
(MapViewControllerBridge
的创建位置),并传入 markers
属性,如下所示:
struct ContentView: View {
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge(markers: $markers)
// ...
}
}
}
}
请注意,前缀 $
用于将 markers
传递给 MapViewControllerBridge
,因为前者需要绑定属性。$
是一个用于 Swift 属性封装容器的预留前缀。应用到 State 时,它将返回 Binding。
- 接下来运行应用,看看地图上显示的标记。
8. 为所选城市添加动画效果
在上一步中,您通过将 State 从一个 SwiftUI 视图传递给另一个 SwiftUI 视图,向地图添加了标记。在这一步中,您将为城市/标记添加动画效果,当用户在可互动列表中点按该城市/标记后即会显示此动画效果。若要执行动画,您需要对 State 的变化做出响应,具体方法是在发生变化时修改地图的相机位置。如需详细了解地图相机的概念,请参阅相机和视图。
使地图以动画形式呈现所选城市
若要使地图以动画形式呈现所选城市,请执行以下操作:
- 在
MapViewControllerBridge
中定义新的 Binding
ContentView
具有一个名为 selectedMarker
的 State 属性,它会初始化为 nil,并且每当从列表中选择城市时,该属性就会更新。此操作由 ContentView
中的 CitiesList
视图 buttonAction
处理。
ContentView
CitiesList(markers: $markers) { (marker) in
guard self.selectedMarker != marker else { return }
self.selectedMarker = marker
// ...
}
每当 selectedMarker
发生变化时,MapViewControllerBridge
都应知悉此状态的变化,以便让地图以动画形式呈现所选标记。因此,请在 MapViewControllerBridge
中创建一个类型为 GMSMarker
的新 Binding,并将该属性命名为 selectedMarker
。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var selectedMarker: GMSMarker?
}
- 更新
MapViewControllerBridge
,以在selectedMarker
发生变化时在地图上呈现动画效果
声明新的 Binding 后,您需要更新 MapViewControllerBridge
的 updateUIViewController_, context)
函数,以便地图以动画形式呈现所选标记。复制以下代码,然后继续操作:
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var selectedMarker: GMSMarker?
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
markers.forEach { $0.map = uiViewController.map }
selectedMarker?.map = uiViewController.map
animateToSelectedMarker(viewController: uiViewController)
}
private func animateToSelectedMarker(viewController: MapViewController) {
guard let selectedMarker = selectedMarker else {
return
}
let map = viewController.map
if map.selectedMarker != selectedMarker {
map.selectedMarker = selectedMarker
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(toZoom: kGMSMinZoomLevel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(with: GMSCameraUpdate.setTarget(selectedMarker.position))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
map.animate(toZoom: 12)
})
}
}
}
}
}
animateToSelectedMarker(viewController)
函数将使用 GMSMapView
的 animate(with)
函数执行一系列地图动画。
- 将
ContentView
的selectedMarker
传递给MapViewControllerBridge
在 MapViewControllerBridge
声明新的 Binding 后,继续更新 ContentView
以传入 selectedMarker
(MapViewControllerBridge
进行实例化的位置)。
ContentView
struct ContentView: View {
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker)
// ...
}
}
}
}
完成此步骤后,每当用户在列表中选择新城市时,系统现在就会为地图呈现动画效果。
为 SwiftUI 视图添加动画效果以强调城市
SwiftUI 将为 State 转换执行动画,让您能够轻而易举地为视图添加动画效果。为了进行演示,您将在地图动画播放完毕后,将视图的焦点移至所选城市,从而添加更多动画。为此,请完成以下步骤:
- 为
MapViewControllerBridge
添加onAnimationEnded
闭包
由于 SwiftUI 动画将在您之前添加的地图动画序列之后执行,因此请在 MapViewControllerBridge
中声明一个名为 onAnimationEnded
的新闭包,并在 animateToSelectedMarker(viewController)
方法中的最后一个地图动画播放完毕后,延迟 0.5 秒后再调用此闭包。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
var onAnimationEnded: () -> ()
private func animateToSelectedMarker(viewController: MapViewController) {
guard let selectedMarker = selectedMarker else {
return
}
let map = viewController.map
if map.selectedMarker != selectedMarker {
map.selectedMarker = selectedMarker
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(toZoom: kGMSMinZoomLevel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(with: GMSCameraUpdate.setTarget(selectedMarker.position))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
map.animate(toZoom: 12)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
// Invoke onAnimationEnded() once the animation sequence completes
onAnimationEnded()
})
})
}
}
}
}
}
- 在
MapViewControllerBridge
中实现onAnimationEnded
实现 onAnimationEnded
闭包,其中 MapViewControllerBridge
会在 ContentView
内实例化。复制并粘贴以下代码,该代码会添加一个名为 zoomInCenter
的新 State,还会使用 clipShape
修改视图,并根据 zoomInCenter
的值更改裁剪形状的直径
ContentView
struct ContentView: View {
@State var zoomInCenter: Bool = false
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
let diameter = zoomInCenter ? geometry.size.width : (geometry.size.height * 2)
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker, onAnimationEnded: {
self.zoomInCenter = true
})
.clipShape(
Circle()
.size(
width: diameter,
height: diameter
)
.offset(
CGPoint(
x: (geometry.size.width - diameter) / 2,
y: (geometry.size.height - diameter) / 2
)
)
)
.animation(.easeIn)
.background(Color(red: 254.0/255.0, green: 1, blue: 220.0/255.0))
}
}
}
}
- 接下来运行应用,看看动画效果!
9. 将事件发送到 SwiftUI
在这一步中,您将监听从 GMSMapView
发出的事件,并将该事件发送到 SwiftUI。具体而言,您将为地图视图设置委托,并监听相机移动事件,这样一来,当某个城市处于聚焦状态且地图相机随手势移动时,地图视图将失去焦点,以便您能查看地图的更多部分。
使用 SwiftUI 协调器
GMSMapView
会发出事件,例如相机位置变化或点按标记。监听这些事件的机制是通过 GMSMapViewDelegate 协议实现的。SwiftUI 引入了协调器概念,协调器专门用于充当 UIKit 视图控制器的代理。因此,在 SwiftUI 环境中,协调器应负责确保符合 GMSMapViewDelegate
协议。为此,请完成以下步骤:
- 在
MapViewControllerBridge
中创建名为MapViewCoordinator
的协调器
在 MapViewControllerBridge
类内创建一个嵌套类,并将其命名为 MapViewCoordinator
。此类应符合 GMSMapViewDelegate
,并且应将 MapViewControllerBridge
声明为属性。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
final class MapViewCoordinator: NSObject, GMSMapViewDelegate {
var mapViewControllerBridge: MapViewControllerBridge
init(_ mapViewControllerBridge: MapViewControllerBridge) {
self.mapViewControllerBridge = mapViewControllerBridge
}
}
}
- 在
MapViewControllerBridge
中实现makeCoordinator()
接下来,在 MapViewControllerBridge
中实现 makeCoordinator()
方法,并返回您在上一步中创建的 MapViewCoodinator
实例。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
func makeCoordinator() -> MapViewCoordinator {
return MapViewCoordinator(self)
}
}
- 将
MapViewCoordinator
设为地图视图的委托
创建自定义协调器后,下一步是将协调器设置为视图控制器的地图视图的委托。为此,请在 makeUIViewController(context)
中更新视图控制器初始化。您可以通过 Context 对象访问上一步中创建的协调器。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
func makeUIViewController(context: Context) -> MapViewController {
let uiViewController = MapViewController()
uiViewController.map.delegate = context.coordinator
return uiViewController
}
- 为
MapViewControllerBridge
添加闭包,以便可以向上传播相机的将要移动事件
我们的目标是随着移动相机更新视图,因此请声明一个新的闭包属性,以接受 MapViewControllerBridge
中名为 mapViewWillMove
的布尔值,并在 MapViewCoordinator
内的委托方法 mapView(_, willMove)
中调用此闭包。将 gesture
的值传递给此闭包,使 SwiftUI 视图仅响应与手势相关的相机移动事件。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
var mapViewWillMove: (Bool) -> ()
//...
final class MapViewCoordinator: NSObject, GMSMapViewDelegate {
// ...
func mapView(_ mapView: GMSMapView, willMove gesture: Bool) {
self.mapViewControllerBridge.mapViewWillMove(gesture)
}
}
}
- 更新 ContentView 以传入
mapWillMove
的值
在 MapViewControllerBridge
中声明新的闭包后,更新 ContentView
以传入此新闭包的值。在该闭包中,如果移动事件与手势相关,则将 State zoomInCenter
切换为 false
。这样一来,当地图随手势移动时,系统会有效地再次以完整视图显示地图。
ContentView
struct ContentView: View {
@State var zoomInCenter: Bool = false
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
let diameter = zoomInCenter ? geometry.size.width : (geometry.size.height * 2)
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker, onAnimationEnded: {
self.zoomInCenter = true
}, mapViewWillMove: { (isGesture) in
guard isGesture else { return }
self.zoomInCenter = false
})
// ...
}
}
}
}
- 接下来运行应用,看看新的变化!
10. 恭喜
恭喜您成功完成此 Codelab!您已经掌握了许多基础知识,希望在学完这些课程后,您现在能够使用 Maps SDK for iOS 构建自己的 SwiftUI 应用。
所学内容
- SwiftUI 与 UIKit 之间的区别
- 如何使用 UIViewControllerRepresentable 桥接 SwiftUI 和 UIKit
- 如何通过 State 和 Binding 更改地图视图
- 如何使用协调器将地图视图中的事件发送到 SwiftUI
后续步骤
- Maps SDK for iOS - 查看 Maps SDK for iOS 的官方文档
- Places SDK for iOS - 查找您附近的本地商家和地图注点
- maps-sdk-for-ios-samples - 查看 GitHub 上的示例代码,其中演示了 Maps SDK for iOS 中的所有功能。
- SwiftUI - 查看 Apple 关于 SwiftUI 的官方文档
- 回答下面的问题,帮助我们为您制作最为有用的内容:
您还想学习哪些 Codelab?
上面没有列出您想学习的 Codelab?没关系,请在此处通过创建新问题的方式申请 Codelab。