SwiftUI: Mapでインタラクティブにピンを立てる
SwiftUIではMapKit
のMKMapView
を使うのではなく、Map
というビューがあって手軽に地図を表示できます。
<本記事の環境>
- Xcode 14.3
- Swift 5.8
- macOS 13.3.1(a)
- iOS 16.4.1(a)
ピン(or コメント・注釈)を立てたいが…
やりたいことは「ユーザがドラッグでインタラクティブに場所を指定して、そこにピンを立てられるようにする」です。
Webで調べましたが、座標を指定してピンを立てるという例はあるものの、インタラクティブにユーザに場所を指定させるという例がほとんどありません。
このため調査がてらサンプルを作成しました。
ピン座標の指定方法
指定方法を2パターン考えました。
- ピンをドラッグする。ピンを操作するモード、マップを操作するモードを分ける(ボタンで切り替え)
- マップ中心に十字を表示し、マップをずらして十字の中心にピンを立てる。
最初は「マップ上でタップ長押し」とかも考えて少し実装してみたりもしたのですが、拡大・縮小や移動を指でやるので、モードの切り替えまでマップ上の指操作だと非常に操作性が悪くなりやめました。
長押しの時間とかよほどうまく設定しないと、なかなか思い通りの操作になりません。実際、Google Mapでも移動のつもりがタップされてしまい元々表示していたピンを見失ったりして、いらつくことがあります。
サンプルアプリの画面イメージは以下の通りです。
説明:ピンドラッグモード版
ピンをドラッグするモードなのか、マップをドラッグするモードなのかでモードを分けます。
ピンドラッグモードには、ボタンを押して入ります。
サンプルではピンは1本だけで、ドラッグが終わるとドラッグモードも勝手に終了し、マップ操作モードに戻ります。
Map
にはMKMapView
にある、画面の座標を地図上の座標に変換するconvert
がないので、自前で計算します。地図の中心の座標と、地図の縦横の幅はバインド変数(coordinateRegion
)に入ってくるので、それらを用います。
-
GeometryReader
で、ビューのサイズその他を取ってきます。 - ドラッグハンドラ内で、ドラッグ中の座標と1のサイズその他を用いて、相対座標に変換します。
ここで言う相対座標とは、そのビュー内における中心を(0,0)とし、x,yともに-0.5〜+0.5で表したものになります。 -
Map
のcoordinateRegion.span.latitudeDelta
には、地図の縦横の緯度での幅が入っています。coordinateRegion.center.latitude
に、相対座標y ×coordinateRegion.span.latitudeDelta
を足すと、ドラッグしている画面上の座標を地図上の緯度に換算したものが求められます。[1] -
longitude
も同様に求めます。
いったんピンの座標が求められたら、ピンがドロップされた、ということにして、Map
にMapAnnotation
を追加します。
この追加したMapAnnotation
の座標はドラッグ中ずっと更新されます。
最後、ドラッグを終了すると.onEnded
が呼ばれるので、そこでピンドラッグモードを終了します。
ピンドラッグモード中は、Map
のinteractionModes
を空にします。マップ操作モードになったら.all
に戻します。
説明:十字版
こっちはピンドラッグモード版に比べると非常に簡単です。
十字をZStack
でマップの中心に表示します。サンプルではRectangle
を2つ、幅1で置いています。
ピンを立てるボタンを押したら、coordinateRegion.center
を取得して、MapAnnotation
を追加します。
サンプルコード
GitHubに置きました。
ちなみに
サンプルを動かしていて気付いたのですがこのMap
、自分のロケーションをダブルタップすると、勝手に追跡モードになります。
もう1回ダブルタップしたら戻るのかと思いましたが戻りませんでした。
追記(2023/5/29)
実行すると「Publishing changes from within view updates is not allowed, this will cause undefined behavior.」という警告が出ますね。
Webを調べたり色々変えて試したりしてみると、複数の問題があるようです。
-
@Published
でマークした変数をBinding<T>
を取るような箇所に渡すと、Xcode 14以降では上記警告が出るということです。Xcode 13以前では出ないらしいです。
今回のケースでは、Map
のcoorinateRegion
が該当します。
Binding<T>
のところに@State
/@Binding
変数を渡すようにすると解消します。ただし、それだけだと@Published
の値が書き換わっても、@State
/@Binding
は書き換わらないので画面が更新されません。
今回のケースだと、現在地が@Published
に入ってきたとしても、それが@State
に反映されないのでMap
が書き換わらないという状態になります。
このため@Published
と@State
/@Binding
とは、.onChange
を使って同期すると良い、ということです。
ちなみに.onChange
はEquatable
でないと監視できませんが、MKCoordinateRegion
はEquatable
に準拠してないので、やるならextension
を使って準拠させる必要があります。 -
Map
のcoordinateRegion
にバインド変数を渡すと、とにかく警告が出るようです。
@Published
ではなく@State
を渡すようにしても、@State
をプログラム側から更新したタイミングで「Modifying state during view update, this will cause undefined behavior.」という警告が出ます。
DispatchQueue
を使って非同期に更新してもダメでした。Donny Walsさんの記事にはMap
の中で出てるんでしょうがない、みたいなことが書いてあります。
もともとMap
を表示した段階で4回警告が出ますが、1を直すと2の警告が1回だけ出るようになります。
(修正版をコミットしました)
2の警告は現時点では利用者側ではどうしようもなさそうです。
参考
-
画面上の座標系で上下どちらがマイナスかプラスかにも依ります。今回の場合は結局足すのではなくて引きました ↩︎
Discussion