🔍

MKMapView上に指をなぞって任意の図形を描画する

2023/09/29に公開

こんにちは、スペースマーケットでモバイルアプリエンジニアをしている王です。段々涼しくなりましたね、皆さんは風邪に気を付けてください。

「⚪︎⚪︎駅の南口辺りで検索したい」のような、より細かく地図検索したいニーズに応じて、スペースマーケットのアプリで「囲んで検索」という新しい機能をリリースしました!

なかなか記事が少なく、模索しながら標準のAPIのみで実装できましたので、今回は肝心の地図上で指をなぞって図形を描画する部分(iOS編)をご紹介できればと思います。

Android編はこちらをご覧ください

また、「囲んで検索」は最新版アプリにてご利用できますので、ぜひこちらのリンクよりダウンロードして、囲んで検索を体験してみてください👆

目標

地図画面で囲んで検索モードに入ると、地図上で図形描画可能にする

図形を描画し、閉じてないときは自動的にクローズできる

描画した図形を地図上で表示し、地図を拡大、縮小、移動しても図形は追従できる

クリアボタンで図形をクリアし、再度描画できる

実現方法

まずざっくり各目標をどのように実現したか簡単に説明します。

  • 地図画面で囲んで検索モードに入ると、地図上で図形描画可能にする
  • 描画した図形を地図上で表示し、地図を拡大、縮小、移動しても図形は追従できる
    • まず描画用UIViewが邪魔になるのでremoveする
    • UIViewで描画した図形はCGPointの配列を持っているので、CGPointをCLLocationCoordinate2Dに変換して、MKPolygonを使って地図へレンダリング。この部分もコードで詳しく説明する
  • 図形をクリアでき、図形を再度描画できるようにする
    • 特筆するところなく、地図上で表示した図形をクリアし、描画用UIViewを再度生成することで実現

実装

地図画面で囲んで検索モードに入ると、地図上で図形描画可能にする

いよいよ実装に入ります。まずベースとなるMapViewを用意します(細かい設定など省略します)

MapViewController.swift
import UIKit
import MapKit

class MapViewController: UIViewController {
    
    @IBOutlet private weak var mapView: MKMapView!
    // 囲んで検索モードに入るためのボタン
    @IBOutlet private weak var surroundSearchButton: UIButton!
    @IBOutlet private weak var closeButton: UIButton!
    
    private var isSurroundSearchMode = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpMapView()
	surroundSearchButton.addTarget(self, action: #selector(didTapSurroundSearchButton), for: .touchUpInside)
	closeButton.addTarget(self, action: #selector(didTapCloseButton), for: .touchUpInside)
    }
    
    private func setUpMapView() {
        // mapViewの各種設定...
	mapView.delegate = self
	...
    }
    
    @objc private func didTapSurroundSearchButton() {
       isSurroundSearchMode = true
    }
    
    @objc private func didTapCloseButton() {
       isSurroundSearchMode = false
    }
}

extension MapViewController: MKMapViewDelegate {
}

次に描画用のUIViewを用意し、UIView上で指でなぞって図形を描画できるようにします。ここら辺の記事がいっぱいあると思いますが、特に難しいことをやっていません。

DrawView.swift
import UIKit

protocol DrawViewDelegate: AnyObject {
    func didFinishDrawing(pointList: [CGPoint])
}

final class DrawView: UIView {
    private let MINIMUM_POLYGON_POINT_COUNT = 3
    private var pointList: [CGPoint] = []
    private var isFinishDrawing = false

    weak var delegate: DrawViewDelegate?

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        guard let context = UIGraphicsGetCurrentContext() else { return }

        context.setLineWidth(5)
        context.setStrokeColor(UIColor.main.cgColor)

        if pointList.isNotEmpty {
            context.beginPath()
            if let firstPoint = pointList.first {
                context.move(to: firstPoint)
            }
            for point in pointList {
                context.addLine(to: point)
            }
            if isFinishDrawing {
                context.closePath()
            }
            context.strokePath()
        }

        if isFinishDrawing && pointList.count >= MINIMUM_POLYGON_POINT_COUNT {
            context.beginPath()
            if let firstPoint = pointList.first {
                context.move(to: firstPoint)
            }
            for point in pointList {
                context.addLine(to: point)
            }
            context.closePath()
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        isFinishDrawing = false
        guard let point = touches.first?.location(in: self) else { return }
        pointList.append(point)
        setNeedsDisplay()
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let point = touches.first?.location(in: self) else { return }
        pointList.append(point)
        setNeedsDisplay()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        isFinishDrawing = true
        guard let point = touches.first?.location(in: self),
              pointList.count >= MINIMUM_POLYGON_POINT_COUNT else {
            clearDrawingPoint()
            return
        }
        pointList.append(point)
        delegate?.didFinishDrawing(pointList: pointList)
        clearDrawingPoint()
    }

    func clearDrawingPoint() {
        pointList.removeAll()
        setNeedsDisplay()
    }
}
  • pointList: [CGPoint]
    • 図形を地図へレンダリングするとき[CGPoint] -> [CLLocationCoordinate2D]で変換する必要があるため
  • didFinishDrawing(pointList: [CGPoint])
    • 地図移動の邪魔になるので、描画完了時点でこのメソッドを呼び出し、地図へのレンダリング、DrawViewの削除とCGPoint変換を行う

context.closePath()すると自動補完できるが、地図へ表示するとき不具合が起きてしまうことがあるので、touchesEndedで起点のCGPointを終点として追加すると図形が綺麗に表示できます。


最後はボタン押下時にDrawViewを生成して被れば、一応地図上で描画するように見えます。(もちろんDrawViewがremoveされると描画した図形が見えなくなりますが)

MapViewController.swift
class MapViewController: UIViewController {
    
    @IBOutlet private weak var mapView: MKMapView!
    // 囲んで検索モードに入るためのボタン
    @IBOutlet private weak var surroundSearchButton: UIButton!
    @IBOutlet private weak var closeButton: UIButton!
    
    private var isSurroundSearchMode = false
+   private var drawView: DrawView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpMapView()
	surroundSearchButton.addTarget(self, action: #selector(didTapSurroundSearchButton), for: .touchUpInside)
	closeButton.addTarget(self, action: #selector(didTapCloseButton), for: .touchUpInside)
    }
    
    private func setUpMapView() {
        // mapViewの各種設定...
	mapView.delegate = self
	...
    }
    
    @objc private func didTapSurroundSearchButton() {
        isSurroundSearchMode = true
    }
    
    @objc private func didTapCloseButton() {
        isSurroundSearchMode = false
    }
    
+   private func showDrawView() {
+       if drawView == nil {
+           drawView = DrawView(frame: mapView.bounds)
+       }
+       drawView?.delegate = self
+       drawView?.backgroundColor = .clear
+       if let drawView {
+           mapView.addSubview(drawView)
+       }
+   }
}

extension MapViewController: MKMapViewDelegate {
}

+ extension MapViewController: DrawViewDelegate {
+     func didFinishDrawing(pointList: [CGPoint]) {
+       guard let coordinateList = makeCoordinateList(pointList: pointList) else { 
+           drawView?.clearDrawingPoint()
+           return
+       }
+         drawView?.removeFromSuperview()  
+	 // TODO: 図形を地図へレンダリング
+     }
+
+     private func makeCoordinateList(pointList: [CGPoint]) -> [CLLocationCoordinate2D]? {
+     // TODO: [CGPoint] -> [CLLocationCoordinate2D]
+ }

描画した図形を地図上で表示し、地図を拡大、縮小、移動しても図形は追従できる

肝心の部分ですが実際にやってみるとそこまで難しくありません。主にMKPolygonMKOverlaymapView(_:rendererFor:)を使って実現します

まずは準備段階として、[CGPoint]を[CLLocationCoordinate2D]の変換を行います

MapViewController.swift
extension MapViewController: DrawViewDelegate {
    ・・・
    private func makeCoordinateList(pointList: [CGPoint]) -> [CLLocationCoordinate2D]? {
        let coordinateList = pointList.map { point -> CLLocationCoordinate2D in
            mapView.convert(point, toCoordinateFrom: mapView)          }
         if let firstCoordinate = coordinateList.first {
             return coordinateList + [firstCoordinate]
         } else {
             return nil
         }
    }
}

そして地図で図形を表示するためのpolygonを用意し、didFinishDrawingが呼ばれた時点で生成していきます。

MKPolygon: 地図上に多角形を表現するためのクラスで、主に地図上に特定のエリアをハイライトしたり、囲んだりする際に使用します。

MapViewController.swift
class MapViewController: UIViewController {
    ・・・
+   private var drawingPolygon: MKPolygon?
}

extension MapViewController: DrawViewDelegate {
    func didFinishDrawing(pointList: [CGPoint]) {
        guard let coordinateList = makeCoordinateList(pointList: pointList) else { 
            drawView?.clearDrawingPoint()
            return
	}
        drawView?.removeFromSuperview()  
+       if drawingPolygon == nil {
+           drawingPolygon = MKPolygon(coordinates: coordinateList, count: coordinateList.count)
+       }
+       if let drawingPolygon {
+           mapView.addOverlay(drawingPolygon)
+       }
    }
}

最後はMKMapViewDelegateの描画用メソッドmapView(_:rendererFor:)で色や太さ、塗りつぶしなど指定して描画完了!

MapViewController.swift
extension MapViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        guard let polygon = overlay as? MKPolygon else {
            return MKOverlayRenderer()
        }
        let polygonRender = MKPolygonRenderer(overlay: polygon)
	// 枠線の色
        polygonRender.strokeColor = .blue
	// 塗りつぶしの色
        polygonRender.fillColor = .blue.withAlphaComponent(0.3)
	// 枠線の太さ
        polygonRender.lineWidth = 5
        return polygonRender
    }
}

これで指で描画した図形は地図上に表示し、地図に追従できるようになります🎉

図形をクリアでき、図形を再度描画できるようにする

いよいよ最後の部分ですね。目標全部実現するまであと一歩!

MapViewController.swift
class MapViewController: UIViewController {
    ・・・
    private func clearPolygon() {
	if let drawingPolygon {
            mapView.removeOverlay(drawingPolygon)
            drawingPolygon = nil
        }
	if let drawView {
            mapView.addSubview(drawView)
        }
    }
}

はい終わりです。removeOverlayで図形を消して、addSubviewでdrawViewをもう一回被るだけです。
これで一通り機能すると思います。が、もう一個問題が残ってしまいます。

ぐちゃぐちゃな図形が描画されてしまう問題

よっしゃ完了だ!と思ってモンキーテストをやる時に、図形が交差点出たりぐちゃぐちゃで描画されたりした場合、描画した図形の枠線が変になるや、塗りつぶしが中途半端になる問題を発見しました。毎回綺麗な図形を描画するには期待できないので、なんとか解決しないといけないですね。

枠線が太くなる、塗りつぶしがうまくいかない

どうすればいいか悩んでる時の救世主がこの記事でした
https://zenn.dev/bluage_techblog/articles/1471c2f24ba659#ぐちゃぐちゃになぞられた線を整形する

凸包アルゴリズムを使ってCGPointを整形することで、この問題を回避できます!!

DrawView.swift
final class DrawView {
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        ・・・
-       delegate?.didFinishDrawing(pointList: pointList)
+       delegate?.didFinishDrawing(pointList: makeConvexHullPointList(pointList: pointList))
        clearDrawingPoint()
    }
}

extension DrawView {
    // 凸包アルゴリズム適用したリストを返す
    private func makeConvexHullPointList(pointList: [CGPoint]) -> [CGPoint] {
        // 反時計回りにソート
        let sortedPointList = pointList.sorted { lPoint, rPoint -> Bool in
            if lPoint.x == rPoint.x {
                return lPoint.y < rPoint.y
            } else {
                return lPoint.x < rPoint.x
            }
        }

        var convexHullPointList = [CGPoint]()

        // 左半分の凸包構築
        for point in sortedPointList {
            while convexHullPointList.count >= 2 && calculateCrossProduct(p: convexHullPointList[convexHullPointList.count - 2],
                                                                          q: convexHullPointList[convexHullPointList.count - 1],
                                                                          r: point) <= 0 {
                convexHullPointList.removeLast()
            }
            convexHullPointList.append(point)
        }

        // 右半分の凸包構築
        let upperHullSize = convexHullPointList.count + 1
        for index in (0..<(sortedPointList.count - 1)).reversed() {
            let point = sortedPointList[index]
            while convexHullPointList.count >= upperHullSize && calculateCrossProduct(p: convexHullPointList[convexHullPointList.count - 2],
                                                                                      q: convexHullPointList[convexHullPointList.count - 1],
                                                                                      r: point) <= 0 {
                convexHullPointList.removeLast()
            }
            convexHullPointList.append(point)
        }

        // 最後の重複を削除
        convexHullPointList.removeLast()

        if convexHullPointList.count < MINIMUM_POLYGON_POINT_COUNT {
            return pointList
        } else {
            return convexHullPointList
        }
    }

    // 外積を計算
    private func calculateCrossProduct(p: CGPoint, q: CGPoint, r: CGPoint) -> CGFloat {
        let pq = CGPoint(x: q.x - p.x, y: q.y - p.y)
        let pr = CGPoint(x: r.x - p.x, y: r.y - p.y)
        return pq.x * pr.y - pq.y * pr.x
    }
}

凸包に関して全く門外漢なのでChatGPTに聞いたコードをそのまま使ったが、綺麗に動いてくれたので感動しました。いい時代ですね〜

本当は凹包使いたいですがいまいち実装方法わからずなので今後の自分に期待します。

全体のコード

全体のコード
MapViewController.swift
import UIKit
import MapKit

final class MapViewController: UIViewController {
    
    @IBOutlet private weak var mapView: MKMapView!
    // 囲んで検索モードに入るためのボタン
    @IBOutlet private weak var surroundSearchButton: UIButton!
    @IBOutlet private weak var clearPolygonButton: UIButton!
    @IBOutlet private weak var closeButton: UIButton!
    
    private var isSurroundSearchMode = false
+   private var drawView: DrawView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpMapView()
	surroundSearchButton.addTarget(self, action: #selector(didTapSurroundSearchButton), for: .touchUpInside)
	clearPolygonButton.addTarget(self, action: #selector(didTapClearPolygonButton), for: .touchUpInside)
	closeButton.addTarget(self, action: #selector(didTapCloseButton), for: .touchUpInside)
    }
    
    private func setUpMapView() {
        // mapViewの各種設定...
	mapView.delegate = self
	...
    }
    
    @objc private func didTapSurroundSearchButton() {
        isSurroundSearchMode = true
    }
    
    @objc private func didTapCloseButton() {
        isSurroundSearchMode = false
    }
    
    private func showDrawView() {
        if drawView == nil {
            drawView = DrawView(frame: mapView.bounds)
        }
        drawView?.delegate = self
        drawView?.backgroundColor = .clear
        if let drawView {
            mapView.addSubview(drawView)
        }
    }
    
    private func didTapClearPolygonButton() {
	if let drawingPolygon {
            mapView.removeOverlay(drawingPolygon)
            drawingPolygon = nil
        }
	if let drawView {
            mapView.addSubview(drawView)
        }
    }    
}

extension MapViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        guard let polygon = overlay as? MKPolygon else {
            return MKOverlayRenderer()
        }
        let polygonRender = MKPolygonRenderer(overlay: polygon)
	// 枠線の色
        polygonRender.strokeColor = .blue
	// 塗りつぶしの色
        polygonRender.fillColor = .blue.withAlphaComponent(0.3)
	// 枠線の太さ
        polygonRender.lineWidth = 5
        return polygonRender
    }
}

extension MapViewController: DrawViewDelegate {
    func didFinishDrawing(pointList: [CGPoint]) {
        guard let coordinateList = makeCoordinateList(pointList: pointList) else { 
            drawView?.clearDrawingPoint()
            return
	}
        drawView?.removeFromSuperview()  
        if drawingPolygon == nil {
            drawingPolygon = MKPolygon(coordinates: coordinateList, count: coordinateList.count)
        }
        if let drawingPolygon {
            mapView.addOverlay(drawingPolygon)
        }
     }

    private func makeCoordinateList(pointList: [CGPoint]) -> [CLLocationCoordinate2D]? {
        let coordinateList = pointList.map { point -> CLLocationCoordinate2D in
        mapView.convert(point, toCoordinateFrom: mapView)
	}
        if let firstCoordinate = coordinateList.first {
            return coordinateList + [firstCoordinate]
        } else {
            return nil
        }
     }
}
DrawView.swift
import UIKit

protocol DrawViewDelegate: AnyObject {
    func didFinishDrawing(pointList: [CGPoint])
}

final class DrawView: UIView {
    private let MINIMUM_POLYGON_POINT_COUNT = 3
    private var pointList: [CGPoint] = []
    private var isFinishDrawing = false

    weak var delegate: DrawViewDelegate?

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        guard let context = UIGraphicsGetCurrentContext() else { return }

        context.setLineWidth(5)
        context.setStrokeColor(UIColor.main.cgColor)

        if pointList.isNotEmpty {
            context.beginPath()
            if let firstPoint = pointList.first {
                context.move(to: firstPoint)
            }
            for point in pointList {
                context.addLine(to: point)
            }
            if isFinishDrawing {
                context.closePath()
            }
            context.strokePath()
        }

        if isFinishDrawing && pointList.count >= MINIMUM_POLYGON_POINT_COUNT {
            context.beginPath()
            if let firstPoint = pointList.first {
                context.move(to: firstPoint)
            }
            for point in pointList {
                context.addLine(to: point)
            }
            context.closePath()
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        isFinishDrawing = false
        guard let point = touches.first?.location(in: self) else { return }
        pointList.append(point)
        setNeedsDisplay()
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let point = touches.first?.location(in: self) else { return }
        pointList.append(point)
        setNeedsDisplay()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        isFinishDrawing = true
        guard let point = touches.first?.location(in: self),
              pointList.count >= MINIMUM_POLYGON_POINT_COUNT else {
            clearDrawingPoint()
            return
        }
        pointList.append(point)
        delegate?.didFinishDrawing(pointList: makeConvexHullPointList(pointList: pointList))
        clearDrawingPoint()
    }

    func clearDrawingPoint() {
        pointList.removeAll()
        setNeedsDisplay()
    }
}

extension DrawView {
    // 凸包アルゴリズム適用したリストを返す
    private func makeConvexHullPointList(pointList: [CGPoint]) -> [CGPoint] {
        // 反時計回りにソート
        let sortedPointList = pointList.sorted { lPoint, rPoint -> Bool in
            if lPoint.x == rPoint.x {
                return lPoint.y < rPoint.y
            } else {
                return lPoint.x < rPoint.x
            }
        }

        var convexHullPointList = [CGPoint]()

        // 左半分の凸包構築
        for point in sortedPointList {
            while convexHullPointList.count >= 2 && calculateCrossProduct(p: convexHullPointList[convexHullPointList.count - 2],
                                                                          q: convexHullPointList[convexHullPointList.count - 1],
                                                                          r: point) <= 0 {
                convexHullPointList.removeLast()
            }
            convexHullPointList.append(point)
        }

        // 右半分の凸包構築
        let upperHullSize = convexHullPointList.count + 1
        for index in (0..<(sortedPointList.count - 1)).reversed() {
            let point = sortedPointList[index]
            while convexHullPointList.count >= upperHullSize && calculateCrossProduct(p: convexHullPointList[convexHullPointList.count - 2],
                                                                                      q: convexHullPointList[convexHullPointList.count - 1],
                                                                                      r: point) <= 0 {
                convexHullPointList.removeLast()
            }
            convexHullPointList.append(point)
        }

        // 最後の重複を削除
        convexHullPointList.removeLast()

        if convexHullPointList.count < MINIMUM_POLYGON_POINT_COUNT {
            return pointList
        } else {
            return convexHullPointList
        }
    }

    // 外積を計算
    private func calculateCrossProduct(p: CGPoint, q: CGPoint, r: CGPoint) -> CGFloat {
        let pq = CGPoint(x: q.x - p.x, y: q.y - p.y)
        let pr = CGPoint(x: r.x - p.x, y: r.y - p.y)
        return pq.x * pr.y - pq.y * pr.x
    }
}

最後に

今回初めてMKMapViewをちゃんと扱うこと(以前別のアプリでMapBoxしか触ることなく)で最初は何もかもわからん状態でしたが、調べながら実装できて本当に良かった!とウキウキ状態ですが、まだまだ改善、反省点があったり、もっといい実装方法があると思いますが、よければコメントなどをいただくと幸いです🙇

長い文章ですが、最後までお読みいただきありがとうございます。

スペースマーケット Engineer Blog

Discussion