在封裝了地圖源之后,我們開始實現(xiàn)最常用的功能,自定義 UI 展示。這里我以繪制一個標注舉例。
自定義 UI 可以用 CoreGraphic 繪制,也可以用傳統(tǒng)的 UIKit 那套。我這里自定義標注的樣式并不復雜,我使用傳統(tǒng)的 UIView 展示。當然有一些圖形的 UI 我也有用到 CoreGraphic。
因為我的自定義標注出了圖片外還有一些信息要展示(標注標題),因此我用一個數(shù)據(jù)結構來表示標注的信息。
public enum MeshAnnotationType {
case homePoint
}
public class MeshMapAnnotation {
public var type: MeshAnnotationType
init(type: MeshAnnotationType) {
self.type = type
}
}
因為是很簡單的標注樣式,所以 View 的實現(xiàn)也很簡單:
class MeshAnnotaionView: UIView {
private(set) var annotion: MeshMapAnnotation
private(set) var imageView = UIImageView(image: nil)
init(annotion: MeshMapAnnotation) {
self.annotion = annotion
super.init(frame: CGRect.zero)
addSubview(imageView)
setupUI()
}
private func setupUI() {
switch type {
case .homePoint:
imageView.image = Asset.Map.iconMapHomepoint.image
imageView.frame = CGRect(x: 0, y: 0, width: 32, height: 32)
bounds = imageView.frame
default:
break
}
}
}
這里的樣式就是簡單的一個 icon。標注的樣式這里就不展開講了(不是重點),總之就是自己實現(xiàn)了一個 View。
下一步我們定義一個 CustomMapOverlayView 專門用來管理繪制自定義的需要展示的 UI。
class CustomMapOverlayView: UIView {
private var homePointAnnotationView: MeshAnnotaionView?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
isUserInteractionEnabled = false
}
func updateHomePoint(_ point: CGPoint?) {
if let point = point {
if homePointAnnotationView == nil {
let annotation = MeshMapAnnotation(type: .homePoint)
homePointAnnotationView = MeshAnnotaionView(annotion: annotation)
addSubview(homePointAnnotationView!)
}
homePointAnnotationView?.center = point
} else {
homePointAnnotationView?.removeFromSuperview()
}
}
}
CustomMapOverlayView 的一個細節(jié)要把 isUserInteractionEnabled 設為 false,因為這層覆蓋在地圖源上層,如果也響應交互事件,那么用戶就無法拖動、縮放地圖了。
至于自定義標注在這個 View 里怎么管理,就看各自的業(yè)務場景。因為我這里的地圖標注就幾個類型,直接定義成了可選的屬性。如果要給上層更大的靈活性,也可以用字典存儲。
在目前這個結構里,我們的自定義標注是可以脫離地圖單獨測試的。我們可以在測試項目中,直接初始化 CustomMapOverlayView,調用 updateHomePoint 就可以渲染出 homePointView。自定義 UI 的元素就可以良好的支持單元測試。這也是在設計的時候要考慮到一點,每一個單元盡量內聚。和外部通過數(shù)據(jù)連接,自身的邏輯可以獨立的運行。這樣最后整體的結構就會是各個小單元連接起來,而不是一堆單元直接焊死在一起。
接下來我們把 CustomMapOverlayView 集成到地圖控件中:
public class MeshMapView: UIView {
let customOverlayView: CustomMapOverlayView
public init() {
customOverlayView = CustomMapOverlayView(frame: CGRect.zero)
super.init(frame: CGRect.zero)
addVendorMapView()
addSubview(customOverlayView)
customOverlayView.snp.makeConstraints { (make) in
make.edges.equalToSuperview()
}
}
}
這里需要稍微注意一下圖層的順序,因為自定義 UI 層要在地圖源上方,因此需要先添加地圖,再添加自定義 View。
集成之后就可以暴露接口給外部調用:
public class MeshMapView: UIView {
public var homePoint: CLLocationCoordinate2D? {
didSet {
updateHomePoint(homePoint)
}
}
private func updateHomePoint(_ coordinate: CLLocationCoordinate2D?) {
let correspondingPoint = convertCoordinateToCustomOverlayView(coordinate: coordinate)
customOverlayView.updateHomePoint(correspondingPoint)
}
private func convertCoordinateToCustomOverlayView(coordinate: CLLocationCoordinate2D?) -> CGPoint? {
guard let coordinate = coordinate else { return nil }
let standardCoordindate = MeshMapView.convertCoordinateToGCJIfNeeded(coordinate: coordinate)
guard let point = map?.convert(coordinate: standardCoordindate, toPointTo: customOverlayView) else { return nil }
if point.x.isNaN || point.y.isNaN { //如果轉換時地圖還沒有加載完會返回無效的點
return nil
}
return point
}
}
國內地圖使用的坐標都是 GCJ,但是國際上很多地方存的都是 GPS 坐標,因此這里在轉換坐標的時候調用了一個接口將 WGS84 轉成 GCJ,當然這里的誤差肯定是有的。
上面的代碼重點是,我們需要保存標注的地理坐標,添加到 customOverlayView 之前需要將地理坐標轉換為平面坐標。轉換完成之后就可以調用 customOverlayView.updateHomePoint 了。
還有一個細節(jié)是地理坐標到平面坐標的轉換,有時地圖沒加載完會轉換失敗。因為 CGPoint 是值類型,有的地圖 SDK 轉換失敗會轉出值為 NaN。轉換后還需要判斷一下 x 和 y 的值是否是有效的。
完成上面的代碼后,調用 updateHomePoint 后可以展示標注的位置了。但是目前這個實現(xiàn)還有一個問題,當用戶移動地圖的時候,標注在視圖的位置沒有跟著一下變動。正確的反應應該是地圖位置變了,標注的位置也跟著一起變化。很自然的,我們需要監(jiān)聽地圖區(qū)域變化通知,接著更新標注的位置。
首先我們聲明一個類當做地圖源的代理對象:
protocol VendorMapDelegate: class {
func mapViewDidChange()
func mapInitComplete()
}
class VendorMapDelegateProxy: NSObject, MAMapViewDelegate {
weak var delegate: VendorMapDelegate?
init(vendorMapDelegate: VendorMapDelegate) {
self.delegate = vendorMapDelegate
super.init()
}
func mapViewRegionChanged(_ mapView: MAMapView!) {
delegate?.mapViewDidChange()
}
func mapInitComplete(_ mapView: MAMapView!) {
delegate?.mapInitComplete()
}
}
聲明了一個通用的接口 VendorMapDelegate 來表示地圖源的通知事件。因為每個時刻只會有一個地圖源存在,因此 VendorMapDelegateProxy 也只會有一個實例和 MeshMapView 關聯(lián)。mapInitComplete 方法是高德特有的,不同的地圖 SDK 有不同的方式表示自己加載完成,有的是 finishLoading,高德則是 mapInitComplete。地圖加載完成的事件外界也會關心,因此也聲明了這個方法。
接著我們把代理對象集成到 MeshMapView 中:
public class MeshMapView: UIView {
public var homePoint: CLLocationCoordinate2D? {
didSet {
updateHomePoint(homePoint)
}
}
private lazy var mapDelegateProxy: VendorMapDelegateProxy = {
return VendorMapDelegateProxy(vendorMapDelegate: self)
}()
private func addVendorMapView() {
switch MeshMapView.currentMapVendor {
case .gaode:
let gaodeMap = MAMapView(frame: CGRect.zero)
gaodeMap.delegate = mapDelegateProxy
addSubview(gaodeMap)
gaodeMap.snp.makeConstraints { (make) in
make.edges.equalToSuperview()
}
self.gaodeMap = gaodeMap
case .baidu:
// 。。。
}
}
func refreshCustomOverlay() {
updateHomePoint(homePoint)
}
}
extension MeshMapView: VendorMapDelegate {
func mapViewDidChange() {
refreshCustomOverlay()
}
func mapInitComplete() {
//。。。
}
}
集成這段代碼后可以看出為什么之前需要保存 homePoint 的地理坐標了:因為地圖區(qū)域變化后需要重新渲染標注,需要元數(shù)據(jù)重新映射平面坐標,好更新位置。
這個模塊的設計要點是 CustomMapOverlayView 的職責一定要劃分清楚,它只接受平面坐標更新位置。這樣 CustomMapOverlayView 可以和業(yè)務解耦,只是特供了標注的繪制能力。而地圖控件需要管理坐標轉換,地圖區(qū)域變動后的重新渲染的時機。