mcpField
A
axiom-mapkit

axiom-mapkit

github.com/charleswiltgen開発1.2k87 回インストール今日更新
リポジトリ

axiom-mapkit について

iOS開発者向けのMapKitスキルです。地図、注釈、検索、経路、パフォーマンスの実装パターンと選択指針を示し、よくある問題も扱います。

主な機能

  1. 01SwiftUI MapとMKMapViewの選択
  2. 02注釈のクラスタリングと表示範囲での絞り込み
  3. 03MapKit検索とオートコンプリートのパターン
  4. 04経路の計算と表示
  5. 05よくある問題と性能改善の指針

活用シーン

  1. 01iOSアプリの地図API選定
  2. 02大量の地点を負荷を抑えて地図に表示
  3. 03場所検索とオートコンプリートの追加
  4. 04経路とルート案内の表示

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-mapkit

インストール後、Claude Code で/axiom-mapkitを入力して読み込まれたか確認します。

詳細

MapKit のパターン

iOS アプリ向けの MapKit のパターンとアンチパターンです。よくある間違いを防ぎます。SwiftUI Map で十分な場面で MKMapView を使う、ビュー本体でアノテーションを作成する、setRegion のループ、アノテーション数が多い場合のパフォーマンス問題などです。

使用する場面

  • iOS アプリにマップを追加する
  • アノテーション、マーカー、カスタムピンを表示する
  • 検索(住所、POI、オートコンプリート)を実装する
  • マップに経路案内/ルーティングを追加する
  • マップの表示問題(アノテーションが表示されない、表示領域が飛ぶ)をデバッグする
  • アノテーションが多い場合のマップのパフォーマンスを最適化する
  • SwiftUI Map と MKMapView のどちらを使うか判断する

関連スキル

  • axiom-mapkit-ref — 完全な API リファレンス
  • axiom-mapkit-diag — 症状に基づくトラブルシューティング
  • axiom-core-location — 位置情報の許可と監視

パート 1:アンチパターン(時間コスト付き)

アンチパターン時間コスト対策
SwiftUI Map で十分な場面で MKMapView を使うUIViewRepresentable の定型コードに 2~4 時間標準的なマップ機能には SwiftUI の Map {} を使います(iOS 17 以降)
SwiftUI のビュー本体でアノテーションを作成する100 個以上で UI がフリーズし、更新のたびにビューが再作成されるアノテーションをモデルに移し、@State または @Observable を使います
アノテーションビューを再利用しない(MKMapView)アノテーションが 500 個を超えるとメモリ使用量が急増し、スクロールが遅くなるdequeueReusableAnnotationView(withIdentifier:for:)
ガードなしで updateUIView 内で setRegion を呼び出す無限ループ — 領域の変更で更新が発生し、更新によって領域が設定されるmapView.region != region でガードするか、フラグを使います
MapCameraPosition を無視する(SwiftUI)カメラをプログラムで制御できず、「ユーザーの位置に合わせる」が動作しないposition パラメーターを @State var cameraPosition にバインドします
メインスレッドで同期ジオコーディングを行うジオコーディングごとに UI が 1~3 秒フリーズするCLGeocoder().geocodeAddressString を async/await とともに使います
表示領域に合わせてアノテーションを絞り込まない1 万件のアノテーションを一度に読み込むmapView.annotations(in:) を使うか、表示領域で取得します
MKLocalSearch の resultTypes を無視する無関係な検索結果が返り、検索が遅くなる.resultTypes = [.pointOfInterest] または .address を設定して絞り込みます

パート 2:判断ツリー

判断ツリー 1:SwiftUI Map と MKMapView
digraph {
    "Need map in app?" [shape=diamond];
    "iOS 17+ target?" [shape=diamond];
    "Need custom tile overlay?" [shape=diamond];
    "Need fine-grained delegate control?" [shape=diamond];
    "Use SwiftUI Map" [shape=box];
    "Use MKMapView\nvia UIViewRepresentable" [shape=box];

    "Need map in app?" -> "iOS 17+ target?" [label="yes"];
    "iOS 17+ target?" -> "Need custom tile overlay?" [label="yes"];
    "iOS 17+ target?" -> "Use MKMapView\nvia UIViewRepresentable" [label="no"];
    "Need custom tile overlay?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
    "Need custom tile overlay?" -> "Need fine-grained delegate control?" [label="no"];
    "Need fine-grained delegate control?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
    "Need fine-grained delegate control?" -> "Use SwiftUI Map" [label="no"];
}
SwiftUI Map が適している場合(ほとんどのアプリ)
  • マーカーとアノテーションを備えた標準的なマップ
  • カメラをプログラムで制御する
  • ユーザーの位置を標準機能で表示する
  • 形状オーバーレイ(円、ポリゴン、ポリライン)
  • マップスタイルの選択(標準、イメージ、ハイブリッド)
  • 選択の処理
  • クラスタリング
MKMapView が必要な場合
  • カスタムタイルオーバーレイ(例:OpenStreetMap、カスタム画像)
  • デリゲートを細かく制御する(willBeginLoadingMap、didFinishLoadingMap)
  • SwiftUI の機能を超えるカスタムアノテーションビューのアニメーション
  • iOS 17 より前のデプロイメントターゲット
  • カスタム MKOverlayRenderer サブクラスを使った高度なオーバーレイ描画
判断ツリー 2:件数に応じたアノテーション戦略
Annotation count?
├─ < 100 → Use Marker/Annotation directly in Map {} content builder
│   Simple, declarative, no performance concern
│
├─ 100-1000 → Enable clustering
│   Set .clusteringIdentifier on annotation views
│   SwiftUI: Marker("", coordinate:).tag(id)
│   MKMapView: view.clusteringIdentifier = "poi"
│
└─ 1000+ → Server-side clustering or visible-region filtering
    Fetch only annotations within mapView.region
    Or pre-cluster on server, send cluster centroids
    MKMapView with view reuse is preferred for very large datasets
表示領域による絞り込み(SwiftUI)

表示中のマップ領域内にあるアノテーションのみを読み込みます。1 万件を超えるアノテーションを一度に読み込むのを防ぎます。

struct MapView: View {
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var visibleAnnotations: [Location] = []

    let allLocations: [Location]  // Full dataset

    var body: some View {
        Map(position: $cameraPosition) {
            ForEach(visibleAnnotations) { location in
                Marker(location.name, coordinate: location.coordinate)
            }
        }
        .onMapCameraChange(frequency: .onEnd) { context in
            visibleAnnotations = allLocations.filter { location in
                context.region.contains(location.coordinate)
            }
        }
    }
}

extension MKCoordinateRegion {
    func contains(_ coordinate: CLLocationCoordinate2D) -> Bool {
        let latRange = (center.latitude - span.latitudeDelta / 2)...(center.latitude + span.latitudeDelta / 2)
        let lngRange = (center.longitude - span.longitudeDelta / 2)...(center.longitude + span.longitudeDelta / 2)
        return latRange.contains(coordinate.latitude) && lngRange.contains(coordinate.longitude)
    }
}
クラスタリングが重要な理由

クラスタリングを行わず、アノテーションが 500 個ある場合:

  • マップが読めなくなります(ピンが完全に重なります)
  • アノテーションが増えるたびにスクロールやズームが遅くなります
  • アノテーション数に比例してメモリ使用量が増えます

クラスタリングを行う場合:

  • ユーザーには件数付きの意味のあるグループが表示されます
  • 表示されているクラスターマーカーのみが描画されます
  • タップして展開すると、個々のアノテーションが表示されます
判断ツリー 3:検索と経路案内
Search implementation:
├─ User types search query
│   └─ MKLocalSearchCompleter (real-time autocomplete)
│       Configure: resultTypes, region bias
│       └─ User selects result
│           └─ MKLocalSearch (full result with MKMapItem)
│               Use completion.title for MKLocalSearch.Request
│
└─ Programmatic search (e.g., "nearest gas station")
    └─ MKLocalSearch with naturalLanguageQuery
        Configure: resultTypes, region, pointOfInterestFilter

Directions implementation:
├─ MKDirections.Request
│   Set source (MKMapItem.forCurrentLocation()) and destination
│   Set transportType (.automobile, .walking, .transit)
│
└─ MKDirections.calculate()
    └─ MKRoute
        ├─ .polyline → Display as MapPolyline or MKPolylineRenderer
        ├─ .expectedTravelTime → Show ETA
        ├─ .distance → Show distance
        └─ .steps → Turn-by-turn instructions

パート 3:プレッシャーシナリオ

シナリオ 1:「MKMapView を UIViewRepresentable でラップする」

セットアップ:SwiftUI アプリに地図を追加します。開発者は UIKit プロジェクトで MKMapView を使い慣れています。

プレッシャー:「MKMapView はよく知っています。SwiftUI Map は初めてで、機能が限られているかもしれません。」

スキルがある場合の想定:判断ツリーを確認します。アプリで標準のマーカー、アノテーション、カメラ制御、ユーザーの位置情報、図形オーバーレイが必要な場合、SwiftUI Map ですべて対応できます。SwiftUI Map を使いましょう。

スキルがない場合のアンチパターン:UIViewRepresentable と Coordinator を使って MKMapView をラップする 200 行以上のコードを書き、状態を手動で橋渡しし、アノテーションビュー用のデリゲートメソッドを実装し、updateUIView の無限ループに悩まされます。しかし、コンテンツビルダーを使った Map {} の 20 行で済んだはずです。

時間的コスト:不要な定型コードに 2~4 時間、その後も保守負担が続きます。

確認事項:アプリが必要とする機能のうち、SwiftUI Map では提供できないものを具体的に挙げられますか?挙げられない場合は、SwiftUI Map を使いましょう。

シナリオ 2:「地図に 10,000 個のピンをすべて追加する」

セットアップ:アプリに 10,000 件の位置データが保存されています。プロダクトマネージャーは、ユーザーに地図上ですべての場所を見せたいと考えています。

プレッシャー:「ユーザーはすべての場所を見る必要があります。全部追加してください。」

スキルがある場合の想定:クラスタリングと表示領域によるフィルタリングを使います。クラスタリングなしで 10K 件のアノテーションを表示しても実用になりません。ピンが重なり、スクロールが遅くなり、メモリ使用量が急増します。クラスタリングを使えば意味のあるグループが表示されます。表示領域によるフィルタリングを使えば、画面に表示されている範囲だけを読み込めます。

スキルがない場合のアンチパターン:10,000 件のアノテーションを一度にすべて追加します。地図はピンが重なった判読不能な塊になります。スクロールの遅延でアプリが壊れているように感じられます。メモリ使用量は 200~400MB 急増します。

実装手順:

  1. クラスタリングを有効にする(.clusteringIdentifier)
  2. 表示領域内のアノテーションだけを取得する(.onMapCameraChange とクエリを使用)
  3. 可能であれば、5K 件を超えるデータセットにはサーバーサイドの事前クラスタリングを使う
シナリオ 3:「検索で結果が見つからない」

セットアップ:MKLocalSearch で無関係な結果が返るか、結果がまったく返りません。開発者は Google Maps SDK の追加を検討しています。

プレッシャー:「MapKit の検索は壊れています。サードパーティの SDK を追加しましょう。」

スキルがある場合の想定:まず設定を確認します。MapKit の検索では、次の設定が必要です。

  1. resultTypes — .pointOfInterest または .address に絞り込みます(デフォルトではすべてが返されます)
  2. region — 表示中の地図領域に基づいて結果を絞り込みます
  3. クエリ形式 — 「coffee shops」のような自然言語は使えますが、構造化クエリは使えません

スキルがない場合のアンチパターン:適切に設定すれば MapKit の検索が正しく動作するのに、Google Maps SDK を追加します(バイナリが 50MB 以上、API キーの管理、課金設定が必要です)。

時間的コスト:サードパーティ SDK の追加に 4~8 時間かかるのに対し、MapKit 検索の設定は 5 分で済みます。


パート 4:Core Location との統合

MapKit と Core Location は、開発者が驚くような形で連携します。

暗黙的な許可要求

MKMapView で showsUserLocation = true を設定するか、SwiftUI Map に UserAnnotation() を追加すると、MapKit は位置情報の許可がまだ要求されていない場合、暗黙的に要求します。

つまり、次のようになります。

  • 開発者が想定するタイミングではなく、地図の表示時に許可ダイアログが表示されます
  • 位置情報が必要な理由がわからないまま、ユーザーにダイアログが表示されます
  • 許可されなかった場合、青い点は何の通知もなく表示されません
推奨パターン

地図を表示する前に、明示的に許可を要求します。

// 1. Request authorization with context
let session = CLServiceSession(authorization: .whenInUse)

// 2. Then show map with user location
Map {
    UserAnnotation()
}
CLServiceSession(iOS 17 以降)

地図上で位置情報を継続的に表示するには、CLServiceSession を作成します。

@Observable
class MapModel {
    var cameraPosition: MapCameraPosition = .automatic
    private var locationSession: CLServiceSession?

    func startShowingUserLocation() {
        locationSession = CLServiceSession(authorization: .whenInUse)
    }

    func stopShowingUserLocation() {
        locationSession = nil
    }
}
関連情報

許可の判断ツリー、モニタリングのパターン、バックグラウンドでの位置情報については、以下を参照してください。

  • axiom-core-location — 許可の戦略、モニタリングの方法
  • axiom-core-location-diag — 「位置情報が動作しない」場合のトラブルシューティング
  • axiom-energy — バッテリーサブシステムとしての位置情報

パート 5:SwiftUI Map クイックスタート

最も一般的なパターンである、マーカーとユーザーの位置情報を表示する地図です。

struct ContentView: View {
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var selectedItem: MKMapItem?

    let locations: [Location]  // Your model

    var body: some View {
        Map(position: $cameraPosition, selection: $selectedItem) {
            UserAnnotation()

            ForEach(locations) { location in
                Marker(location.name, coordinate: location.coordinate)
                    .tint(location.category.color)
            }
        }
        .mapStyle(.standard(elevation: .realistic))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
        .onChange(of: selectedItem) { _, item in
            if let item {
                handleSelection(item)
            }
        }
    }
}
主なポイント
  • @State var cameraPosition — プログラムによるカメラ制御のバインディング
  • selection: $selectedItem — マーカーのタップを処理します
  • MapCameraPosition.automatic — 初期表示をシステムが管理します
  • .mapControls {} — 位置ボタン、コンパス、縮尺の組み込み UI
  • コンテンツビルダー内の ForEach — データから動的にアノテーションを生成します

パート 6:検索の実装パターン

オートコンプリートを含む検索:

@Observable
class SearchModel {
    var searchText = ""
    var completions: [MKLocalSearchCompletion] = []
    var searchResults: [MKMapItem] = []

    private let completer = MKLocalSearchCompleter()
    private var completerDelegate: CompleterDelegate?

    init() {
        completerDelegate = CompleterDelegate { [weak self] results in
            self?.completions = results
        }
        completer.delegate = completerDelegate
        completer.resultTypes = [.pointOfInterest, .address]
    }

    func updateSearch(_ text: String) {
        searchText = text
        completer.queryFragment = text
    }

    func search(for completion: MKLocalSearchCompletion) async throws {
        let request = MKLocalSearch.Request(completion: completion)
        request.resultTypes = [.pointOfInterest, .address]
        let search = MKLocalSearch(request: request)
        let response = try await search.start()
        searchResults = response.mapItems
    }

    func search(query: String, in region: MKCoordinateRegion) async throws {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = query
        request.region = region
        request.resultTypes = .pointOfInterest
        let search = MKLocalSearch(request: request)
        let response = try await search.start()
        searchResults = response.mapItems
    }
}
MKLocalSearchCompleter デリゲート(必須)
class CompleterDelegate: NSObject, MKLocalSearchCompleterDelegate {
    let onUpdate: ([MKLocalSearchCompletion]) -> Void

    init(onUpdate: @escaping ([MKLocalSearchCompletion]) -> Void) {
        self.onUpdate = onUpdate
    }

    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        onUpdate(completer.results)
    }

    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        // Handle error — network issues, rate limiting
    }
}
レート制限

Apple は MapKit の検索にレート制限を設けています。オートコンプリートの場合:

  • MKLocalSearchCompleter は内部で独自にスロットリングを処理します
  • キー入力ごとに新しいコンプレーターを作成せず、1 つのインスタンスを再利用してください
  • 各キー入力時に queryFragment を設定します。コンプレーターがデバウンス処理を行います

MKLocalSearch の場合:

  • キー入力ごとに検索を実行せず、オートコンプリートにはコンプレーターを使用してください
  • MKLocalSearch は、ユーザーが候補を選択するか、検索を送信したときにのみ実行してください

パート 7:経路案内の実装パターン

func calculateDirections(
    from source: CLLocationCoordinate2D,
    to destination: MKMapItem,
    transportType: MKDirectionsTransportType = .automobile
) async throws -> MKRoute {
    let request = MKDirections.Request()
    request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
    request.destination = destination
    request.transportType = transportType

    let directions = MKDirections(request: request)
    let response = try await directions.calculate()

    guard let route = response.routes.first else {
        throw MapError.noRouteFound
    }
    return route
}
経路の表示(SwiftUI)
Map(position: $cameraPosition) {
    if let route {
        MapPolyline(route.polyline)
            .stroke(.blue, lineWidth: 5)
    }

    Marker("Start", coordinate: startCoord)
    Marker("End", coordinate: endCoord)
}
経路の表示(MKMapView)
// Add overlay
mapView.addOverlay(route.polyline, level: .aboveRoads)

// Implement renderer delegate
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    if let polyline = overlay as? MKPolyline {
        let renderer = MKPolylineRenderer(polyline: polyline)
        renderer.strokeColor = .systemBlue
        renderer.lineWidth = 5
        return renderer
    }
    return MKOverlayRenderer(overlay: overlay)
}
経路情報
let route: MKRoute = ...
let travelTime = route.expectedTravelTime  // TimeInterval in seconds
let distance = route.distance              // CLLocationDistance in meters
let steps = route.steps                    // [MKRoute.Step]

for step in steps {
    print("\(step.instructions) — \(step.distance)m")
    // "Turn right on Main St — 450m"
}

パート 8:クラスタリングのパターン

SwiftUI(iOS 17 以降)
Map(position: $cameraPosition) {
    ForEach(locations) { location in
        Marker(location.name, coordinate: location.coordinate)
            .tag(location.id)
    }
    .mapItemClusteringIdentifier("locations")
}
MKMapView
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    if let cluster = annotation as? MKClusterAnnotation {
        let view = mapView.dequeueReusableAnnotationView(
            withIdentifier: "cluster",
            for: annotation
        ) as! MKMarkerAnnotationView
        view.markerTintColor = .systemBlue
        view.glyphText = "\(cluster.memberAnnotations.count)"
        return view
    }

    let view = mapView.dequeueReusableAnnotationView(
        withIdentifier: "pin",
        for: annotation
    ) as! MKMarkerAnnotationView
    view.clusteringIdentifier = "locations"
    view.markerTintColor = .systemRed
    return view
}
クラスタリングの要件
  1. クラスタリング対象のすべてのアノテーションビューで、同じ clusteringIdentifier を共有する必要があります
  2. アノテーションビュークラスを登録します:mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "pin")
  3. クラスタリングは、現在のズームレベルでアノテーションが実際に重なっている場合にのみ有効になります
  4. クラスタ化とクラスタ解除のアニメーションは、システムが自動的に管理します

パート 9:リリース前チェックリスト

  • マップが正しく読み込まれ、表示される
  • アノテーションが正しい座標に表示される(緯度と経度が入れ替わっていない)
  • 100 件以上のアノテーションでクラスタリングが機能する
  • 検索で関連性の高い結果が返される(resultTypes が設定されている)
  • カメラ位置をプログラムで制御できる
  • 多数のアノテーションがある状態でスクロール/ズームしてもメモリ使用量が安定している
  • ユーザーの位置が正しく表示される(表示前に認証を処理している)
  • 経路がポリラインオーバーレイとして描画される
  • マップがダークモードで動作する(マップスタイルは自動的に適応する)
  • アクセシビリティ:VoiceOver がマップ要素を読み上げる
  • setRegion/updateUIView の無限ループが発生しない(MKMapView を使用している場合)
  • MKLocalSearchCompleter が再利用される(キー入力ごとに再作成されない)
  • アノテーションビューが dequeueReusableAnnotationView を使って再利用される(MKMapView)
  • 表示前に Look Around の利用可否が確認される(MKLookAroundSceneRequest)

リソース

WWDC:2023-10043、2024-10094

ドキュメント:/mapkit、/mapkit/map

スキル:mapkit-ref、mapkit-diag、core-location

コメント