返回

SwiftUI MapKit:自定义叠加层绘制详解

IOS

SwiftUI MapKit 自定义叠加层绘制

在使用 SwiftUI 构建地图应用时,开发者经常需要超越 MapKit 提供的默认样式,例如在路径折线之上绘制箭头和文本标注。默认情况下,MapKit 的样式选项仅限于基本笔触样式,直接实现复杂的叠加层比较困难。此时,UIViewRepresentable 就成了一种有效的解决方法。本文将探讨如何利用它来绘制自定义叠加层,以及处理一些相关问题。

问题:默认样式的局限性

MapKit 提供的 MKPolylineMKPolygon 等覆盖层,在定制外观时选项有限。你无法直接在折线上添加箭头或在指定位置添加文本标注,这就阻碍了一些复杂的地图信息展示。针对类似路线箭头或文本注释的场景,默认的MapView的渲染能力略显不足,需要探索更精细化的渲染方法。

解决方案一:使用 UIViewRepresentable 自定义视图

为了突破默认样式的限制,可以使用UIViewRepresentable协议创建一个封装 MKMapView 的自定义视图,从而获得更强大的绘制能力。这个方法允许你利用 UIKit 中的底层 API 来自定义叠加层的渲染,并达到预期效果。

操作步骤:

  1. 创建一个结构体并遵循 UIViewRepresentable 协议,例如 CustomMapView
  2. makeUIView(context:) 中创建一个 MKMapView 实例,并配置初始设置(比如区域、缩放等)。
  3. 实现 updateUIView(_:context:),负责地图视图的更新和数据传递。
  4. 利用 MKMapViewDelegate 中的 rendererForOverlay(_:) 方法返回自定义渲染器。这里是关键步骤,可以使用 MKPolylineRenderer 并进行自定义。
  5. 在渲染器中使用 Core Graphics 绘制箭头或文本。例如,可以创建一个 CAShapeLayerCATextLayer 来绘制对应的图形或文字。
  6. 需要特别注意叠加层的层级和动画。避免闪烁和渲染顺序错误。
  7. CustomMapView整合到你的SwiftUI视图中。

代码示例:

import SwiftUI
import MapKit

struct CustomMapView: UIViewRepresentable {

    let coordinates: [CLLocationCoordinate2D]
    @Binding var mapRegion: MKCoordinateRegion

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        mapView.setRegion(mapRegion, animated: false)
       
        let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
        mapView.addOverlay(polyline)
        return mapView
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
       uiView.setRegion(mapRegion, animated: true) // 允许在region变化时动画更新
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, MKMapViewDelegate {
      
        let parent: CustomMapView
        init(_ parent: CustomMapView) {
            self.parent = parent
        }
        
        func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
            if let polyline = overlay as? MKPolyline {
                let renderer = MKPolylineRenderer(polyline: polyline)
                renderer.strokeColor = .blue
                renderer.lineWidth = 4
                addArrowAndTextOverlay(for: polyline, in: renderer, mapView: mapView)
                return renderer
                }
                return MKOverlayRenderer(overlay: overlay)
            }

        private func addArrowAndTextOverlay(for polyline: MKPolyline, in renderer: MKPolylineRenderer, mapView: MKMapView) {
             guard polyline.pointCount > 1 else { return }

           for i in stride(from: 0, to: polyline.pointCount - 1, by: 10) {
                   
                 let mapPoint1 = polyline.points()[i]
                 let mapPoint2 = polyline.points()[min(i+5, polyline.pointCount - 1)]

               
                let cgPoint1 = renderer.point(for: mapPoint1)
                let cgPoint2 = renderer.point(for: mapPoint2)

                let arrowLayer = makeArrow(from: cgPoint1, to: cgPoint2, color: .red)
                renderer.layer.addSublayer(arrowLayer)


                // 假设您希望在两个点中间添加文本
                  let midPoint = CGPoint(x: (cgPoint1.x + cgPoint2.x) / 2, y: (cgPoint1.y + cgPoint2.y) / 2)
                  let textLayer = makeText(at: midPoint, text: "\(i)", color:.green)
                    renderer.layer.addSublayer(textLayer)
            }

          }
            
        
          private func makeArrow(from start: CGPoint, to end: CGPoint, color: UIColor) -> CAShapeLayer {

              let arrowLength: CGFloat = 15
              let arrowWidth: CGFloat = 8
              
                let angle = atan2(end.y - start.y, end.x - start.x)
                
             let arrowPath = UIBezierPath()
              
               arrowPath.move(to: end)
               arrowPath.addLine(to: CGPoint(x: end.x - arrowLength * cos(angle - .pi / 6), y: end.y - arrowLength * sin(angle - .pi/6)))
              
              
               arrowPath.move(to: end)
                arrowPath.addLine(to: CGPoint(x: end.x - arrowLength * cos(angle + .pi/6), y: end.y - arrowLength * sin(angle + .pi/6)))

               let layer = CAShapeLayer()
                layer.path = arrowPath.cgPath
               layer.strokeColor = color.cgColor
                layer.lineWidth = 1
              return layer
              
            
           }


        private func makeText(at point: CGPoint, text: String, color:UIColor) -> CATextLayer{
            
            let textLayer = CATextLayer()
               textLayer.string = text
             
           textLayer.fontSize = 12
            
            textLayer.foregroundColor = color.cgColor
              let textSize = (text as NSString).size(withAttributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)])
             textLayer.frame = CGRect(x:point.x - textSize.width / 2, y: point.y - textSize.height / 2, width: textSize.width, height: textSize.height)
            return textLayer
          }
    }
}

操作安全和最佳实践:

  • 为了避免在渲染过程中出现卡顿,尽可能使用缓存机制,减少计算次数,特别是当路径很长或有很多文本标签时。
  • 文本标签应该根据当前地图的缩放级别进行调整。避免标签重叠。可以使用计算方法决定何时绘制文本标注。
  • 使用不同的Layer类型时需要注意性能。 比较 CALayerCAShapeLayerCATextLayer 各自性能,并在适当情况下使用
  • 测试各种设备,特别注意不同屏幕尺寸下图形和文本展示是否正常。

额外考量

尽管上述方法能较好解决自定义覆盖层问题,但仍然需要针对具体需求仔细调整。例如:

  • 如果需要实现动画效果,需要额外进行设计,让动画过渡更加自然流畅。
  • 若地图包含大量的叠加层元素,则可能需要使用自定义视图并用Metal框架来进行渲染。

总而言之,利用 UIViewRepresentable 与 Core Graphics 配合,是 SwiftUI 中解决 MapKit 自定义覆盖层难题的一种有效策略。理解相关原理,并谨慎实施上述建议,能够帮助你构建更高级、更美观的地图应用。