SwiftUI 2.0 で MapKit を使って地図アプリを作ってみる
はじめに
SwiftUI 2.0 では、地図を表示するためのビュー「Map」がMapKitに追加されました。以前は、地図を利用するにはUIViewRepresentable
を使う必要があったのでちょっと面倒だったのですが、これでとても楽になりました!
ということで、今回は SwiftUI 2.0 & MapKit を利用して、地図アプリを作ってみようと思います!
とりあえずの完成形
今回はこんな感じのものを作ってみたいと思います。
【SwiftUI 2.0】MapKit で現在地とAnnotaionを表示する
GitHubはこちらです github.com
ざっくり機能
- 位置情報の取得
- デフォルトではおおよその位置情報を取得する
- 正確な位置情報をリクエストする
- 地図を表示する
- 現在地ボタン
- タップしたら現在地を表示する
- クリアボタン
- タップしたらアノテーションをクリアする
開発環境
参考書
SwiftUIの勉強には、この本が特におすすめです!
位置情報の扱いなどはこちらを参考に
ViewModelを追加
Mapを表示するために必要な情報は、すべてViewModel
で定義します。以下のようなMapViewModel
を作成してみます。
import SwiftUI import MapKit final class MapViewModel: ObservableObject { // 初期表示の座標. @Published var region = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: 35.6812362, longitude: 139.7671248 ), latitudinalMeters: 10_000, longitudinalMeters: 10_000 ) @Published var trackingMode: MapUserTrackingMode = .follow }
Mapの表示位置を管理するregion
を定義しています。とりあえず東京駅の座標を指定していますが、ここはお好みで問題ありません。
今回はtrackingMode
にはあまり触れていませんが、ユーザの位置情報を追跡するかどうかのBool値です。
とりあえず地図を表示してみる
メインとなるViewをMapView
として定義し、その中で地図を表示してみます。
import SwiftUI import MapKit struct MapView: View { @StateObject private var viewModel = MapViewModel() var body: some View { Map( coordinateRegion: $viewModel.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $viewModel.trackingMode ) .ignoresSafeArea() } }
Mapkit
をインポートし、Map
を表示します。その際は@StateObject
として先程のMapViewModel
を定義し、そのプロパティをMap
に指定しています。
地図を表示するだけであれば、これだけで完成です。とても簡単ですね!
おおよその現在地を表示するための準備
続いて、おおよその現在地を表示してみます。iOSアプリでは「正確な現在地」と「おおよその現在地」を取得することができますが、今回はデフォルトを「おおよその現在地」とします。
Info.plist
に以下のように項目を追加します。
CLLocationManagerを用意する
位置情報を取得するためのCLLocationManager
を利用するため、MapViewModel
を以下のように改修します。
// NSObject を継承する. final class MapViewModel: NSObject, ObservableObject { // CLLocationManagerを生成して保持. private let manager = CLLocationManager() // ~省略~ // イニシャライザで自身をデリゲートに設定する. override init() { super.init() manager.delegate = self } } // CLLocationManagerDelegateを実装する extension MapViewModel: CLLocationManagerDelegate { // 位置情報関連の権限に変更があったら呼び出される. func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { print(#function) } }
位置情報の権限をリクエスト
続いて、位置情報の利用権限をリクエストするため、MapViewModel
のデリゲートメソッド部分を以下のように改修します。
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { print(#function) if manager.authorizationStatus == .authorizedWhenInUse { print(#function, "権限があるので位置情報をリクエスト.") manager.startUpdatingLocation() } else { print(#function, "権限がないので権限をリクエスト.") manager.requestWhenInUseAuthorization() } }
位置情報の権限を確認し、権限があれば位置情報のリクエスト、なければ権限のリクエストを行っています。
現在地の取得と利用
MapViewModel
に、位置情報を取得した際に呼び出されるデリゲートメソッドを追加します。
// 位置情報が更新されたら呼び出される. func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { print(#function) manager.stopUpdatingLocation() guard let location = locations.first else { return } // 取得した位置情報を reigon.center に与える. withAnimation { region.center = location.coordinate } }
位置情報を取得したら、位置情報のリクエストを一旦停止し、受け取った位置情報をregion.center
に与えています。これで、おおよその位置情報を取得し、地図に反映するようになりました。
正確な位置情報を利用するための準備
正確な位置情報を利用したい場合には、その権限のリクエストを行う必要があります。まずはInfo.plist
に「正確な位置情報を利用する理由」を追加します。
理由は複数設定可能になっているため、key & value で定義します。
正確な位置情報をリクエストする
Info.plist
に理由を追加したら、MapViewModel
のデリゲートメソッド内で、正確な位置情報の利用権限をリクエストします。権限はaccuracyAuthorization
で確認を行うことができます。
リクエストの際には、先程定義した key & value の key の値を指定しています。
if manager.authorizationStatus == .authorizedWhenInUse { print(#function, "権限があるので位置情報をリクエスト.") // 正確な位置情報を利用する権限があるかどうか. if manager.accuracyAuthorization != .fullAccuracy { // 正確な位置情報をリクエスト. manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "full_accuracy_message") } manager.startUpdatingLocation() } else { // ~省略~
これで、正確な位置情報をリクエストするようになりました。
現在地をリクエストするメソッド
地図を自由に操作した後、表示位置を現在地に戻すための機能を追加します。まずはMapViewModel
に現在地の取得をリクエストするためのメソッドを追加します。
/// 位置情報のリクエスト. func requestUserLocation() { manager.startUpdatingLocation() }
これで位置情報を取得すれば、既に実装しているlocationManager(_ : didUpdateLocations:)
が呼び出されます。
現在地ボタンを追加する
では、このメソッドを呼び出すためのボタンを画面上に追加します。
var body: some View { ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { Map( ... ).ignoresSafeArea() // ~省略~ // ボタン領域. HStack(spacing: 24.0) { // 位置情報リクエストボタン. Button(action: { viewModel.requestUserLocation() }, label: { Image(systemName: "dot.circle.and.cursorarrow") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0) }) } .padding(24.0) } }
全体をZStack
で囲い、前面の右下にボタン領域を表示しています。ボタンをタップしたら、先程用意したメソッドを呼び出すようにしています。
これで、ボタンをタップすれば現在地が中央に表示されるようになりました。
位置情報を管理する構造体を追加
位置情報を保持するための構造体を追加します。Identifiable
に準拠させます。(初期値を適当に与えていますが、初期化を楽にするためです)
import SwiftUI import MapKit struct MapItem: Identifiable { var id = UUID().uuidString var coordinate = CLLocationCoordinate2D() var color = Color.red }
画面の表示位置を記録していく
地図の表示位置が変化するたびに、その位置情報を取得し、MapItem
の配列として保持していきます。
import Combine // 追加 final class MapViewModel: NSObject, ObservableObject {
まずはMapViewModel
に非同期処理を行うためのimport Combine
を追加します。
// 以下のプロパティを追加 @Published var items: [MapItem] = [] private var cancellable = Set<AnyCancellable>() override init() { super.init() manager.delegate = self // 位置情報の変化を監視する $region .debounce(for: 0.5, scheduler: DispatchQueue.main) .sink { [weak self] region in // 位置情報が変化したら、その位置情報からMapItemを生成して保持する. let annotation = MapItem(coordinate: region.center, color: .blue) self?.items.append(annotation) } .store(in: &cancellable) }
MapViewModel
のイニシャライザに、region
の変化を監視する処理を追加します。region
が変化したら、その座標情報を取得し、MapItem
を生成しています。生成したMapItem
は配列として保存していきます。
変化した値を全て取得すると大変なことになるので、debounce
を利用して0.5秒間のインターバルを設けています。
取得した位置情報をポインティング
Map
のイニシャライザを修正し、地図上にポインティングできるようにしています。ここではまずMapMaker
を利用しています。
Map( coordinateRegion: $viewModel.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $viewModel.trackingMode, annotationItems: viewModel.items ) { item in MapMarker(coordinate: item.coordinate, tint: item.color) } .ignoresSafeArea()
これで、地図を移動するたびにマーカーが表示されるようになりましたね!
任意のマーカービューを作成
MapMaker
は初めから用意されているマーカーですが、任意のViewを利用することも可能です。まずはマーカー用のViewを作成してみます。
import SwiftUI struct MapAnnotationView: View { var item: MapItem var body: some View { Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0) .foregroundColor(item.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } }
特になんの変哲もない普通のViewです。MapItem
を受け取るようにしています。
任意のViewをマップ上に表示する
では、地図上にMapMaker
の代わりに独自のMapAnnotationView
を配置してみます。
Map( // ~省略~ ) { item in MapAnnotation( coordinate: item.coordinate, anchorPoint: CGPoint(x: 0.5, y: 1.0) ) { MapAnnotationView(item: item) } } .ignoresSafeArea()
MapMaker
の代わりにMapAnnotation
を指定しています。位置情報はこのイニシャライザに渡します。(anchorPoint
は、ビューのどのポイントを位置情報に合わせるか、を指定するものです。)表示したいビューは、このクロージャに指定します。
これで、表示されるマーカーが独自のViewになりました。
マーカーの削除
MapViewModel
にitems
を初期化するメソッドを追加し、画面上にそのメソッドを呼び出すボタンを配置します。
/// MapItemをクリア. func removeItems() { items.removeAll() }
MapView
にはボタンを追加し、上記のメソッドを呼び出します。
HStack(spacing: 24.0) { // MapItemのクリアボタン. Button(action: { viewModel.removeItems() }, label: { Image(systemName: "mappin.slash") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0, height: 44.0) }) // 位置情報リクエストボタン. // ~省略~ } .padding(24.0)
これで画面上に表示されているマーカーを削除することができます。
詳細画面のビューを追加
マーカータップ時に表示される詳細画面のビューを作成します。背景を半透明のビューにし、その中央にマーカーと同じ画像を少し大きめに表示します。matchedGeometryEffect
を利用することで、heroアニメーションを実現しています。
import SwiftUI import MapKit struct DetailView: View { @Binding var mapItem: MapItem @Binding var showModal: Bool var namespace: Namespace.ID var body: some View { ZStack { // 背景色 Color.black.opacity(0.2).ignoresSafeArea() // MapItemの画像. Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) .matchedGeometryEffect(id: mapItem.id, in: namespace) .frame(width: UIScreen.main.bounds.width - 100.0) .foregroundColor(mapItem.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } // タップしたら閉じる. .onTapGesture { withAnimation { showModal.toggle() mapItem = MapItem() } } } }
MapAnnotationViewにNamespaceを渡す
MapAnnotationView
にもNamespace
を渡せるように修正します。アニメーションのIDには、MapItemが持つid
を利用しています。
struct MapAnnotationView: View { var item: MapItem var namespace: Namespace.ID // プロパティを追加する var body: some View { Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) // アニメーションのために追加する. .matchedGeometryEffect(id: item.id, in: namespace) .frame(width: 44.0) .foregroundColor(item.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } }
MapView
でMapAnnotationView
を呼び出している箇所にも修正が必要です。まずはMapView
にもNamespace
を定義します。
@StateObject private var viewModel = MapViewModel() @Namespace var namespace // 追加
MapAnnotationView
のイニシャライザにnamespace
を渡します。
MapAnnotation( coordinate: item.coordinate, anchorPoint: CGPoint(x: 0.5, y: 1.0) ) { MapAnnotationView(item: item, namespace: namespace) // ここを修正 }
マーカーをタップ可能にする
地図上のマーカーをタップ可能にし、タップされたマーカーの情報を保持するように修正します。MapView
に、以下のようにプロパティを追加します。
@State private var showModal = false @State private var selectedItem = MapItem()
また、マーカーにonTapGesture
を追加し、タップされたマーカー情報を保持します。
MapAnnotationView(item: item, namespace: namespace) .onTapGesture { withAnimation { showModal.toggle() // フラグ反転 selectedItem = item // 選択されたマーカーを保持. } }
詳細ビューを表示する
フラグshowModal
の値を判断し、詳細ビューを表示します。
ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { Map(...) // ~省略~ .ignoresSafeArea() HStack(spacing: 24.0) { // ~省略~ } .padding(24.0) // アノテーションが選択されたら、詳細画面を前面に表示する. if showModal { DetailView( mapItem: $selectedItem, showModal: $showModal, namespace: namespace ) } }
これで、タップされたアイテムが詳細ビューとして表示されるようになりました!
まとめ
SwiftUI 2.0 で用意されたMap
は、MKMapView
とはちょっと使い方は異なりますが、位置情報の取得などの階層についてはこれまで通りの処理となります。
地図の表示のやり方、マーカーの使い方など、こちらを参考にやってみてください!