[SwiftUI] 特定の位置情報から周辺の観光地情報を取得して表示する方法

# はじめに
特定の観光地の位置情報から周辺に存在する観光地の情報を取得して地図に表示させたいと思い調べたところ、SwiftUIとMapKitで簡単に実装できたので、実装方法と一緒にまとめました。

# 環境
- Xcode 16.2
- iOS 18

# 内容

### 作ったもの

1. 特定の位置情報（例：姫路城）を中心としたマップ表示
2. 周辺の観光スポット検索と表示
3. マップビューとリスト表示の切り替え
4. スポットの詳細情報の表示

<img width="200" alt="マップ表示" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/62dd44eb-38fa-4a9a-bb65-de69291889fb.png">
<img width="200" alt="リスト表示" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/bfa33d60-9a17-48eb-8ad5-f1927efeab62.png">


### 実装方法

#### 地図画面

- 画面表示のタイミングで周辺の観光地情報を取得
- 取得した観光情報をマップに表示
- 右上に地図とリストの切り替えボタンを配置

```swift
import SwiftUI
import MapKit

struct MapView: View {
    // 例：姫路城の座標
    let location: CLLocationCoordinate2D = CLLocationCoordinate2D(
        latitude: 34.8394,
        longitude: 134.6939
    )
    let span: MKCoordinateSpan = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)

    @State private var items: [MKMapItem] = []
    @State private var isShowingList = false

    var body: some View {
        Group {
            if isShowingList {
                // リスト表示は分けています
            } else {
                Map(position: .constant(MapCameraPosition.region(MKCoordinateRegion(
                    center: location,
                    span: span
                )))) {
                    // 例：姫路城の座標
                    Annotation(
                        "",
                        coordinate: location,
                        anchor: .bottom
                    ) {
                        Image(systemName: "mappin.and.ellipse")
                            .foregroundColor(.red)
                    }

                    // 検索結果の座標
                    ForEach(items, id: \.self) { item in
                        Annotation(
                            item.name ?? "",
                            coordinate: item.placemark.coordinate,
                            anchor: .bottom
                        ) {
                            Image(systemName: "mappin.and.ellipse")
                                .foregroundColor(.blue)
                                .onTapGesture {
                                    item.openInMaps()
                                }
                        }
                    }
                }
                .mapStyle(.standard(elevation: .realistic))
                .mapControls {
                    MapPitchToggle()
                }
            }
        }
        .task {
            // 表示時に検索実行
            do {
                items = try await SpotSearchManager.searchSpotDetails(
                    for: location
                )
            } catch {
                return
            }
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    isShowingList.toggle()
                } label: {
                    Image(systemName: isShowingList ? "map" : "list.dash")
                }
            }
        }
    }
}
```

#### 周辺観光地の検索

- 位置情報、タイプ、文字列で地図検索
  - [resultTypes](https://developer.apple.com/documentation/mapkit/mklocalsearchcompleter/resulttype)を指定して検索の精度を上げる
    - address: 検索補完の結果に住所を含める
    - pointOfInterest: 検索補完の結果に地図上の興味深い場所、例えば店舗やランドマークなどを含める
    - physicalFeature: 検索補完の結果に地形的特徴(山、川、湖などの自然の地形)を含める
    - query: 検索補完の結果に検索クエリを含める
  - 検索結果は[MKMapItem
](https://developer.apple.com/documentation/mapkit/mkmapitem)
    - MKMapItemは地理的な位置情報と、住所や事業所の名前などのその場所に関連する興味深いデータを保持
      - `name`: スポットの名前
      - `phoneNumber`: 電話番号
      - `url`: ウェブサイトURL
      - `placemark`: 位置情報（住所、座標など）
- 検索結果から位置情報ないものを除去
- 結果から距離順で並び替え


```swift
@MainActor
class SpotSearchManager {
    static func searchSpot(for coordinate: CLLocationCoordinate2D, radiusInMeters: Double = 1000) async throws -> [MKMapItem] {
        let request = MKLocalSearch.Request()

        // 検索の優先範囲を設定
        let region = MKCoordinateRegion(
            center: coordinate,
            latitudinalMeters: radiusInMeters,
            longitudinalMeters: radiusInMeters
        )
        request.region = region

        request.resultTypes = [.physicalFeature]
        request.naturalLanguageQuery = "観光"
        
        let search = MKLocalSearch(request: request)
        let response = try await search.start()
        let spots = response.mapItems.filter {
            $0.placemark.location != nil
        }

        // 結果を距離順にソート
        return spots.sorted { item1, item2 in
            let location1 = CLLocation(
                latitude: item1.placemark.coordinate.latitude,
                longitude: item1.placemark.coordinate.longitude
            )
            let location2 = CLLocation(
                latitude: item2.placemark.coordinate.latitude,
                longitude: item2.placemark.coordinate.longitude
            )
            let centerLocation = CLLocation(
                latitude: coordinate.latitude,
                longitude: coordinate.longitude
            )

            return location1.distance(from: centerLocation) < location2.distance(from: centerLocation)
        }
    }
}
```

#### リスト画面

- 取得した観光地情報のリスト表示
- 観光地情報の表示
- 観光地ウェブサイトのリンクボタン配置
- 純正Mapアプリのリンクボタン配置
  - [.openInMaps](https://developer.apple.com/documentation/mapkit/mkmapitem/openinmaps(launchoptions:from:completionhandler:))は、「マップ」Appを開き、特定のマップアイテムを表示するためのメソッド

```swift
List {
    ForEach(items, id: \.self) { item in
        VStack(alignment: .leading, spacing: 8) {
            Text(item.name ?? "名称なし")
                .font(.headline)

            if let phoneNumber = item.phoneNumber, !phoneNumber.isEmpty {
                HStack {
                    Image(systemName: "phone")
                    Text(phoneNumber)
                }
            }

            if let address = item.placemark.thoroughfare {
                HStack {
                    Image(systemName: "location")
                    Text("\(item.placemark.administrativeArea ?? "") \(item.placemark.locality ?? "") \(address)")
                }
            }

            if let url = item.url {
                Link(destination: url) {
                    HStack {
                        Image(systemName: "link")
                        Text("ウェブサイトを開く")
                    }
                    .foregroundColor(.blue)
                }
                .buttonStyle(.plain)
            }

            Button {
                item.openInMaps()
            } label: {
                HStack {
                    Image(systemName: "map")
                    Text("マップで開く")
                }
                .foregroundColor(.blue)
            }
            .buttonStyle(.plain)
        }
        .padding(.vertical, 8)
    }
}
```


# おわりに

シンプルながらも機能的な地図アプリを簡単に実装できました🎉
しかしながら、より細かい要求、例えば特定の観光地の値をID指定して取得したいなどは現状できないようでした。

最近公開されたこのドキュメントを読む限り、今後の機能追加も期待できそうだったので引き続きキャッチアップしていきたいと思います。

https://developer.apple.com/documentation/mapkit/unified-map-urls


# 参考

https://developer.apple.com/documentation/mapkit/mklocalsearch

https://developer.apple.com/documentation/mapkit/mklocalsearch/resulttype