SH Lab の アプリ開発部屋

リリースしたアプリの告知とかサポートとか、技術的なお話とか、そんな感じでつぶやきます。

SwiftUI 2.0 で MapKit を使って地図アプリを作ってみる

はじめに

SwiftUI 2.0 では、地図を表示するためのビュー「Map」がMapKitに追加されました。以前は、地図を利用するにはUIViewRepresentableを使う必要があったのでちょっと面倒だったのですが、これでとても楽になりました!

ということで、今回は SwiftUI 2.0 & MapKit を利用して、地図アプリを作ってみようと思います!

とりあえずの完成形

今回はこんな感じのものを作ってみたいと思います。


【SwiftUI 2.0】MapKit で現在地とAnnotaionを表示する

GitHubはこちらです github.com

ざっくり機能

  • 位置情報の取得
    • デフォルトではおおよその位置情報を取得する
    • 正確な位置情報をリクエストする
  • 地図を表示する
  • 現在地ボタン
    • タップしたら現在地を表示する
  • クリアボタン

開発環境

参考書

SwiftUIの勉強には、この本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ 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に指定しています。

地図を表示するだけであれば、これだけで完成です。とても簡単ですね!

f:id:hoshi0523:20201206115928p:plain

おおよその現在地を表示するための準備

続いて、おおよその現在地を表示してみます。iOSアプリでは「正確な現在地」と「おおよその現在地」を取得することができますが、今回はデフォルトを「おおよその現在地」とします。

Info.plistに以下のように項目を追加します。

f:id:hoshi0523:20201206120855p:plain

f:id:hoshi0523:20201206120908p:plain

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に与えています。これで、おおよその位置情報を取得し、地図に反映するようになりました。

f:id:hoshi0523:20201206122138p:plain

正確な位置情報を利用するための準備

正確な位置情報を利用したい場合には、その権限のリクエストを行う必要があります。まずはInfo.plistに「正確な位置情報を利用する理由」を追加します。

f:id:hoshi0523:20201206122924p:plain

理由は複数設定可能になっているため、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 {
    // ~省略~

これで、正確な位置情報をリクエストするようになりました。

f:id:hoshi0523:20201206123701p:plain

現在地をリクエストするメソッド

地図を自由に操作した後、表示位置を現在地に戻すための機能を追加します。まずは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で囲い、前面の右下にボタン領域を表示しています。ボタンをタップしたら、先程用意したメソッドを呼び出すようにしています。

これで、ボタンをタップすれば現在地が中央に表示されるようになりました。

f:id:hoshi0523:20201206125343g:plain

位置情報を管理する構造体を追加

位置情報を保持するための構造体を追加します。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()

f:id:hoshi0523:20201206131045g:plain

これで、地図を移動するたびにマーカーが表示されるようになりましたね!

任意のマーカービューを作成

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になりました。

f:id:hoshi0523:20201206132140g:plain

マーカーの削除

MapViewModelitemsを初期化するメソッドを追加し、画面上にそのメソッドを呼び出すボタンを配置します。

/// 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)
    }
}

MapViewMapAnnotationViewを呼び出している箇所にも修正が必要です。まずは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
        )
    }
}

これで、タップされたアイテムが詳細ビューとして表示されるようになりました!

f:id:hoshi0523:20201206134327g:plain

まとめ

SwiftUI 2.0 で用意されたMapは、MKMapViewとはちょっと使い方は異なりますが、位置情報の取得などの階層についてはこれまで通りの処理となります。

地図の表示のやり方、マーカーの使い方など、こちらを参考にやってみてください!