Skip to main content

Mapbox と MapKit の連携

やりたいこと

#やりたいこと使う API
1Mapbox で地図とピンを描画Map, CircleLayer (Mapbox)
2ピンのタップ位置から地図情報を取得CoreLocation (Apple)
3タップ位置をハイライト(orピン表示)CircleAnnotation (Mapbox)
4経路探索MapKit MKDirections (Apple)
5経路の描画PolylineAnnotation (Mapbox)
  • 4: 経路探索は Mapbox にも API があるが、マップの課金と体系が違うので、いったん使用せず、Apple 標準の MapKit を使用する方針

ついでにやったこと

  • 詳細情報の表示は inspector() で出すようにした
    • iPad ならサイドパネル、iPhone なら下からニュッと出る
  • ピンのカスタマイズ
    • タップの範囲を広げたレイヤを上に重ねた
    • Google Map に似せて 影相当のレイヤを下に重ねた
    • これによって重くなった様子はない
  • ContentView.swift で閉じられるよう 1 ファイルにした
    • 作り込むときは @ViewBuilder で分けてる部分をファイルに分ける
  • ForEach で Annotation は対応していないので GeoJSON + Layer

感想

  • この範囲の実装なら Mapbox は SwiftUI 版で大丈夫そう。
  • MapKit のみで SwiftUI で試したときより、イベントが干渉なく拾いやすくて良い

続きでやりたいこと

  • ノウハウ的には、当面必要なことは ほぼ調べ終わった気がするので、続きは作り込み系
  • 実質、公開予定のアプリの中身を丸ごと出してるのと変わらないけど、まあいいか
続きでやりたいこと
  • 調べること
    • 検索機能
      • 表示されたピンのフィルタ
      • フィルタされたピンのリスト表示 (重そう)
    • 現在地の取得、現在地への移動
      • 経路を表示しながら現在地のピンを追従させるようにしたらなお良し
    • ピンのカスタマイズ (アイコンなど)
  • 作り込み系
    • Inspector に表示した画面の作り込み
      • あとは SwiftUI 的な見た目だけの話
      • 画像の表示とか
    • CloudKit で経路の保存
    • ピンを公園単位でなく遊具単位にする
      • Geojson は既にあるので VectorSource を切り替えるだけ
  • リファクタ系
    • Map { ... } の中身は @ViewBuilder で分けておく
    • 範囲外タップで選択解除したときの Inspector の表示などは、Detail 画面の UI 詳細詰めるときに再度考える

サンプル

コンパクトかつ試しやすいサンプルになったと思う (自画自賛)

swift

import SwiftUI
import MapboxMaps
import CoreLocation
import MapKit

struct ContentView: View {
@State private var selectedFeature: Feature? = nil
@State private var selectedPlacemark: CLPlacemark? = nil
@State private var isDetailViewPresented: Bool = false

@State private var routePoints: [(id: UUID, name: String, coord: CLLocationCoordinate2D)] = []

@State private var routeCoords: [CLLocationCoordinate2D] = []

var body: some View {
ZStack {
MapboxView()
}
.inspector(isPresented: $isDetailViewPresented) {
DetailView()
}
}

@ViewBuilder
func MapboxView() -> some View {
let center = CLLocationCoordinate2D(latitude: 35.6598, longitude: 139.702389)
let camera = Viewport.camera(center: center, zoom: 13, bearing: 0, pitch: 0)

// あらかじめ Mapbox Studio で作成済の Tileset
let parksTilesetUrl = "mapbox://takaaki024.99uywdyx"
let parksTilesetLayerId = "parks-9qd2dk"

MapboxMaps.MapReader { mapReader in // MapboxMaps. まで指定しているのは、MapKit との干渉を避けるため
Map(initialViewport: camera) {
VectorSource(id: "parks")
.url(parksTilesetUrl)

CircleLayer(id: "parksShadow", source: "parks")
.sourceLayer(parksTilesetLayerId)
.circleRadius(12)
.circleColor(UIColor.gray)
.circleBlur(0.5)

CircleLayer(id: "parksPoint", source: "parks")
.sourceLayer(parksTilesetLayerId)
.circleRadius(7)
.circleColor(UIColor.red)
.circleStrokeColor(UIColor.white)
.circleStrokeWidth(2)

CircleLayer(id: "parksTouchArea", source: "parks")
.sourceLayer(parksTilesetLayerId)
.circleRadius(15)
.circleColor(UIColor.white)
.circleOpacity(0.01)

if let coord = selectedCoord {
if selectedPlacemark != nil {
CircleAnnotation(centerCoordinate: coord)
.circleRadius(3)
.circleColor(.blue)
}

CircleAnnotation(centerCoordinate: coord)
.circleRadius(15)
.circleColor(.clear)
.circleStrokeColor(.blue)
.circleStrokeWidth(2)
}

// Route
PolylineAnnotation(lineCoordinates: routeCoords)
.lineColor(.green)
.lineWidth(8)
.lineBorderColor(.white)
.lineBorderWidth(1.5)

// Waypoints - not works ...
/*
ForEach(routePoints, id: \.id) { point in
CircleAnnotation(centerCoordinate: point.coord)
.circleRadius(5)
.circleColor(.green)
}
*/
}
.onMapTapGesture { context in
if selectedFeature != nil {
selectedFeature = nil
isDetailViewPresented = false
} else if selectedPlacemark != nil {
selectedPlacemark = nil
isDetailViewPresented = false
} else {
// ★ CoreLocation: タップ位置から位置情報(Placemark)を取得
let coord = context.coordinate
let geoCoder = CLGeocoder()
let location = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
geoCoder.reverseGeocodeLocation(location) { placemarks, error in
if let placemark = placemarks?.first {
print(placemark.name)
print(placemark.location?.coordinate)
selectedPlacemark = placemark
isDetailViewPresented = true
}
}
}
}
.onLayerTapGesture("parksTouchArea") { queriedFeature, context in
selectedPlacemark = nil
selectedFeature = queriedFeature.feature
isDetailViewPresented = true
return true
}
}

.ignoresSafeArea()
}

var selectedCoord: CLLocationCoordinate2D? {
if let feature = selectedFeature {
return feature.geometry?.point?.coordinates
}
if let placemark = selectedPlacemark {
return placemark.location?.coordinate
}
return nil
}

@ViewBuilder
func DetailView() -> some View {
VStack {
VStack {
if let feature = selectedFeature {
let name = feature.getString("name")
Text("公園情報")
Divider()
HStack {
Text("公園名")
Text(name ?? "-")
}
Button("経路に追加") {
guard let name = name, let coord = selectedCoord else {
return
}
routePoints.append((UUID(), name, coord))
}
.padding()
} else if let placemark = selectedPlacemark {
Text("所在地情報")
Divider()
HStack {
Text("地名")
VStack(alignment: .leading) {
Text(placemark.administrativeArea ?? "-")
Text(placemark.locality ?? "-")
Text(placemark.subLocality ?? "-")
Text(placemark.name ?? "-")
Text(placemark.subAdministrativeArea ?? "-")
Text(placemark.postalCode ?? "-")
}
}
Button("経路に追加") {
guard let name = placemark.name, let coord = selectedCoord else {
return
}
routePoints.append((UUID(), name, coord))
}
.padding()
} else {
Text("feature not selected")
}
Spacer()
}
VStack {
Divider()
Text("経路情報")
Divider()
List(routePoints, id: \.id) { point in
HStack {
Text(point.name)
Spacer()
Text("(\(point.coord.latitude), \(point.coord.longitude))")
}
}
Button("経路探索") {
searchRoute()
}
.padding()
.disabled(routePoints.count < 2)
Button("経路をクリア") {
routePoints = []
}
.padding()

}
}
}

private func searchRoute() {
routeCoords = []

Task {
for i in 1..<routePoints.count {
let src = MKMapItem(placemark: MKPlacemark(coordinate: routePoints[i-1].coord))
let dst = MKMapItem(placemark: MKPlacemark(coordinate: routePoints[i].coord))

let req = MKDirections.Request()
req.source = src
req.destination = dst
req.transportType = .walking

let directions = MKDirections(request: req)
let results = try await directions.calculate()
let routes = results.routes
if let route = routes.first {
routeCoords += route.polyline.coordinates

// for debug
print("route.distance: \(route.distance)")
print("route.expectedTravelTime: \(route.expectedTravelTime)")
}
try? await Task.sleep(nanoseconds: UInt64(100_000_000 * i)) // 0.1 sec
}
}
}
}

// ★ Feature から プロパティ取得用
extension Feature {
func getString(_ key: String) -> String? {
(properties?[key] as? JSONValue)?.rawValue as? String
}

func getBool(_ key: String) -> Bool? {
(properties?[key] as? JSONValue)?.rawValue as? Bool
}
}

// 変換用
extension MKPolyline {
var coordinates: [CLLocationCoordinate2D] {
var coords = [CLLocationCoordinate2D](repeating: kCLLocationCoordinate2DInvalid, count: self.pointCount)
self.getCoordinates(&coords, range: NSRange(location: 0, length: self.pointCount))
return coords
}
}