ちょっと話題の記事

[Swift][iOS] 京都御所に近ければ偉いだと?! せやったらそんな”はんなり”した地図アプリ作ったろやないか! – MKMapView使い方まとめ

まるたけえびすにおしおいけ。あねさんろっかくたこにしき。しあやぶったかまつまんごじょう。 せきだちゃらちゃらうおのたな。ろくじょう、ひちじょうとおりすぎ。はちじょうこえればとうじみち。くじょうおおじでとどめさす。
2020.09.14

はじめに

CX事業本部の なか安 です。おこしやす。

愚生は京都市出身の人間でございまして、 弊社Slackでは京都について雑談をするチャンネルを作っております。

身内ネタな話になってしまいますが、在りし日に自称クラメソで一番面白い人(参考ブログ)が、その京都チャンネルにこんな書き込みをしてきました。

御所に近い順にえらい?

どうやらこの自称クラメソで一番面白い人は、京都というものにあらぬ誤解を持っているようです。

思い返してみれば最近はテレビなどの影響なのでしょうか。 京都出身というだけで「いけずなんでしょ」とか「プライド高いんでしょ」とか「裏表あるんでしょ」とか、 非常に悲しいことを言われてしまいます。

ソンナコトナイヨ。ソンナコトナインダヨ。

しかし、そんな悲壮感も心に抱きながらも「地図にピンを打つと京都御所にどれだけ近いのか」というアプリ作りに挑戦してみることにしました。

iOSではMapKitMKMapViewという標準の地図コンポーネントが用意されています。

地図を使ったアプリも多いですが、あまり触ったことない方もおられるかもしれません。 今回のブログは、そういった方に向けてMKMapViewの基本的な使い方のまとめを通じて 「京都御所にどれだけ近いのか」アプリの制作をブログにしたためてみたいと思います。

今回のブログは以降は全編「京ことば」で書かせていただきます(「京都弁」じゃないですよ「京ことば」です)。 読みづらいところもあるかもしれまんが、 これを読む皆様に至りましては、あなたの頭の中の京都人(できたら、和服を着たおばさま)に喋らせながらお読みください。

では、まいりましょう。

下準備

ほしたら、まずは下準備をさせてもらいますえ(*1)。

ビューコントローラのソースコードはこないな感じにさせてもらいます(*2)。 地図ビューの参照もmapViewという変数で取得できるよう あんじょうしときます(*3)。

import UIKit
import MapKit

class ViewController: UIViewController {
    
    var mapView: MKMapView {
        return view as! MKMapView
    }
    
    override func loadView() {
        view = MKMapView()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.delegate = self
    }
}

extension ViewController: MKMapViewDelegate {
    
}

こないなふうに書かはるだけでアプリを起動してみはると地図が出てきはると思います。

せやけど、あきまへん。 このまんまやと こないな感じで日本全体が表示されてしまわはります。(※設定が日本になっている端末の場合)

これやと御所が何処にあるのかようわかりまへんなぁ。

移動と縮尺

せやからまずは、任意の場所に任意の縮尺で地図を表示させてみましょ。

まず、こないなメソッドをこしらえます(*4)。 ほんまやったら3行くらいで収まるんやけど、 読みやすうするために改行を多めに入れさせてもらいますね。

private func moveTo(
    center location: CLLocationCoordinate2D,
    animated: Bool,
    span: CLLocationDegrees = 0.01) {
    
    let coordinateSpan = MKCoordinateSpan(
        latitudeDelta: span,
        longitudeDelta: span
    )
    let coordinateRegion = MKCoordinateRegion(
        center: location,
        span: coordinateSpan
    )
    mapView.setRegion(
        coordinateRegion,
        animated: animated
    )
}

いきなり色んなもんが出てきはりましたさかいに、出てきはるモノについて説明だけさせてもらいます。

CLLocationCoordinate2D

MKMapView上で「緯度経度の指定」なんかをする時によう使われる構造体です。

似たもんにCLLocationいうクラスもいてはるんですけど、 地図を扱わはるんならCLLocationCoordinate2Dのほうをよう使いますさかいに、 こちらの方を使わせてもらいます。

MKCoordinateSpan

この値は「縮尺」を決めるもんやと思ておくれやす。

与えてる値は緯度経度の「度」にあたる部分で、その「度の範囲」いう意味になるいうことなんやけれど、 地図のえらい難しい話になってまうさかいに、ここは1 degrees = 約111kmになるもんやと思うておくれやす。

引数のデフォルト値を設定させてもろてるんですけど、もちろん、このへんはお好みでかましまへん。

MKCoordinateRegion

ここまでの「場所」と「縮尺」を組み合わせてできあがりますのんが「リージョン(地域)」になります。

地図にこのオブジェクトを渡さはったら、任意の場所を中心に任意の縮尺で地図を表示してくれはります。

京都御所を表示させる

一口に"京都御所"言いはっても 0.7km × 1.3km の広〜い御所ですさかい、 今回の「御所」は天皇はんがお住まいになられていた「紫宸殿」を中心とさせてもらいます。

GoogleMapやと

緯度: 35.024101 経度: 135.762018のあたりやろか。

これをビューコントローラの定数として持たせておきましょ。

class MapViewController : UIViewController {
    
    /// 京都御所(紫宸殿)の緯度経度
    private let imperialPalaceLocation = CLLocationCoordinate2DMake(35.024101, 135.762018)
}

で、ビューコントローラのviewDidLoad()で、先程のメソッドをこないに呼び出さはると

override func viewDidLoad() {
    super.viewDidLoad()
    mapView.delegate = self
    moveTo(center: imperialPalaceLocation, animated: false)
}

アプリ起動時には紫宸殿を中心に地図を表示しはります。

タップした位置を取得

次ですけど、地図上でタップしはった場所の緯度経度が取れるよう あんじょうしていきましょか。

タップイベントについてはUITapGestureRecognizerの仕組みを使わせてもうて取得させてもらいます。

override func viewDidLoad() {
    super.viewDidLoad()
    mapView.delegate = self
    moveTo(center: imperialPalaceLocation, animated: false)
    addTapGestureRecognizer()
}

private func addTapGestureRecognizer() {
    let gesture = UITapGestureRecognizer(
        target: self, 
        action: #selector(didTap(gesture:))
    )
    mapView.addGestureRecognizer(gesture)
}

@objc private func didTap(gesture: UITapGestureRecognizer) {
    guard gesture.state == .ended else { return }
    
    let point = gesture.location(in: view)
    let locationCoordinate = mapView.convert(
        point, 
        toCoordinateFrom: mapView
    )
    print(locationCoordinate)
}

ビュー上のタップ位置をCGPointとして取れますねんけど、 それを地図上のCLLocationCoordinate2Dに変換してくれはるconvert(:toCoordinateFrom:)いう便利メソッドに渡さはりますと、 比較的簡単に緯度経度が取れますえ。

このコードを足さはってから、地図上のどこかをタップしはると

CLLocationCoordinate2D(latitude: 35.01936209940412, longitude: 135.75969800000007)
CLLocationCoordinate2D(latitude: 35.02165515011939, longitude: 135.76203133333334)
CLLocationCoordinate2D(latitude: 35.024232025607255, longitude: 135.75845800000008)
CLLocationCoordinate2D(latitude: 35.02167698838846, longitude: 135.76413800000012)
CLLocationCoordinate2D(latitude: 35.0245595887067, longitude: 135.76476466666676)
CLLocationCoordinate2D(latitude: 35.02786790243959, longitude: 135.76399133333337)
CLLocationCoordinate2D(latitude: 35.02864309850634, longitude: 135.75999133333343)

こないな感じで緯度経度が取れることが分からしますやろ?

タップした位置へ移動

ここまで出来はったら、タップしはった位置が地図の中心に来はるように移動するようしてみましょ。

先程こしらえたmoveTo()メソッドにはanimatedいう引数をこしらえてたさかいに、それを使えば簡単や思います。

@objc private func didTap(gesture: UITapGestureRecognizer) {
    guard gesture.state == .ended else { return }
     
    let point = gesture.location(in: view)
    let locationCoordinate = mapView.convert(
        point,
        toCoordinateFrom: mapView
    )
    moveTo(center: locationCoordinate, animated: true)
}

起動して地図をタップしてみはると、そこに地図がついてきてくれはる感じになる思います。

地図にマーク(ピン)を付ける

タップしはったところに移動できはるようなったんはよろしおすけど、 わかりやすいようマークが付くようなったらよろしいやろ思いまして、 そのあたりをやっていこう思います。

地図上のマークのことは「アノテーション」呼びますねん。

具体的には地図ビューにMKAnnotationオブジェクトを追加していきます。 MKAnnotation自体はマークそのものやなく、情報だけを保持してはるモデルオブジェクトや思てください。

アノテーションの追加

まずは地図ビューにアノテーションを追加する処理を書こか思います。

この追加された数の分だけアノテーションが表示されはることになりますねんけど、 今回はタップしはったとこだけに表示したい思てますさかいに、 1つだけがセットできるように組んでいこか思います。

そんなら、下記のようなメソッドを用意させてもらいます。

private func setAnnotation(location: CLLocationCoordinate2D) {
    mapView.removeAnnotations(mapView.annotations)
    
    let annotation = MKPointAnnotation()
    annotation.coordinate = location
    mapView.addAnnotation(annotation)
}

地図ビューにはアノテーションを保持するための配列がありますねんけど、 それを操作するためのaddremoveいうのが用意されてはります。

MKPointAnnotationオブジェクトに表示させたい緯度経度を渡しといて、地図ビューに追加させてます。

1つだけ表示させたいさかいに、 すでに配列に入ってはるアノテーションは消してから追加するよう、あんじょうにしてる感じです。

ほしたら、さきほどのdidTapにこれを足して行きましょか。

@objc private func didTap(gesture: UITapGestureRecognizer) {
    guard gesture.state == .ended else { return }
     
    let point = gesture.location(in: view)
    let locationCoordinate = mapView.convert(
        point,
        toCoordinateFrom: mapView
    )
    moveTo(center: locationCoordinate, animated: true)
    setAnnotation(location: locationCoordinate)
}

どないですか?

こないな感じでタップしたところにマークが付くようなったんとちゃいますやろか。

アノテーションの表示の仕方を変える

ええ感じにマークが付きはしましたけど、デフォルトのマークやさかいになんやオモロありまへんなぁ。 せやから、ちょっとカスタマイズしてみましょ。

地図上に表れてはるマークは「アノテーションビュー」いうもんが出てはるんです。 追加されたMKAnnotationの情報に基づいて地図にレンダリングしてはるんですね。

ひとつ注意せなあかんことは、アノテーションビューは表示されようとしてはるときに再利用されてきはりますのんや。

これはつまりUITableViewに対するUITableViewCellと よう似てはりますね。 画面上に表示されてへんときは描画せえへんようにしてメモリを節約してはるんです。

せやさかいに、ここで初めてMKMapViewDelegateの仕組みを使うことになります。

具体的に書かせてもらいますと

extension ViewController: MKMapViewDelegate {
    
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        let identifier = "annotation"
        if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
            annotationView.annotation = annotation
            return annotationView
        } else {
            let annotationView = MKMarkerAnnotationView(
                annotation: annotation,
                reuseIdentifier: identifier
            )
            annotationView.markerTintColor = .purple
            return annotationView
        }
    }
}

UITableViewCellとかと同じ(おんなし)ようにdequeueReusableAnnotationView(withIdentifier:)で、 識別用文字列を用いて再利用可能なアノテーションビューがいてはるか?いうのを確認します。

もしも既に作ってはるんやったら、アノテーション情報を渡してビューを返すだけですねんけれども、 あらへん場合はビューを新しく作って返してあげなあきまへん。 このあたりもテーブルビューと同じおすなぁ。

また、アノテーションビューについてやけれども、標準的な見た目として2種類のクラスが用意されてはります。

MKMarkerAnnotationViewMKPinAnnotationViewです。

今回は京都らしゅう"紫"のマーカーにするようにさせてもろたんですけど、 色を変えよ思うときには上の例のようにmarkerTintColorを設定しはるとよろしいんですわ。

これをMKPinAnnotationViewにするときは、下の例のようにpinTintColorになるんで、気いつけなあきまへん。

let annotationView = MKPinAnnotationView(
    annotation: annotation,
    reuseIdentifier: identifier
)
annotationView.pinTintColor = .purple

アノテーションを画像で表示する

次はアノテーションビューを任意の画像で表示してみましょ。 実際にアプリを作らはる時は、この表示方法を使うことの方が多いかもしれまへんね。

こないな場合は、基底クラスのMKAnnotationViewでビューをこしらえて画像を指定してあげましょ。 (マーカーやピンであっても画像は指定でけはるんですけど、本来の使い方とは異ならはるので)

今回は「いらすとや」さんから京都っぽい人力車のイラストを貰うてきたんで、それを使わせてもらいます。 mark.pngいうファイルとしてプロジェクトに入れてから、こないな感じで書きます。

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    let identifier = "annotation"
    if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
        annotationView.annotation = annotation
        return annotationView
    } else {
        let annotationView = MKAnnotationView(
            annotation: annotation,
            reuseIdentifier: identifier
        )
        annotationView.image = UIImage(named: "mark")
    }
}

ええ感じにかわいくなりましたやろ?

地図に同心円を描く

そういや「御所から近い」って何やろか。細こう言いだしたらキリがあらしまへん。 単純に中心からの距離を分かりやすうするためには同心円で表してみはったらいかがやろ。

せやさかい、地図に紫宸殿を中心にした同心円を描いてみよう思います。

オーバーレイ

ここで新たな概念が出てきはります。それは「オーバーレイ」いうもんです。

色々な図形を地図上に こしらえるような仕組みがありますんやけど、その要素がオーバーレイいうもんになります。

オーバーレイを地図に足さはることで図形が描けるんやけども、それを単に地図に足すだけやあきまへん。 これまたMKMapViewDelegateとの合わせ技で実現することになりますねん。

円を追加する

ほしたら、まずは地図に円を足すメソッドを こしらえるところから初めてみましょか。

private func drawCircle(
    center location: CLLocationCoordinate2D,
    meter: CLLocationDistance,
    times: Int) {
    
    mapView.addOverlays((1...times).map { i -> MKCircle in
        let raduis = meter * CLLocationDistance(i)
        return MKCircle(center: location, radius: raduis)
    })
}

このメソッドは「どこを中心に何mごとに何回同心円を描くか」いう機能になるんやけれども、 ハイライトしてるところが その実装にミソになるさかい、よう見はってください。

MKCircleいう地図に円を描く用のクラスが用意されてはります。 この仕組みを使わはることによって地図にオーバーレイいう形で任意の図形が描けることにならはるんです。

地図ビューにaddOverlay(単体の場合)、addOverlays(複数の場合)をすることで簡単にできはりますね。

せやけど、これだけやと「どんな太さ」の「どんな色」の円を描いたらよろしいんかは地図ビューさんには分からはりやしませんのや。

せやさかいに先程も言うたとおりMKMapViewDelegateでそのへんの調節をしはることになりますんのや。

円の線の調整

MKMapViewDelegateに準拠したところに、こないなメソッドを足します。

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    let circleRenderer = MKCircleRenderer(overlay: overlay)
    circleRenderer.strokeColor = .lightGray
    circleRenderer.lineWidth = 0.5
    return circleRenderer
}

このMKMapViewDelegateが要求しはるメソッドは、 渡されてきたオーバーレイ情報を描画しはるためのクラスMKOverlayRendererを返さなあきまへん。

名前の通りレンダリングをしはるためのクラスなんやけれど、 この中のプロパティらはCoreGraphics系の扱いに よう似てはります。

ほしたら、まずは 250mごとに線を描いてみましょか。 viewDidLoad()に1行足しはってください。

override func viewDidLoad() {
    super.viewDidLoad()
    mapView.delegate = self
    moveTo(center: imperialPalaceLocation, animated: false)
    addTapGestureRecognizer()
    drawCircle(center: imperialPalaceLocation, meter: 250, times: 20)
}

250mごとに20本の円を描くようさせてもろたさかいに、御所から最大半径5kmの円が描けたことになります。

円の塗りつぶし

円の中を塗りつぶしてみんのも試してみましょ。

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    let circleRenderer = MKCircleRenderer(overlay: overlay)
    circleRenderer.fillColor = UIColor.purple.withAlphaComponent(0.08)
    return circleRenderer
}

どんどん半透明(アルファ値0.08)の円を重ねっていってるさかいに、御所に近い方から色が濃くなってってはります。 なんや御所がおどろおどろしいもんになってしまはりましたな。

もちろん、strokefillを一緒に使わはることもできますえ。

地図に直線を描く

もすこし色んな描画をしてみましょか。

今度はタップしはったところにマークが付くだけやのうて、紫宸殿からその位置まで線が描かれるようさせてもらいます。 そしたら、いつでも御所のことを感じれてよろしおすやろ?

直線を追加する

こんときも円のときと同じように"オーバーレイ"を使うて描くんですけど、 タップするたびに線が足されてしまうとやかましいですさかいに、 タップごとに1本だけ線が足されるよう あんじょうしていきましょ。

直線を描くために使うオーバーレイクラスはMKPolylineいうもんにならはります。 これをビューコントローラのプロパティ変数にしときます。

class ViewController: UIViewController {
    
    private var polyline: MKPolyline?
    
    // (以下略)
}

そうしはったら、線を描く用のメソッドをこないな感じに こしらえさせてもらいます。

private func drawLine(
    from fromLocation: CLLocationCoordinate2D,
    to toLocation: CLLocationCoordinate2D) {
    
    // 描画中の直線は削除する
    if let drewPolyLine = polyline {
        mapView.removeOverlay(drewPolyLine)
    }
    
    var coordinates = [fromLocation, toLocation]
    polyline = MKPolyline(
        coordinates: &coordinates,
        count: coordinates.count
    )
    mapView.addOverlay(polyline!)
}

コメントにも書かせてもろたように、2度目に呼び出さはるときには、 前に こしらえた直線オーバーレイは地図ビューから消してしまわはったら何個も直線が描かれへんようになります。

起点(from)と終点(to)の2点の緯度経度からMKPolylineを こしらえるんやけど、 そんときは配列のポインタ渡しと「何点を繋ぐ線を描くか」いう情報を与えてあげなあきまへんから気ぃつけはってくださいね。

直線レンダラ

これも円の時と同じおすけど、 MKMapViewDelegateMKOverlayRendererを返すメソッドで線の色なんかを決めたげる必要があります。

さっきはMKCircleRendererでしたけど、直線の場合はMKPolylineRendererを使わなあきまへん。

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    let polylineRenderer = MKPolylineRenderer(overlay: overlay)
    polylineRenderer.strokeColor = .red
    polylineRenderer.lineWidth = 2
    return polylineRenderer
}

御所と赤い糸でつながってるようになりましたやろ?

円と線を併用

ここでひとつ気ぃつけなあかんことがあります。 ひとつの地図に円も線も描かなあかんときです。 レンダラーを返すメソッドはひとつやさかいに、ここは工夫が必要です。

そないな場合は、渡されてくるオーバーレイの型で見極めてあげたらよろしか思います。

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    if overlay is MKCircle {
        let circleRenderer = MKCircleRenderer(overlay: overlay)
        circleRenderer.strokeColor = .brown
        circleRenderer.lineWidth = 0.5
        return circleRenderer
    }
    if overlay is MKPolyline {
        let polylineRenderer = MKPolylineRenderer(overlay: overlay)
        polylineRenderer.strokeColor = .red
        polylineRenderer.lineWidth = 2
        return polylineRenderer
    }
    fatalError() // dead code.
}

さらにややこしい仕様があらはる場合は、 オーバーレイのカスタムなクラスを こしらえて、分岐させる方法でもよろしいかもしれまへんね。

なんしか、こないな感じで円と線が同居できはるようになりました。

2点間の距離を算出する

CLLocationの仕組みを使わはると、2点間の距離を取らはることができます。 このあたりは以前にブログで書かせてもろてますさかいに、こちらも参考になさってください。

[iOS] 地図アプリを作る – 現在地から近い順に並べる

ただ、今回は緯度経度はCLLocationやのうてCLLocationCoordinate2Dをメインに使うてますさかいに、 CLLocationCoordinate2D同士で距離が取れるようしておきましょ。

細かな距離を取得する

別途CLLocationCoordinate2Dextensionをこしらえます。

extension CLLocationCoordinate2D {
    
    func distance(
        to targetLocation: CLLocationCoordinate2D) -> CLLocationDistance {
        
        let location1 = CLLocation(
            latitude: latitude,
            longitude: longitude
        )
        let location2 = CLLocation(
            latitude: targetLocation.latitude,
            longitude: targetLocation.longitude
        )
        return location1.distance(from: location2)
    }
}

ここでは2点の緯度経度をCLLocationに変換して、 CLLocationdistance(from:)で「メートル法」での距離を取れるようさせてもろてます。

didTap()の時に一旦コンソールに出力させてみましょ。

@objc private func didTap(gesture: UITapGestureRecognizer) {
    // (省略)
    print(imperialPalaceLocation.distance(to: locationCoordinate))
}

そうすると

183.42479740600228
460.5692395568189
809.303734023534
1089.016781130542
1377.2279027424893
1666.9708629593738

タップしはるたんびに こないな感じに御所との距離が取れはります(*5)。

おおよその距離を取得する

たいそう細かい数値で取れはりましたけど、ちょっと人には読みづらあてしゃあないですね。

せやさかいに、人に読みやすいよう ざっくりとした距離を文字列で取れはるようにしてみました。

extension CLLocationCoordinate2D {
    
    func distanceText(
        to targetLocation: CLLocationCoordinate2D) -> String {
        
        let rawDistance = distance(to: targetLocation)
        
        // 1km未満は四捨五入で10m単位
        if rawDistance < 1000 {
            let roundedDistance = (rawDistance / 10).rounded() * 10
            return "\(Int(roundedDistance))m"
        }
        // 1km以上は四捨五入で0.1km単位
        let roundedDistance = (rawDistance / 100).rounded() * 100
        return "\(roundedDistance / 1000)km"
    }
}

ほしたらまた didTap()の時にコンソールに出力させてみましょ。

@objc private func didTap(gesture: UITapGestureRecognizer) {
    // (省略)
    print(imperialPalaceLocation.distanceText(to: locationCoordinate))
}

そうすると

160m
400m
700m
1.1km
1.5km
1.8km
2.2km

わかりやすい表示にならはったか思います。

地図のマークから吹き出しを出す

地図の上のマークを長押ししはると、吹き出しが出てきはるUIを見たことがあるんとちゃいますやろか。

あの吹き出しのことを「コールアウト」いいます。

さっき算出できた距離をそのコールアウトで表示させてみよう思います。

さきほどアノテーションビューを返すために実装したメソッドに1行足させてもらいます。

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    let identifier = "annotation"
    if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
        annotationView.annotation = annotation
        return annotationView
    } else {
        let annotationView = MKAnnotationView(
            annotation: annotation,
            reuseIdentifier: identifier
        )
        annotationView.image = UIImage(named: "mark")
        annotationView.canShowCallout = true
        return annotationView
    }
}

アノテーションビューのcanShowCalloutプロパティをtrueにしておきましょ。

せやけど、これだけやとコールアウトは表示されまへん。 アノテーション自体に表示する情報を与えてやらなあきまへんのや。

せやし、アノテーション追加のためのメソッドsetAnnotation(location:)も直していきましょ。

private func setAnnotation(location: CLLocationCoordinate2D) {
    mapView.removeAnnotations(mapView.annotations)
    
    let annotation = MKPointAnnotation()
    annotation.coordinate = location
    annotation.title = location.distanceText(to: imperialPalaceLocation)
    mapView.addAnnotation(annotation)
}

アノテーションのtitleプロパティに、先程こしらえた距離の文字列を返すメソッドdistanceText(to:)を使うて、 御所までの距離を渡していきます。

コールアウトはタイトルだけやなく、サブタイトルやら、周りのビューかて設定できますさかいに、様々なカスタマイズができはりますねん。 iOS9の頃(ObjC)とちょっと古くはなりますねんけど(京都の老舗と比べたらたいしたことありまへん)、 このあたりについてのブログがありますさかいに参考にしてみはってください。

[iOS] MapKitの基本的な使い方(iOS9対応)

いけずなアプリを作る

ここからはお遊びなんやけど「御所に近い順にえらい」を実現するためですさかいに、 地図上のタップしはった位置から御所との距離にもとづいて吹き出し(コールアウト)にセリフを吐かせてみよか思います。

タップしたらコールアウトさせる

コールアウトが表示されるというんは、アノテーションがselectされた状態でいてはるいうことです。

地図ビューにはselectAnnotation()いうメソッドが用意されてはるんやけど、 アノテーションを追加しはった時(今回の例ではsetAnnotation(location:)の時)に、 こないな感じで呼び出してしまわはると、吹き出しが出たり出なかったりと不安定な動作になってまうんです。

// この方法だと不安定
mapView.addAnnotation(annotation)
mapView.selectAnnotation(annotation: annotation, animated: true)

このselectAnnotation()をいつ呼び出さなあかんかというと、アノテーションビューが地図上に置かれはったときです。

MKMapViewDelegateでは、その「アノテーションビューが地図上に置かれはったとき」に呼び出されるデリゲートメソッドが存在してはるので、 そのタイミングでアノテーションビューからアノテーションを取り出して選択してやりますねん。

extension ViewController: MKMapViewDelegate {
    
    func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
        guard let annotation = views.first?.annotation else { return }
        
        mapView.selectAnnotation(annotation, animated: true)
    }
}

距離に応じてセリフを分岐

おしまいに、御所からの距離に応じて吹き出しに出すセリフを返すメソッドをこしらえました。

(悪意はまったくありまへん)

private func rank(location: CLLocationCoordinate2D) -> String {
    let rawDistance = location.distance(to: imperialPalaceLocation)
    
    switch rawDistance {
    case 0..<(2 * 1000):
        return "まぁ!由緒ある京都の方なんやねぇ"
    case (2 * 1000)..<(5 * 1000):
        return "ええとこに住んではりますねぇ"
    case (5 * 1000)..<(25 * 1000):
        return "わざわざ遠いとこから 疲れはったやろ"
    case (25 * 1000)..<(150 * 1000):
        return "えらい遠いとこから来はったんやねぇ"
    case (150 * 1000)..<(1500 * 1000):
        return "京都には観光客見に来はったんですか?"
    default:
        return "・・・"
    }
}

これをアノテーションに情報として渡しときます。

private func setAnnotation(location: CLLocationCoordinate2D) {
    mapView.removeAnnotations(mapView.annotations)
    
    let annotation = MKPointAnnotation()
    annotation.coordinate = location
    annotation.title = rank(location: location)
    annotation.subtitle = location.distanceText(to: imperialPalaceLocation)
    
    mapView.addAnnotation(annotation)
}

ほしたら、こないなアプリの完成です。

おしまいに

どないでしたやろか。

京都の人間は決して「御所に近いと偉い」やなんて思てなどしてまへん。

せやけど、こないにアプリをこしらえていく中で MKMapViewの基本的な使い方もまとめられたのでご機嫌さんです。

今回の登場人物をまとめると

  • 地図に付けるマークやピンは「アノテーション」ていわはります。
  • アノテーションは再利用される「アノテーションビュー」で実際は描かれます。
  • 地図に付ける線画は「オーバーレイ」ていわはる仕組みです。
  • アノテーションから出てくる吹き出しは「コールアウト」ていわはるもんです。

「京ことば」ではんなりと書かせてもらいましたけれど、どなたはんかのお役に立てばよろしおすなぁ。

ほな、また。よろしゅうに言うといてください(*6)。さいなら。

今回のソースコード全文

コピペしても動きはると思います。

動きが気に食わん、書き方が気に食わん、みたいなことがあらはりましたら、色々書き直しはってください。

import UIKit
import MapKit

class ViewController: UIViewController {
    
    /// 京都御所(紫宸殿)の緯度経度
    private let imperialPalaceLocation = CLLocationCoordinate2DMake(35.024101, 135.762018)
    
    private var polyline: MKPolyline?
    
    var mapView: MKMapView {
        return view as! MKMapView
    }
    
    override func loadView() {
        view = MKMapView()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.delegate = self
        moveTo(center: imperialPalaceLocation, animated: false)
        addTapGestureRecognizer()
        drawCircle(center: imperialPalaceLocation, meter: 5000, times: 250)
    }
    
    private func addTapGestureRecognizer() {
        let gesture = UITapGestureRecognizer(
            target: self, 
            action: #selector(didTap(gesture:))
        )
        mapView.addGestureRecognizer(gesture)
    }
     
    @objc private func didTap(gesture: UITapGestureRecognizer) {
        guard gesture.state == .ended else { return }
         
        let point = gesture.location(in: view)
        let locationCoordinate = mapView.convert(
            point,
            toCoordinateFrom: mapView
        )
        moveTo(center: locationCoordinate, animated: true)
        setAnnotation(location: locationCoordinate)
        drawLine(from: imperialPalaceLocation, to: locationCoordinate)
    }
    
    private func moveTo(
        center location: CLLocationCoordinate2D,
        animated: Bool,
        span: CLLocationDegrees = 0.01) {
        
        let coordinateSpan = MKCoordinateSpan(
            latitudeDelta: span,
            longitudeDelta: span
        )
        let coordinateRegion = MKCoordinateRegion(
            center: location,
            span: coordinateSpan
        )
        mapView.setRegion(
            coordinateRegion,
            animated: animated
        )
    }
    
    private func setAnnotation(location: CLLocationCoordinate2D) {
        mapView.removeAnnotations(mapView.annotations)
        
        let annotation = MKPointAnnotation()
        annotation.coordinate = location
        annotation.title = rank(location: location)
        annotation.subtitle = location.distanceText(to: imperialPalaceLocation)
        
        mapView.addAnnotation(annotation)
    }
    
    private func drawCircle(
        center location: CLLocationCoordinate2D,
        meter: CLLocationDistance,
        times: Int) {
        
        mapView.addOverlays((1...times).map { i -> MKCircle in
            let raduis = meter * CLLocationDistance(i)
            let circle = MKCircle(center: location, radius: raduis)
            return circle
        })
    }
    
    private func drawLine(
        from fromLocation: CLLocationCoordinate2D,
        to toLocation: CLLocationCoordinate2D) {
         
        // 描画中の直線は削除する
        if let drewPolyLine = polyline {
            mapView.removeOverlay(drewPolyLine)
        }
         
        var coordinates = [fromLocation, toLocation]
        polyline = MKPolyline(
            coordinates: &coordinates,
            count: coordinates.count
        )
        mapView.addOverlay(polyline!)
    }
    
    private func rank(location: CLLocationCoordinate2D) -> String {
        let rawDistance = location.distance(to: imperialPalaceLocation)
        
        switch rawDistance {
        case 0..<(2 * 1000):
            return "まぁ!由緒ある京都の方なんやねぇ"
        case (2 * 1000)..<(5 * 1000):
            return "ええとこに住んではりますねぇ"
        case (5 * 1000)..<(25 * 1000):
            return "わざわざ遠いとこから 疲れはったやろ"
        case (25 * 1000)..<(150 * 1000):
            return "えらい遠いとこから来はったんやねぇ"
        case (150 * 1000)..<(1500 * 1000):
            return "京都には観光客見に来はったんですか?"
        default:
            return "・・・"
        }
    }
}

extension ViewController: MKMapViewDelegate {
    
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        let identifier = "annotation"
        if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
            annotationView.annotation = annotation
            return annotationView
        } else {
            let annotationView = MKAnnotationView(
                annotation: annotation,
                reuseIdentifier: identifier
            )
            annotationView.image = UIImage(named: "mark")
            annotationView.canShowCallout = true
            return annotationView
        }
    }
    
    func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
        guard let annotation = views.first?.annotation else { return }
        
        mapView.selectAnnotation(annotation, animated: true)
    }
    
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        if overlay is MKCircle {
            let circleRenderer = MKCircleRenderer(overlay: overlay)
            circleRenderer.strokeColor = .brown
            circleRenderer.lineWidth = 0.5
            return circleRenderer
        }
        if overlay is MKPolyline {
            let polylineRenderer = MKPolylineRenderer(overlay: overlay)
            polylineRenderer.strokeColor = .red
            polylineRenderer.lineWidth = 2
            return polylineRenderer
        }
        fatalError() // dead code.
    }
}

extension CLLocationCoordinate2D {
    
    func distance(
        to targetLocation: CLLocationCoordinate2D) -> CLLocationDistance {
        
        let location1 = CLLocation(
            latitude: latitude,
            longitude: longitude
        )
        let location2 = CLLocation(
            latitude: targetLocation.latitude,
            longitude: targetLocation.longitude
        )
        return location1.distance(from: location2)
    }
    
    func distanceText(
        to targetLocation: CLLocationCoordinate2D) -> String {
        
        let rawDistance = distance(to: targetLocation)
        
        // 1km未満は四捨五入で10m単位
        if rawDistance < 1000 {
            let roundedDistance = (rawDistance / 10).rounded() * 10
            return "\(Int(roundedDistance))m"
        }
        // 1km以上は四捨五入で0.1km単位
        let roundedDistance = (rawDistance / 100).rounded() * 100
        return "\(roundedDistance / 1000)km"
    }
}

mark.pngに使用したイラスト
https://www.irasutoya.com/2013/10/blog-post_2089.html

京ことば補足

(*1) ほしたら

「そうしたら」「それでは」の転換。京都の言葉というよりは東の国から入って混ざった言葉とも言われます。

(*2) こないな感じ

「このような感じ」の意味。

「そないな風に」とか「あないな人」「どないなことです?」などの此其彼何言葉は京都ではよく使われてる印象です。

(*3) あんじょうしときます

「うまいことしておきます」の意味。

大阪商人の言葉とも言われてますが、うちのおばあちゃんがよく使ってたので京ことばなのでしょう。たぶん。 「味良く」の転換とも言われます。

「あんじょうしときやー」と言われると「行儀よくしとけ」「元気でな」っていうニュアンスになるのかなと。

(*4) こしらえる

「作る」の意味。

京ことばかどうかは怪しいけれども、これもおばあちゃんがよく言ってた記憶。

(*5) しはるたんび

「するたび」の意味。

京都の言葉は「度(たび)」の間に「ん」がついてる印象です。

「その度に」は「そのたんびに」と言いますが、「この度は」は「このたんび」とはあまり聞かない気もします。

(*6) よろしゅうに言うといてください

京ことばというかは、京都のおばちゃん同士が別れ際にだいたいこれを言い合っている気がします。

どす

京都の言葉というと、語尾に「どす」を付ける人が多いです。

しかし、実際には舞妓さん芸姑さんくらいしか使わないような言葉です。 「〜おす」は、まだ身近な人からは聞いたことはありますが、道端では「どす」は聞いたことはないです。

このブログでも一度も「〜どす」は使っておりません。