Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

norainsX/MapViewGuider

Open more actions menu

Repository files navigation

在SwiftUI中使用MKMapView

SwiftUI是个好东西,让我们方便进行UI的布局;可如果做得应用和地图有关,那么就没有那么舒心了,因为SwiftUI里面没有封装地图的控件!如果需要使用地图的话,那么我们必须自己动手,将MKMapView给包装进来!好吧,那么我们现在就来看下,如果进行封装吧!

基础用法

封装MKMapKit

对于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()
    }
}

运行程序,我们就能看到激动人心的画面了:

截屏2020-02-26下午3.15.07

设置Frame

如果仔细查看代码,会发现我们之前将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即可。

大头针

大头针在地图的应用,主要是让用户知道这里有一些客制化的信息,点击的时候可以进行获取,比如当前商家的信息啊、当前位置的图片等等。

添加大头针

添加大头针的方法比较简单,大体来说,有如下几个步骤:

  1. 创建一个实现了MKAnnotation协议的类,这里假设这个类的名称叫PinAnnotation
  2. 创建一个PinAnnotation的实例
  3. 通过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)
        ...
    }

运行起来之后,效果如下所示:

使用和系统自带地图一致的大头针图像

如果大家仔细观察的话,会发现前一节我们所使用的大头针的图案,和系统自带的地图所用的大头针不太一样。那么,如果需要使用和系统自带地图一致的大头针图像,该怎么弄呢?其步骤如下:

  1. MapViewDelegate类中实现一个mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?函数
  2. 在该函数被调用的时候,创建一个标识符为"MKPinAnnotationView"的AnnotationView实例
  3. 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
    }
}

运行之后,效果如下所示:

image-20200227085117720

弹出附属框

接下来我们做个有意思的事情,就是点击地图上的大头症,让它能够弹出显示附属框,该附属框有文字。要实现这玩意,需要在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是用来显示的附属框内容

代码运行之后,效果如下所示:

image-20200227093401592

如果需要显示图片的话,也很简单,就是代码中声明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
    }
}

运行起来之后,界面如下所示:

image-20200227101306617

接下来我们考量的难点就是,如何进行界面的切换呢?我们首先来了解SwiftUI的导航基本架构:

NavigationView {
	NavigationLink(destination: XXX, isActive: $YYY) {
     ...
  }
}

上述的只是一些伪代码,但我们需要知道如下知识点:

  • NavigationView是导航视图,整个APP可以只有一处地方使用,只要其它的View以及子View都在其作用范围
  • NavigationLink主要用于切换页面的,必须在NavigationView作用范围之内才有效
  • destination是要切换页面的实例
  • isActive是用来控制切换的,当其为true的时候会进行切换

基于如上的知识点,我们来做如下几个代码修改:

  1. MapViewState增加一个navigateView变量,用来保存要导航的界面实例
  2. 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方式比较简单,步骤大概有如下几步:

  1. 创建一个派生于MKPolylineRenderer的子类,并且在该子类的draw函数中设置绘制的轨迹的颜色和大小
  2. 在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并返回,就是主要做的事情。

运行代码,就可以看到效果了:

image-20200227143737923

如果需要本阶段的代码,请按如下进行操作:

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是用于同步屏幕刷新频率的计时器。

在我们接下来的示例里面,主要用到它的这几个方面:

  1. 调用CADisplayLink的构造函数并关联定时调用的函数
  2. 实现定时调用的函数

第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的方式显示一致,如:

image-20200227171432532

如果需要本阶段的代码,请按如下进行操作:

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()
    }
}

效果如下所示:

image-20200227193256167

动态改变轨迹宽度

我们再来考虑一个问题,假设我们需要轨迹刚好覆盖长安大街的话,当地图放大缩小的时候,我们该如何动态设置轨迹的宽度呢?从前面的内容我们知道,可以通过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
        }
    }
}

运行之后,放大地图,可以看见轨迹已经随着地图的放大而放大了:

image-20200227201301985

如果需要本阶段的代码,请按如下进行操作:

git clone https://github.com/no-rains/MapViewGuider.git

git checkout foglayer

About

如何在SwiftUI中使用MKMapView

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

Morty Proxy This is a proxified and sanitized view of the page, visit original site.