基于 Swift 多地圖源業(yè)務向地圖控件實現(xiàn)(二):自定義 UI 展示

在封裝了地圖源之后,我們開始實現(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ū)域變動后的重新渲染的時機。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容