axiom-mapkit について
iOS開発者向けのMapKitスキルです。地図、注釈、検索、経路、パフォーマンスの実装パターンと選択指針を示し、よくある問題も扱います。
主な機能
- 01SwiftUI MapとMKMapViewの選択
- 02注釈のクラスタリングと表示範囲での絞り込み
- 03MapKit検索とオートコンプリートのパターン
- 04経路の計算と表示
- 05よくある問題と性能改善の指針
活用シーン
- 01iOSアプリの地図API選定
- 02大量の地点を負荷を抑えて地図に表示
- 03場所検索とオートコンプリートの追加
- 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 急増します。
実装手順:
- クラスタリングを有効にする(
.clusteringIdentifier) - 表示領域内のアノテーションだけを取得する(
.onMapCameraChangeとクエリを使用) - 可能であれば、5K 件を超えるデータセットにはサーバーサイドの事前クラスタリングを使う
シナリオ 3:「検索で結果が見つからない」
セットアップ:MKLocalSearch で無関係な結果が返るか、結果がまったく返りません。開発者は Google Maps SDK の追加を検討しています。
プレッシャー:「MapKit の検索は壊れています。サードパーティの SDK を追加しましょう。」
スキルがある場合の想定:まず設定を確認します。MapKit の検索では、次の設定が必要です。
resultTypes—.pointOfInterestまたは.addressに絞り込みます(デフォルトではすべてが返されます)region— 表示中の地図領域に基づいて結果を絞り込みます- クエリ形式 — 「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
}
クラスタリングの要件
- クラスタリング対象のすべてのアノテーションビューで、同じ
clusteringIdentifierを共有する必要があります - アノテーションビュークラスを登録します:
mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "pin") - クラスタリングは、現在のズームレベルでアノテーションが実際に重なっている場合にのみ有効になります
- クラスタ化とクラスタ解除のアニメーションは、システムが自動的に管理します
パート 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