使用 SwiftUI (Swift) 将地图添加到您的 iOS 应用

1. 准备工作

此 Codelab 会教您如何将 Maps SDK for iOS 与 SwiftUI 搭配使用。

screenshot-iphone-12-black@2x.png

前提条件

  • 掌握 Swift 基础知识
  • 基本熟悉 SwiftUI

您应执行的操作

  • 启用并使用 Maps SDK for iOS,以使用 SwiftUI 将 Google 地图添加到 iOS 应用。
  • 向地图添加标记。
  • 将状态在 SwiftUI 视图和 GMSMapView 对象之间进行双向传递。

所需条件

2. 进行设置

为了完成以下启用步骤,请启用 Maps SDK for iOS。

设置 Google Maps Platform

如果您还没有已启用结算功能的 Google Cloud Platform 帐号和项目,请参阅 Google Maps Platform 使用入门指南,创建结算帐号和项目。

  1. 在 Cloud Console 中,点击项目下拉菜单,选择要用于此 Codelab 的项目。

  1. 在 Google Cloud Marketplace 中启用此 Codelab 所需的 Google Maps Platform API 和 SDK。为此,请按照此视频或此文档中的步骤操作。
  2. 在 Cloud Console 的凭据页面中生成 API 密钥。您可以按照此视频或此文档中的步骤操作。向 Google Maps Platform 发出的所有请求都需要 API 密钥。

3. 下载起始代码

为帮助您尽快入门,我们在下面提供了一些起始代码,帮助您顺利完成此 Codelab。您可以跳到解决方案部分,但如果您想要按照所有步骤自行构建,请继续阅读。

  1. 克隆代码库(如果您已安装 git)。
git clone https://github.com/googlecodelabs/maps-ios-swiftui.git

或者,您也可以点击下面的按钮,下载源代码。

  1. 获取代码后,在终端 cd 中进入 starter/GoogleMapsSwiftUI 目录。
  2. 运行 carthage update --platform iOS 以下载 Maps SDK for iOS
  3. 最后,在 Xcode 中打开 GoogleMapsSwiftUI.xcodeproj 文件

4. 代码概览

您下载的入门级项目中已为您提供并实现以下类:

  • AppDelegate - 应用的 UIApplicationDelegate。系统会在此处初始化 Maps SDK for iOS。
  • City - 表示城市的结构体(包含城市的名称和坐标)。
  • MapViewController - 一个简单的 UIKit UIViewController,其中包含 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 地图。

add-a-map-screenshot@2x.png

添加您的 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 内使用。为此,请执行以下操作:

  1. 在 Xcode 中打开 MapViewControllerBridge 文件。

此类符合 UIViewControllerRepresentable(封装 UIKit UIViewController 所需的协议),因此可用作 SwiftUI 视图。换句话说,符合此协议可让您将 UIKit 视图桥接到 SwiftUI 视图。为了符合此协议,需要实现两种方法:

  • makeUIViewController(context) - SwiftUI 会调用此方法来创建底层 UIViewController。您可以在此处实例化 UIViewController 并传入其初始状态。
  • updateUIViewController(_, context) - 每当状态变化时,SwiftUI 都会调用此方法。您将在此处对底层 UIViewController 进行任何修改,以响应状态变化。
  1. 创建 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 中使用此结构体来显示地图。

  1. 在 Xcode 中打开 ContentView 文件。

ContentView 会在 SceneDelegate 中实例化,且包含顶级应用视图。系统将从此文件内添加地图。

  1. 在 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()
      } // ...
    }
  }
}
  1. 接下来运行应用,您应该会看到地图在您的设备屏幕上加载,同时屏幕底部会显示一个可拖动的城市列表。

7. 向地图添加标记

在上一步中,您添加了一个地图以及一个显示一系列城市的可互动列表。在本部分中,您将为该列表中的每个城市添加标记。

map-with-markers@2x.png

标记作为 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 结构体,以便将其用于在地图上显示这些标记。为此,请执行以下操作:

  1. 在 MapViewControllerBridge 中声明一个带有 @Binding 注解的新 markers 属性

MapViewControllerBridge

struct MapViewControllerBridge: : UIViewControllerRepresentable {
  @Binding var markers: [GMSMarker]
  // ...
}
  1. 在 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 }
  }
}
  1. 将 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。

  1. 接下来运行应用,看看地图上显示的标记。

8. 为所选城市添加动画效果

在上一步中,您通过将 State 从一个 SwiftUI 视图传递给另一个 SwiftUI 视图,向地图添加了标记。在这一步中,您将为城市/标记添加动画效果,当用户在可互动列表中点按该城市/标记后即会显示此动画效果。若要执行动画,您需要对 State 的变化做出响应,具体方法是在发生变化时修改地图的相机位置。如需详细了解地图相机的概念,请参阅相机和视图。

animate-city@2x.png

使地图以动画形式呈现所选城市

若要使地图以动画形式呈现所选城市,请执行以下操作:

  1. 在 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?
}
  1. 更新 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) 函数执行一系列地图动画。

  1. 将 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 转换执行动画,让您能够轻而易举地为视图添加动画效果。为了进行演示,您将在地图动画播放完毕后,将视图的焦点移至所选城市,从而添加更多动画。为此,请完成以下步骤:

  1. 为 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()
            })
          })
        }
      }
    }
  }
}
  1. 在 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))
      }
    }
  }
}
  1. 接下来运行应用,看看动画效果!

9. 将事件发送到 SwiftUI

在这一步中,您将监听从 GMSMapView 发出的事件,并将该事件发送到 SwiftUI。具体而言,您将为地图视图设置委托,并监听相机移动事件,这样一来,当某个城市处于聚焦状态且地图相机随手势移动时,地图视图将失去焦点,以便您能查看地图的更多部分。

使用 SwiftUI 协调器

GMSMapView 会发出事件,例如相机位置变化或点按标记。监听这些事件的机制是通过 GMSMapViewDelegate 协议实现的。SwiftUI 引入了协调器概念,协调器专门用于充当 UIKit 视图控制器的代理。因此,在 SwiftUI 环境中,协调器应负责确保符合 GMSMapViewDelegate 协议。为此,请完成以下步骤:

  1. 在 MapViewControllerBridge 中创建名为 MapViewCoordinator 的协调器

在 MapViewControllerBridge 类内创建一个嵌套类,并将其命名为 MapViewCoordinator。此类应符合 GMSMapViewDelegate,并且应将 MapViewControllerBridge 声明为属性。

MapViewControllerBridge

struct MapViewControllerBridge: UIViewControllerRepresentable {
  // ...
  final class MapViewCoordinator: NSObject, GMSMapViewDelegate {
    var mapViewControllerBridge: MapViewControllerBridge

    init(_ mapViewControllerBridge: MapViewControllerBridge) {
      self.mapViewControllerBridge = mapViewControllerBridge
    }
  }
}
  1. 在 MapViewControllerBridge 中实现 makeCoordinator()

接下来,在 MapViewControllerBridge 中实现 makeCoordinator() 方法,并返回您在上一步中创建的 MapViewCoodinator 实例。

MapViewControllerBridge

struct MapViewControllerBridge: UIViewControllerRepresentable {
  // ...
  func makeCoordinator() -> MapViewCoordinator {
    return MapViewCoordinator(self)
  }
}
  1. 将 MapViewCoordinator 设为地图视图的委托

创建自定义协调器后,下一步是将协调器设置为视图控制器的地图视图的委托。为此,请在 makeUIViewController(context) 中更新视图控制器初始化。您可以通过 Context 对象访问上一步中创建的协调器。

MapViewControllerBridge

struct MapViewControllerBridge: UIViewControllerRepresentable {
  // ...
  func makeUIViewController(context: Context) -> MapViewController {
    let uiViewController = MapViewController()
    uiViewController.map.delegate = context.coordinator
    return uiViewController
  }
  1. 为 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)
    }
  }
}
  1. 更新 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
        })
        // ...
      }
    }
  }
}
  1. 接下来运行应用,看看新的变化!

10. 恭喜

恭喜您成功完成此 Codelab!您已经掌握了许多基础知识,希望在学完这些课程后,您现在能够使用 Maps SDK for iOS 构建自己的 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?

地图上的数据可视化 更多关于自定义地图样式的信息 在地图中构建 3D 交互

上面没有列出您想学习的 Codelab?没关系,请在此处通过创建新问题的方式申请 Codelab。