📍

SwiftUI: Mapでインタラクティブにピンを立てる

2023/05/28に公開

SwiftUIではMapKitMKMapViewを使うのではなく、Mapというビューがあって手軽に地図を表示できます。

<本記事の環境>

  • Xcode 14.3
  • Swift 5.8
  • macOS 13.3.1(a)
  • iOS 16.4.1(a)

ピン(or コメント・注釈)を立てたいが…

やりたいことは「ユーザがドラッグでインタラクティブに場所を指定して、そこにピンを立てられるようにする」です。

Webで調べましたが、座標を指定してピンを立てるという例はあるものの、インタラクティブにユーザに場所を指定させるという例がほとんどありません。
このため調査がてらサンプルを作成しました。

ピン座標の指定方法

指定方法を2パターン考えました。

  1. ピンをドラッグする。ピンを操作するモード、マップを操作するモードを分ける(ボタンで切り替え)
  2. マップ中心に十字を表示し、マップをずらして十字の中心にピンを立てる。

最初は「マップ上でタップ長押し」とかも考えて少し実装してみたりもしたのですが、拡大・縮小や移動を指でやるので、モードの切り替えまでマップ上の指操作だと非常に操作性が悪くなりやめました。
長押しの時間とかよほどうまく設定しないと、なかなか思い通りの操作になりません。実際、Google Mapでも移動のつもりがタップされてしまい元々表示していたピンを見失ったりして、いらつくことがあります。

サンプルアプリの画面イメージは以下の通りです。

ピンドラッグモード版

十字版

説明:ピンドラッグモード版

ピンをドラッグするモードなのか、マップをドラッグするモードなのかでモードを分けます。
ピンドラッグモードには、ボタンを押して入ります。

サンプルではピンは1本だけで、ドラッグが終わるとドラッグモードも勝手に終了し、マップ操作モードに戻ります。

MapにはMKMapViewにある、画面の座標を地図上の座標に変換するconvertがないので、自前で計算します。地図の中心の座標と、地図の縦横の幅はバインド変数(coordinateRegion)に入ってくるので、それらを用います。

  1. GeometryReaderで、ビューのサイズその他を取ってきます。
  2. ドラッグハンドラ内で、ドラッグ中の座標と1のサイズその他を用いて、相対座標に変換します。
    ここで言う相対座標とは、そのビュー内における中心を(0,0)とし、x,yともに-0.5〜+0.5で表したものになります。
  3. MapcoordinateRegion.span.latitudeDeltaには、地図の縦横の緯度での幅が入っています。coordinateRegion.center.latitudeに、相対座標y × coordinateRegion.span.latitudeDeltaを足すと、ドラッグしている画面上の座標を地図上の緯度に換算したものが求められます。[1]
  4. longitudeも同様に求めます。

いったんピンの座標が求められたら、ピンがドロップされた、ということにして、MapMapAnnotationを追加します。
この追加したMapAnnotationの座標はドラッグ中ずっと更新されます。
最後、ドラッグを終了すると.onEndedが呼ばれるので、そこでピンドラッグモードを終了します。

ピンドラッグモード中は、MapinteractionModesを空にします。マップ操作モードになったら.allに戻します。

説明:十字版

こっちはピンドラッグモード版に比べると非常に簡単です。
十字をZStackでマップの中心に表示します。サンプルではRectangleを2つ、幅1で置いています。
ピンを立てるボタンを押したら、coordinateRegion.centerを取得して、MapAnnotationを追加します。

サンプルコード

GitHubに置きました。

https://github.com/takenori-kabeya/MapSample

ちなみに

サンプルを動かしていて気付いたのですがこのMap、自分のロケーションをダブルタップすると、勝手に追跡モードになります。
もう1回ダブルタップしたら戻るのかと思いましたが戻りませんでした。

追記(2023/5/29)

実行すると「Publishing changes from within view updates is not allowed, this will cause undefined behavior.」という警告が出ますね。

Webを調べたり色々変えて試したりしてみると、複数の問題があるようです。

  1. @Publishedでマークした変数をBinding<T>を取るような箇所に渡すと、Xcode 14以降では上記警告が出るということです。Xcode 13以前では出ないらしいです。
    今回のケースでは、MapcoorinateRegionが該当します。
    Binding<T>のところに@State/@Binding変数を渡すようにすると解消します。ただし、それだけだと@Publishedの値が書き換わっても、@State/@Bindingは書き換わらないので画面が更新されません。
    今回のケースだと、現在地が@Publishedに入ってきたとしても、それが@Stateに反映されないのでMapが書き換わらないという状態になります。
    このため@Published@State/@Bindingとは、.onChangeを使って同期すると良い、ということです。
    ちなみに.onChangeEquatableでないと監視できませんが、MKCoordinateRegionEquatableに準拠してないので、やるならextensionを使って準拠させる必要があります。
  2. MapcoordinateRegionにバインド変数を渡すと、とにかく警告が出るようです。
    @Publishedではなく@Stateを渡すようにしても、@Stateをプログラム側から更新したタイミングで「Modifying state during view update, this will cause undefined behavior.」という警告が出ます。
    DispatchQueueを使って非同期に更新してもダメでした。Donny Walsさんの記事にはMapの中で出てるんでしょうがない、みたいなことが書いてあります。

もともとMapを表示した段階で4回警告が出ますが、1を直すと2の警告が1回だけ出るようになります。
(修正版をコミットしました)
2の警告は現時点では利用者側ではどうしようもなさそうです。

参考

https://www.bokukoko.info/entry/2022/12/29/162535
https://dev.classmethod.jp/articles/swiftui-map-puple-warning/
https://www.donnywals.com/xcode-14-publishing-changes-from-within-view-updates-is-not-allowed-this-will-cause-undefined-behavior/

脚注
  1. 画面上の座標系で上下どちらがマイナスかプラスかにも依ります。今回の場合は結局足すのではなくて引きました ↩︎

Discussion