SwiftUI是个好东西,让我们方便进行UI的布局;可如果做得应用和地图有关,那么就没有那么舒心了,因为SwiftUI里面没有封装地图的控件!如果需要使用地图的话,那么我们必须自己动手,将MKMapView给包装进来!好吧,那么我们现在就来看下,如果进行封装吧!
对于UIKit里面的组件,如果我们需要在SwiftUI中使用,只需要在封装的时候满足UIViewRepresentable协议,也就是实现makeUIView(context:)和updateUIView(_: , context:) 函数即可。是不是很简单?基于此,我们可以很简单地将MKMapView给包装起来:
import MapKit
import SwiftUI
struct MapViewWrapper: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
}使用的时候,和正常的SwiftUI组件没有任何差别,如:
import SwiftUI
struct ContentView: View {
var body: some View {
MapViewWrapper()
}
}运行程序,我们就能看到激动人心的画面了:
如果仔细查看代码,会发现我们之前将frame设置为0,如:
let mapView = MKMapView(frame: .zero)虽然在实际使用中,运行起来的时候会自动适配,但给人的感觉总是不够完美,就像有鲠在喉一样,难受。那么我们改如何获取MKMapView的应有大小呢?这个时候就要使用GeometryReader了。
我们新建一个MapView的结构体,然后在此结构体中使用GeometryReader将MapViewWrapper给包装起来,并且初始化的时候将父窗口的大小传递给MapViewWrapper,如:
struct MapView: View {
var body: some View {
return GeometryReader { geometryProxy in
MapViewWrapper(frame: CGRect(x: geometryProxy.safeAreaInsets.leading,
y: geometryProxy.safeAreaInsets.trailing,
width: geometryProxy.size.width,
height: geometryProxy.size.height))
}
}
}
struct MapViewWrapper: UIViewRepresentable {
var frame: CGRect
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: frame)
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
}使用的地方,自然是要将MapViewWrapper给修改为MapView了:
struct ContentView: View {
var body: some View {
MapView()
}
}这样看来,凡是封装的UIKit组件,都先封装,然后再放到一个struct View中,似乎能省不少麻烦。但是,问题来了,在实际上,如果采用这种封装的方式,那么在使用@ObservableObject这种属性的时候,很有可能在数据变化的时候,无法收到变化的通知。所以这里只是说明怎么能够获取frame而已,但在下面的例子中,我们还是要将这个将MapViewWrapper放到MapView中去的这种方式取消的。
如果我们使用双手来缩放地图的话,那么我们如何获知此时缩放的倍数呢?这个时候就需要使用上代理了。所谓的代码,就是实现了MKMapViewDelegate协议的类。我们为了保存数据,这里还新建了一个MapViewState的类。之所以这样考量,是鉴于设计模式的原则,将数据和状态分开。
首先是MapViewState类,比较简单,只有一个属性:
import MapKit
class MapViewState: ObservableObject {
var span: MKCoordinateSpan?
}接着是MapViewDelegate类,它需要实现MKMapViewDelegate协议,并且为了检测到缩放的事件,还必须实现mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool)函数:
import MapKit
class MapViewDelegate: NSObject, MKMapViewDelegate {
var mapViewState : MapViewState
init(mapViewState : MapViewState){
self.mapViewState = mapViewState
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool) {
mapViewState.span = mapView.region.span
print(mapViewState.span)
}
}其次,就是在MapView中将MapViewDelegate的实例赋给它:
struct MapView: View {
var mapViewState: MapViewState
var mapViewDelegate: MapViewDelegate
var body: some View {
return GeometryReader { geometryProxy in
MapViewWrapper(frame: CGRect(x: geometryProxy.safeAreaInsets.leading,
y: geometryProxy.safeAreaInsets.trailing,
width: geometryProxy.size.width,
height: geometryProxy.size.height),
mapViewState: self.mapViewState,
mapViewDelegate: self.mapViewDelegate)
}
}
}
struct MapViewWrapper: UIViewRepresentable {
var frame: CGRect
var mapViewState: MapViewState
var mapViewDelegate: MapViewDelegate
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: frame)
mapView.delegate = mapViewDelegate
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
}最后,调用的方式也要稍微改一下:
struct ContentView: View {
var mapViewState: MapViewState?
var mapViewDelegate: MapViewDelegate?
init() {
mapViewState = MapViewState()
mapViewDelegate = MapViewDelegate(mapViewState: mapViewState!)
}
var body: some View {
ZStack {
MapView(mapViewState: mapViewState!, mapViewDelegate: mapViewDelegate!)
}
}
}运行程序,这时候应该就能通过调试窗口输出缩放的span了。
最后的最后,总结一下要点:
- MapView的反馈全部是通过MKMapViewDelegate来回调通知的
- MKMapViewDelegate的实例是通过给mapView.delegate赋值实现的
- 缩放的时候,会调用mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool)函数
我们这里考虑一个常见的应用场景,就是很多地图软件都会有一个功能,点击“当前位置”按钮的时候,地图就会嗖地显示我们当前的位置。这个场景,要实现也非常容易。
首先,我们需要在MapViewState增加一个center属性来存储位置变量:
import MapKit
class MapViewState: ObservableObject {
var span: MKCoordinateSpan?
@Published var center: CLLocationCoordinate2D?
}这里之所以将MapViewState声明为ObservableObject,以及为何要将center用@Published包装起来,主要是我们的这些数据在变化的时候需要能够通知到SwiftUI的组件。
我们来看MapView的代码需要有什么变化:
import MapKit
import SwiftUI
struct MapView: View {
@ObservedObject var mapViewState: MapViewState
var mapViewDelegate: MapViewDelegate
var body: some View {
return GeometryReader { geometryProxy in
MapViewWrapper(frame: CGRect(x: geometryProxy.safeAreaInsets.leading,
y: geometryProxy.safeAreaInsets.trailing,
width: geometryProxy.size.width,
height: geometryProxy.size.height),
mapViewState: self.mapViewState,
mapViewDelegate: self.mapViewDelegate)
}
}
}
struct MapViewWrapper: UIViewRepresentable {
var frame: CGRect
@ObservedObject var mapViewState: MapViewState
var mapViewDelegate: MapViewDelegate
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: frame)
mapView.delegate = mapViewDelegate
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
// Set the map display region
if let center = mapViewState.center {
var region: MKCoordinateRegion
if let span = mapViewState.span {
region = MKCoordinateRegion(center: center,
span: span)
} else {
region = MKCoordinateRegion(center: center,
latitudinalMeters: CLLocationDistance(400),
longitudinalMeters: CLLocationDistance(400))
}
view.setRegion(region, animated: true)
mapViewState.center = nil
}
}
}上述代码有如下需要注意的地方:
- 设置显示中心点,是由调用setRegion来实现的
- setRegion的latitudinalMeters和longitudinalMeters是用来控制缩放的比例的
- SetRegion的span也是控制缩放比例的,只是单位和前者不同
- 设置之后,将mapViewState.center设置为nil,主要是为了防止刷新的时候不停地设置中心点
接下来,我们就需要在ContentView添加一个按钮,按下按钮的时候设置中心点位置。代码不复杂,只是稍微添加的地方有点多:
import MapKit
import SwiftUI
struct ContentView: View {
@ObservedObject var mapViewState = MapViewState()
var mapViewDelegate: MapViewDelegate?
init() {
mapViewDelegate = MapViewDelegate(mapViewState: self.mapViewState)
}
var body: some View {
ZStack {
MapView(mapViewState: mapViewState, mapViewDelegate: mapViewDelegate!)
VStack {
Spacer()
Button(action: {
self.mapViewState.center = CLLocationCoordinate2D(latitude: 39.9, longitude: 116.38)
}
) {
Text("MyLocation")
.background(Color.gray)
.padding()
}
}
}
}
}如果这时候你满怀信息运行此代码的话,会很沮丧地发现一个问题,就是点击按钮,无论如何都无法实现回到当前位置的效果。为什么呢?这个在之前的“设置Frame”中有提到,多层封装之后,有可能导致@ObservedObject对象无法收到变化。所以,我们这里还是要将这个二级封装简化为一层。
如果需要这部分失败的代码,请使用git进行如下操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout base.use-bad.wrapper
因为frame我们暂时用不上,所以这里还是直接使用.zero,然后我们将MapViewWrapper更名为MapView,而原来的MapView删掉,于是便得到如下代码:
//
// MapView.swift
// MapViewGuider
//
// Created by norains on 2020/2/26.
// Copyright © 2020 norains. All rights reserved.
//
import MapKit
import SwiftUI
struct MapView: UIViewRepresentable {
@ObservedObject var mapViewState: MapViewState
var mapViewDelegate: MapViewDelegate
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
mapView.delegate = mapViewDelegate
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
// Set the map display region
if let center = mapViewState.center {
var region: MKCoordinateRegion
if let span = mapViewState.span {
region = MKCoordinateRegion(center: center,
span: span)
} else {
region = MKCoordinateRegion(center: center,
latitudinalMeters: CLLocationDistance(400),
longitudinalMeters: CLLocationDistance(400))
}
view.setRegion(region, animated: true)
mapViewState.center = nil
}
}
}这时候运行代码,然后点击按钮,就会自动移动到当前所设定的坐标去了!
本章的内容就此结束,如果需要本章结束时的代码,请按如下进行操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout base.use
这里还有个小手尾,如果在移动到当前位置的时候,还需要显示地图自带的那个闪烁的小圆圈,只需要将mapView的showsUserLocation设置为true即可。
大头针在地图的应用,主要是让用户知道这里有一些客制化的信息,点击的时候可以进行获取,比如当前商家的信息啊、当前位置的图片等等。
添加大头针的方法比较简单,大体来说,有如下几个步骤:
- 创建一个实现了MKAnnotation协议的类,这里假设这个类的名称叫PinAnnotation
- 创建一个PinAnnotation的实例
- 通过MKMapView的addAnnotation函数将PinAnnotation的实例添加到地图上即可
我们来逐步看一下,首先是实现MKAnnotation协议的类。在这个类中,我们主要是实现coordinate这个属性。这个coordinate属性是干啥用的呢?其实就是指明了大头针所放置的位置。鉴于此,我们不难得到一个非常简单的PinAnnotation:
import MapKit
class PinAnnotation: NSObject, MKAnnotation {
var coordinate: CLLocationCoordinate2D
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
}回到我们的工程,这个PinAnnotation的实例放在哪里比较好呢?自然还是MapViewState里面了:
class MapViewState: ObservableObject {
...
var pinAnnotation = PinAnnotation(coordinate: CLLocationCoordinate2D(latitude: 39.9, longitude: 116.38))
}然后,我们需要做得,就是在makeUIView函数中将这大头针给添加进去:
func makeUIView(context: Context) -> MKMapView {
...
mapView.addAnnotation(mapViewState.pinAnnotation)
...
}运行起来之后,效果如下所示:
如果大家仔细观察的话,会发现前一节我们所使用的大头针的图案,和系统自带的地图所用的大头针不太一样。那么,如果需要使用和系统自带地图一致的大头针图像,该怎么弄呢?其步骤如下:
- MapViewDelegate类中实现一个mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?函数
- 在该函数被调用的时候,创建一个标识符为"MKPinAnnotationView"的AnnotationView实例
- AnnotationView实例中,将我们创建的PinAnnotation赋值给它
简单点来说,我们可以添加如下代码:
class MapViewDelegate: NSObject, MKMapViewDelegate {
...
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// If the return value of MKAnnotationView is nil, it would be the default
var annotationView: MKAnnotationView?
let identifier = "MKPinAnnotationView"
annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
if annotationView == nil {
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
}
annotationView?.annotation = annotation
return annotationView
}
}运行之后,效果如下所示:
接下来我们做个有意思的事情,就是点击地图上的大头症,让它能够弹出显示附属框,该附属框有文字。要实现这玩意,需要在mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?函数中做一点事情,而这些事情,我们就干脆封装到PinAnnotation去好了。
class PinAnnotation: NSObject, MKAnnotation {
...
func makeTextAccessoryView(annotationView: MKPinAnnotationView) {
var accessoryView: UIView
//创建文本的附属视图
let textView = UITextView(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
textView.text = "Hello, PinAnnotation!"
textView.isEditable = false
accessoryView = textView
//设置文本对齐的约束条件
let widthConstraint = NSLayoutConstraint(item: accessoryView, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
accessoryView.addConstraint(widthConstraint)
let heightConstraint = NSLayoutConstraint(item: accessoryView, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
accessoryView.addConstraint(heightConstraint)
//将创建好的附属视图赋值
annotationView.detailCalloutAccessoryView = accessoryView
//让附属视图可以显示
annotationView.canShowCallout = true
}
}代码比较简单,看注释就明白大概的意思。总的来说,就是这么两条:
- MKPinAnnotationView.canShowCallout变量用于控制点击的时候,是否显示附属框
- MKPinAnnotationView.detailCalloutAccessoryView是用来显示的附属框内容
代码运行之后,效果如下所示:
如果需要显示图片的话,也很简单,就是代码中声明UITextView的地方,更换为UIImage,然后赋值给MKPinAnnotationView.detailCalloutAccessoryView即可。原理是一样的,也没有什么可说的,这里就不再详述了。
点击大头针,然后在附属框中点击感叹号,导航到另外一个页面。这个场景,应该是比较常用的。只不过,因为我们现在用的是SwiftUI,而MKMapView又属于UIKit,这两者在页面切换这块,其实是有点难以协同的。万事开头难,我们先一步一步来吧。
首先,我们要做的是,在大头针的附属页面显示一个感叹号,点击它的时候,会执行一个函数。这部分代码比较简单,也就三句话,如下所示:
class PinAnnotation: NSObject, MKAnnotation {
...
//点击感叹号的回调函数
@objc func onClickDetailButton(_ sender: Any, forEvent event: UIEvent) {
print("onClickDetailButton")
}
func makeTextAccessoryView(annotationView: MKPinAnnotationView) {
...
// 感叹号按钮
let detailButton = UIButton(type: .detailDisclosure)
// 点击感叹号,会调用传入的onClickDetailButton函数
detailButton.addTarget(self, action: #selector(PinAnnotation.onClickDetailButton(_:forEvent:)), for: UIControl.Event.touchUpInside)
// 将感叹号按钮赋值到视图上
annotationView.rightCalloutAccessoryView = detailButton
}
}运行起来之后,界面如下所示:
接下来我们考量的难点就是,如何进行界面的切换呢?我们首先来了解SwiftUI的导航基本架构:
NavigationView {
NavigationLink(destination: XXX, isActive: $YYY) {
...
}
}上述的只是一些伪代码,但我们需要知道如下知识点:
- NavigationView是导航视图,整个APP可以只有一处地方使用,只要其它的View以及子View都在其作用范围
- NavigationLink主要用于切换页面的,必须在NavigationView作用范围之内才有效
- destination是要切换页面的实例
- isActive是用来控制切换的,当其为true的时候会进行切换
基于如上的知识点,我们来做如下几个代码修改:
- MapViewState增加一个navigateView变量,用来保存要导航的界面实例
- MapViewState增加一个activeNavigate变量,用来控制页面切换
所以,我们MapViewState的代码如下:
class MapViewState: ObservableObject {
...
var navigateView: SecondContentView?
@Published var activeNavigate = false
...
}相应的,点击感叹号的时候,我们就必须要给这两个变量赋值了:
class PinAnnotation: NSObject, MKAnnotation {
...
@objc func onClickDetailButton(_ sender: Any, forEvent event: UIEvent) {
mapViewState.navigateView = SecondContentView()
mapViewState.activeNavigate = true
}
}最后一步,就是将这两个变量插入到UI组件中:
struct ContentView: View {
@ObservedObject var mapViewState = MapViewState()
...
var body: some View {
NavigationView {
ZStack {
MapView(mapViewState: mapViewState, mapViewDelegate: mapViewDelegate!)
.edgesIgnoringSafeArea(.all)
...
if mapViewState.navigateView != nil {
NavigationLink(destination: mapViewState.navigateView!, isActive: $mapViewState.activeNavigate) {
EmptyView()
}
}
}
}
}
}
}添加完毕之后,我们现在点击感叹号,就可以导航到另外的一个页面去了!
如果需要本阶段的代码,请按如下进行操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout annotation
如果大家使用过迷雾世界之类的软件,那么可以知道里面有一个非常有意思的场景,就是随着行走的轨迹,慢慢讲地图给清晰化。本章我们就来讨论这个事情,不过这里不会涉及到如何获取GPS数据以及保存,只是将笔墨着重于如何进行绘制而已。
对于轨迹的绘制,有两种比较常见的方法,一种是通过代理使用MKPolylineRenderer绘制,另外一种是直接在MapView上面再加一层CALayer来进行。相对于来说,前一种比较简单,容易理解,后一种就比较复杂和麻烦了。不过在本章中,这两者都会有介绍,但后续的内容,却是基于后一种CALayer的方式。
但无论是哪种方式,最先要做的,都是在地图上添加轨迹。添加轨迹的方式很简单,就是根据坐标生成MKPolyline这个特殊的overlay,然后添加到MapView的视图中:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
...
//添加轨迹
let polyline = MKPolyline(coordinates: mapViewState.tracks, count: mapViewState.tracks.count)
mapView.addOverlay(polyline)
...
}
}MapViewState中定义的tracks只是一个数据,如:
class MapViewState: ObservableObject {
...
var tracks = [CLLocationCoordinate2D(latitude: 39.9, longitude: 116.38),
CLLocationCoordinate2D(latitude: 39.9, longitude: 116.39)]
}轨迹添加完毕,那么接下来我们就来看如何将其绘制出来了。
MKPolylineRenderer方式比较简单,步骤大概有如下几步:
- 创建一个派生于MKPolylineRenderer的子类,并且在该子类的draw函数中设置绘制的轨迹的颜色和大小
- 在MKMapViewDelegate的回调函数中将此子类的对象反馈给视图
我们先来看一下创建MKPolylineRenderer的子类:
import Foundation
import MapKit
class PolylineRenderer: MKPolylineRenderer {
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
// 线条的颜色
strokeColor = UIColor.red
// 线条的大小
lineWidth = 5
super.draw(mapRect, zoomScale: zoomScale, in: context)
}
}PolylineRenderer代码没啥好说的,就干了两件事,在回调函数中设置了线条的颜色和线条的大小。接下来,我们再来看看如何在MKMapViewDelegate的回调函数中将此子类的对象反馈给视图:
class MapViewDelegate: NSObject, MKMapViewDelegate {
...
// 创建renderer的时候会回调此函数
func mapView(_ mapView: MKMapView, rendererFor: MKOverlay) -> MKOverlayRenderer {
let renderer = PolylineRenderer(overlay: rendererFor)
return renderer
}
}在回调函数中创建PolylineRenderer并返回,就是主要做的事情。
运行代码,就可以看到效果了:
如果需要本阶段的代码,请按如下进行操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout polyline.renderer
不过MKPolylineRenderer方式虽然比较简单,但客制化一些功能的时候比较麻烦。如果仅仅只是显示轨迹的话,可能用MKPolylineRenderer就够了,但如果还需要做更多的工作,可能我们就需要接下来的CALayer的方式了。
####CALayer方式
对于CALayer模式来说,稍微显得有点复杂,我们先一步一步理清一下。首先,由于CALayer需要用到CADisplayLink,我们来看看它是做什么的。
CADisplayLink的官方定义如下:
A timer object that allows your application to synchronize its drawing to the refresh rate of the display.
翻译过来的意思就是,CADisplayLink是一个定时器对象,它可以让你与屏幕刷新频率相同的速率来刷新你的视图。简单点理解,可以认为CADisplayLink是用于同步屏幕刷新频率的计时器。
在我们接下来的示例里面,主要用到它的这几个方面:
- 调用CADisplayLink的构造函数并关联定时调用的函数
- 实现定时调用的函数
第1条很简单,如:
let link = CADisplayLink(target: self, selector: #selector(self.updateDisplayLink))selector中传入的updateDisplayLink的原型如下:
@objc func updateDisplayLink() {
...
}接下来我们需要思考一个问题,我们需要在updateDisplayLink函数里面实现什么功能呢?因为地图是不停地移动的,附着在上面的轨迹自然也不是固定位置的,所以我们需要在updateDisplayLink函数中获取轨迹在CALayer上的位置,然后保存为UIBezierPath曲线,然后待CALayer回调Draw函数的时候将其绘制出来。
我们先来考虑一下如何获取轨迹线。首先,我们知道可以通过MKMapView.overlays获取到它的overlay,然后再通过as操作符判断是不是我们添加了轨迹的MKPolyline:
for overlay in mapView!.overlays {
if let overlay = overlay as? MKPolyline {
...
}
}接下来再通过UnsafeBufferPointer函数来获取地图上的坐标点,然后再通过MKMapView.convert函数将GPS坐标点转化为CALayer相对于MKMapView上的UI坐标:
var points = [CGPoint]()
for mapPoint in UnsafeBufferPointer(start: overlay.points(), count: overlay.pointCount){
let coordinate = mapPoint.coordinate
let point = mapView!.convert(coordinate, toPointTo: mapView!)
points.append(point)
}最后呢,就可以根据这些坐标点绘制贝塞尔曲线了:
let path = UIBezierPath()
if let first = points.first {
path.move(to: first)
}
for point in points {
path.addLine(to: point)
}
for point in points.reversed() {
path.addLine(to: point)
}
path.close()至此,CADisplayLink的使命就完成了。只不过,到这一步,只是将曲线的形状给勾勒出来了,我们还需要将这个形状给显示出来,这里就轮到CALayer上场了。
CALayer的任务就简单多了,它只要实现一个draw函数,然后将存储好的贝塞尔曲线绘制出来即可:
override func draw(in ctx: CGContext) {
UIGraphicsPushContext(ctx)
ctx.setStrokeColor(UIColor.red.cgColor)
path?.lineWidth = 5
path?.stroke()
path?.fill()
UIGraphicsPopContext()
}我们将上述的代码汇集到一个类中,于是就有了我们一个名为FogLayer的类:
import MapKit
import UIKit
class FogLayer: CALayer {
var mapView: MKMapView?
var path: UIBezierPath?
lazy var displayLink: CADisplayLink = {
let link = CADisplayLink(target: self, selector: #selector(self.updateDisplayLink))
return link
}()
override func draw(in ctx: CGContext) {
UIGraphicsPushContext(ctx)
ctx.setStrokeColor(UIColor.red.cgColor)
path?.lineWidth = 5
path?.stroke()
path?.fill()
UIGraphicsPopContext()
}
@objc func updateDisplayLink() {
if mapView == nil {
// Do nothing
return
}
let path = UIBezierPath()
for overlay in mapView!.overlays {
if let overlay = overlay as? MKPolyline {
if let linePath = self.linePath(with: overlay) {
path.append(linePath)
}
}
}
path.lineJoinStyle = .round
path.lineCapStyle = .round
self.path = path
setNeedsDisplay()
}
private func linePath(with overlay: MKPolyline) -> UIBezierPath? {
if mapView == nil {
return nil
}
let path = UIBezierPath()
var points = [CGPoint]()
for mapPoint in UnsafeBufferPointer(start: overlay.points(), count: overlay.pointCount) {
let coordinate = mapPoint.coordinate
let point = mapView!.convert(coordinate, toPointTo: mapView!)
points.append(point)
}
if let first = points.first {
path.move(to: first)
}
for point in points {
path.addLine(to: point)
}
for point in points.reversed() {
path.addLine(to: point)
}
path.close()
return path
}
}那么这个FogLayer的对象是在哪里保存呢?自然还是在MapViewState中:
class MapViewState: ObservableObject {
...
var fogLayer = FogLayer()
}而降FogLayer和MKMapView关联起来,则还是在makeUIView里:
struct MapView: UIViewRepresentable {
...
func makeUIView(context: Context) -> MKMapView {
...
// 添加SubLayer
mapView.layer.addSublayer(mapViewState.fogLayer)
mapViewState.fogLayer.mapView = mapView
mapViewState.fogLayer.frame = UIScreen.main.bounds
mapViewState.fogLayer.displayLink.add(to: RunLoop.main, forMode: RunLoop.Mode.common)
mapViewState.fogLayer.setNeedsDisplay()
return mapView
}
}这里需要注释的是,CADisplay一定要加到RunLoop队列中,否则它是不会起到定时器的作用的。
最后,运行代码,和使用MKPolylineRenderer的方式显示一致,如:
如果需要本阶段的代码,请按如下进行操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout be0c0f28101df0c548a40f8bd53a5d8265657d36
对于用过类似世界迷雾的朋友来说,可能对里面的地图被迷雾覆盖,只有经过的轨迹才是清晰的这个功能比较好奇,究竟它是怎么实现的呢?原理非常简单,其实就显示在CALayer上画一层灰色,然后设置线条为透明,然后绘制即可。所以,我们这里可以修改一下CALayer的draw函数:
class FogLayer: CALayer {
...
override func draw(in ctx: CGContext) {
UIGraphicsPushContext(ctx)
UIColor.darkGray.withAlphaComponent(0.75).setFill()
UIColor.clear.setStroke()
ctx.fill(UIScreen.main.bounds)
ctx.setBlendMode(.clear)
path?.lineWidth = 5
path?.stroke()
path?.fill()
UIGraphicsPopContext()
}
}效果如下所示:
我们再来考虑一个问题,假设我们需要轨迹刚好覆盖长安大街的话,当地图放大缩小的时候,我们该如何动态设置轨迹的宽度呢?从前面的内容我们知道,可以通过MKMapView.convert函数来将地图上的GPS坐标点转换为View上的UI坐标,然后我们又知道长安大街的宽度大概在50米左右,那么我们是否可以选定两个GPS坐标点的距离刚好为50米左右,然后每次绘制的时候将这两个坐标点转换为UI坐标点,然后计算这两个UI坐标点的距离当成轨迹的宽度呢?实际上,这个是可行的。
基于此,我们来选择如下两个测试坐标:
let mapPoint1 = CLLocationCoordinate2D(latitude: 22.629052, longitude: 114.136977)
let mapPoint2 = CLLocationCoordinate2D(latitude: 22.629519, longitude: 114.137098)我们如何可以确认这两个GPS坐标的距离差不多是50左右呢?其实可通过调用如下的这个函数确定:
func coordinateDistance(_ first: CLLocationCoordinate2D, _ second: CLLocationCoordinate2D) -> Int {
func radian(_ value: Double) -> Double {
return value * Double.pi / 180.0
}
let EARTH_RADIUS: Double = 6378137.0
let radLat1: Double = radian(first.latitude)
let radLat2: Double = radian(second.latitude)
let radLng1: Double = radian(first.longitude)
let radLng2: Double = radian(second.longitude)
let a: Double = radLat1 - radLat2
let b: Double = radLng1 - radLng2
var distance: Double = 2 * asin(sqrt(pow(sin(a / 2), 2) + cos(radLat1) * cos(radLat2) * pow(sin(b / 2), 2)))
distance = distance * EARTH_RADIUS
return Int(distance)
}因为这个函数设计到经纬度的一些知识和算法,所以这里就不展开了,只需要知道传入两个GPS坐标,就可以计算出这两者之间的距离即可。
回到正题,我们将两个GPS坐标转为UI的坐标:
let viewPoint1 = mapView.convert(mapPoint1, toPointTo: mapView)
let viewPoint2 = mapView.convert(mapPoint2, toPointTo: mapView)接着,我们用一个初中生都明白的计算两点之间的公式来算出两者的距离。由于距离有可能小于1,所以我们租后还要判断一下返回值是否小于1,如果是,就让它依然等于1,这样地图绘制的时候,就不至于什么都看不见了:
let distance = sqrt(pow(viewPoint1.x - viewPoint2.x, 2) + pow(viewPoint1.y - viewPoint2.y, 2))
if distance < 1 {
return 1.0
} else {
return CGFloat(distance)
}最后,我们只需要给轨迹赋值即可。将上述肢解的代码综合一下,如下所示:
class FogLayer: CALayer {
...
override func draw(in ctx: CGContext) {
...
if let lineWidth = lineWidth {
path?.lineWidth = lineWidth
} else {
path?.lineWidth = 5
}
...
}
var lineWidth: CGFloat? {
if let mapView = self.mapView {
// The distance between mapPoint1 and mapPoint2 in the map is about 53m
let mapPoint1 = CLLocationCoordinate2D(latitude: 22.629052, longitude: 114.136977)
let mapPoint2 = CLLocationCoordinate2D(latitude: 22.629519, longitude: 114.137098)
let viewPoint1 = mapView.convert(mapPoint1, toPointTo: mapView)
let viewPoint2 = mapView.convert(mapPoint2, toPointTo: mapView)
let distance = sqrt(pow(viewPoint1.x - viewPoint2.x, 2) + pow(viewPoint1.y - viewPoint2.y, 2))
if distance < 1 {
return 1.0
} else {
return CGFloat(distance)
}
} else {
return nil
}
}
}运行之后,放大地图,可以看见轨迹已经随着地图的放大而放大了:
如果需要本阶段的代码,请按如下进行操作:
git clone https://github.com/no-rains/MapViewGuider.git
git checkout foglayer








