diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0023a53
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..dab239f
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,27 @@
+Copyright (c) 2023, Stadia Maps, Inc.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ * Neither the name of Stadia Maps nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..639201a
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,24 @@
+{
+ "originHash" : "7e0e0b4f9f6cf2f1eb0a555a1c8cbf395ed94d8d167b2cb16c044c92df7ced2d",
+ "pins" : [
+ {
+ "identity" : "anycodable",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/Flight-School/AnyCodable",
+ "state" : {
+ "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05",
+ "version" : "0.6.7"
+ }
+ },
+ {
+ "identity" : "stadiamaps-api-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/stadiamaps/stadiamaps-api-swift",
+ "state" : {
+ "revision" : "87d74dca9e79390e36a5909ee6be75f7f9e9dfac",
+ "version" : "4.0.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..a517766
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,36 @@
+// swift-tools-version: 5.10
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "StadiaMapsAutocompleteSearch",
+ platforms: [
+ .iOS(.v15),
+ .macOS(.v14),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, making them visible to other packages.
+ .library(
+ name: "StadiaMapsAutocompleteSearch",
+ targets: ["StadiaMapsAutocompleteSearch"]
+ ),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/stadiamaps/stadiamaps-api-swift", from: "4.0.0"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets can depend on other targets in this package and products from dependencies.
+ .target(
+ name: "StadiaMapsAutocompleteSearch",
+ dependencies: [
+ .product(name: "StadiaMaps", package: "stadiamaps-api-swift"),
+ ]
+ ),
+ .testTarget(
+ name: "StadiaMapsAutocompleteSearchTests",
+ dependencies: ["StadiaMapsAutocompleteSearch"]
+ ),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6b5f0fa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# Stadia Maps Autocomplete Search for SwiftUI
+
+This package lets you add geographic autocomplete search to a SwiftUI app.
+
+* Displays a search box and list which you can embed in other views
+* Provides a callback handler with the result details when users tap a result
+* Can bias search results to be nearby a specific location
+* Automatically localizes place names based on the user's device settings (where available)
+
+![Screenshot](screenshot.png)
+
+## Installation
+
+The Xcode UI changes frequently, but you can usually add packages to your project using an option in the File menu.
+Then, you'll need to paste in the repository URL to search: https://github.com/stadiamaps/swiftui-autocomplete-search.
+See https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app for the latest detailed
+instructions from Apple.
+
+## Getting an API key
+
+You will need an API key to use this view.
+
+You can create an API key for free
+[here](https://client.stadiamaps.com/signup/?utm_source=github&utm_campaign=sdk_readme&utm_content=swiftui_autocomplete_readme)
+(no credit card required).
+
+## Using the SwiftUI view
+
+```swift
+import StadiaMapsAutocompleteSearch
+let stadiaMapsAPIKey = "YOUR-API-KEY" // Replace with your API key
+
+// Somewhere in your view body....
+AutocompleteSearch(apiKey: stadiaMapsAPIKey, userLocation: userLocation.clLocation) { selection in
+ // Do something with the selection.
+ // For example, you might do something like this to start navigation in an app using Ferrostar.
+ Task {
+ do {
+ routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, waypoints: [Waypoint(coordinate: GeographicCoordinate(lat: selection.geometry.coordinates[1], lng: selection.geometry.coordinates[0]), kind: .break)])
+
+ try ferrostarCore.startNavigation(route: routes!.first!)
+ errorMessage = nil
+ } catch {
+ errorMessage = "Error: \(error)"
+ }
+ }
+}
+```
diff --git a/Sources/StadiaMapsAutocompleteSearch/AutocompleteSearch.swift b/Sources/StadiaMapsAutocompleteSearch/AutocompleteSearch.swift
new file mode 100644
index 0000000..08ced48
--- /dev/null
+++ b/Sources/StadiaMapsAutocompleteSearch/AutocompleteSearch.swift
@@ -0,0 +1,100 @@
+import CoreLocation
+import StadiaMaps
+import SwiftUI
+
+/// An autocomplete search view that searches for geographic locations as you type.
+public struct AutocompleteSearch: View {
+ @State private var searchText = ""
+ @State private var searchResults: [PeliasGeoJSONFeature] = []
+ @State private var isLoading = false
+
+ let userLocation: CLLocation?
+ let onResultSelected: ((PeliasGeoJSONFeature) -> Void)?
+
+ /// Creates an autocomplete geographic search view.
+ /// - Parameters:
+ /// - apiKey: Your Stadia Maps API key
+ /// - useEUEndpoint: Send requests to servers located in the European Union (may significantly degrade performance outside Europe)
+ /// - userLocation: If present, biases the search for results near a specific location and displays results with (straight-line) distances from this location
+ /// - onResultSelected: A callback invoked when a result is tapped in the list
+ public init(apiKey: String, useEUEndpoint: Bool = false, userLocation: CLLocation? = nil, onResultSelected: ((PeliasGeoJSONFeature) -> Void)? = nil) {
+ StadiaMapsAPI.customHeaders = ["Authorization": "Stadia-Auth \(apiKey)"]
+ if useEUEndpoint {
+ StadiaMapsAPI.basePath = "https://api-eu.stadiamaps.com"
+ }
+ self.userLocation = userLocation
+ self.onResultSelected = onResultSelected
+ }
+
+ public var body: some View {
+ // TODO: Language override?
+ // TODO: Min search length?
+ TextField("Search", text: $searchText)
+ .onChange(of: searchText) { query in
+ Task {
+ try await search(query: query, autocomplete: true)
+ }
+ }
+ .onSubmit {
+ Task {
+ try await search(query: searchText, autocomplete: false)
+ }
+ }
+
+ ZStack {
+ List {
+ ForEach(searchResults) { result in
+ SearchResult(feature: result, relativeTo: userLocation)
+ .contentShape(.rect)
+ .onTapGesture {
+ onResultSelected?(result)
+ }
+ }
+ }
+
+ if isLoading {
+ ProgressView()
+ }
+ }
+ }
+
+ private func search(query: String, autocomplete: Bool) async throws {
+ guard !query.isEmpty else {
+ searchResults = []
+ return
+ }
+
+ isLoading = true
+
+ defer {
+ self.isLoading = false
+ }
+
+ let result: PeliasResponse
+
+ if autocomplete {
+ result = try await GeocodingAPI.autocomplete(text: query, focusPointLat: userLocation?.coordinate.latitude, focusPointLon: userLocation?.coordinate.longitude)
+ } else {
+ result = try await GeocodingAPI.search(text: query, focusPointLat: userLocation?.coordinate.latitude, focusPointLon: userLocation?.coordinate.longitude)
+ }
+
+ // Only replace results if the text matches the current input
+ if query == searchText {
+ searchResults = result.features
+ }
+ }
+}
+
+// Set this to your own Stadia Maps API key.
+// Get an free key at client.stadiamaps.com.
+private let previewApiKey = "YOUR-API-KEY"
+
+#Preview {
+ if previewApiKey == "YOUR-API-KEY" {
+ Text("You need an API key for this to be very useful. Get one at client.stadiamaps.com.")
+ } else {
+ AutocompleteSearch(apiKey: previewApiKey) { selection in
+ print("Selected: \(selection)")
+ }
+ }
+}
diff --git a/Sources/StadiaMapsAutocompleteSearch/Extensions.swift b/Sources/StadiaMapsAutocompleteSearch/Extensions.swift
new file mode 100644
index 0000000..c1b8dec
--- /dev/null
+++ b/Sources/StadiaMapsAutocompleteSearch/Extensions.swift
@@ -0,0 +1,81 @@
+import Foundation
+import StadiaMaps
+import SwiftUI
+
+extension PeliasGeoJSONFeature: Identifiable {
+ public var id: String? {
+ properties?.gid
+ }
+}
+
+public extension PeliasGeoJSONFeature {
+ var subtitle: String? {
+ if let layer = properties?.layer {
+ switch layer {
+ case .venue, .address, .street, .neighbourhood, .postalcode, .macrohood:
+ return properties?.locality ?? properties?.region ?? properties?.country
+ case .country, .dependency, .disputed, .continent:
+ return properties?.continent
+ case .macroregion, .region:
+ return properties?.country
+ case .locality, .localadmin, .borough, .macrocounty, .county:
+ return properties?.region ?? properties?.country
+ case .coarse, .marinearea, .empire, .ocean:
+ return nil
+ }
+ } else {
+ return nil
+ }
+ }
+}
+
+extension PeliasLayer {
+ var iconImage: Image {
+ let imageName = switch self {
+ case .venue:
+ "building.2.crop.circle"
+ case .address:
+ "123.rectangle"
+ case .street:
+ "road.lanes"
+ case .country:
+ "globe.americas"
+ case .macroregion:
+ "globe.americas"
+ case .region:
+ "globe.americas"
+ case .macrocounty:
+ "mappin.and.ellipse"
+ case .county:
+ "mappin.and.ellipse"
+ case .locality:
+ "mappin.and.ellipse"
+ case .localadmin:
+ "mappin.and.ellipse"
+ case .borough:
+ "mappin.and.ellipse"
+ case .neighbourhood:
+ "mappin.and.ellipse"
+ case .postalcode:
+ "mappin.and.ellipse"
+ case .coarse:
+ "mappin.and.ellipse"
+ case .dependency:
+ "globe.americas"
+ case .macrohood:
+ "globe.americas"
+ case .marinearea:
+ "water.waves"
+ case .disputed:
+ "questionmark.circle"
+ case .empire:
+ "globe.americas"
+ case .continent:
+ "globe.americas"
+ case .ocean:
+ "water.waves"
+ }
+
+ return Image(systemName: imageName)
+ }
+}
diff --git a/Sources/StadiaMapsAutocompleteSearch/SearchResult.swift b/Sources/StadiaMapsAutocompleteSearch/SearchResult.swift
new file mode 100644
index 0000000..7a1a499
--- /dev/null
+++ b/Sources/StadiaMapsAutocompleteSearch/SearchResult.swift
@@ -0,0 +1,65 @@
+import CoreLocation
+import MapKit
+import StadiaMaps
+import SwiftUI
+
+struct SearchResult: View {
+ let feature: PeliasGeoJSONFeature
+ let relativeTo: CLLocation?
+ let formatter: MKDistanceFormatter
+
+ init(feature: PeliasGeoJSONFeature, relativeTo: CLLocation?, formatter: MKDistanceFormatter) {
+ self.feature = feature
+ self.relativeTo = relativeTo
+ self.formatter = formatter
+ }
+
+ /// Creates a search result view wtih a default MKDistanceFormatter
+ /// using the abbreviated unit style.
+ init(feature: PeliasGeoJSONFeature, relativeTo: CLLocation?) {
+ let formatter = MKDistanceFormatter()
+ formatter.unitStyle = .abbreviated
+
+ self.init(feature: feature, relativeTo: relativeTo, formatter: formatter)
+ }
+
+ var body: some View {
+ HStack(spacing: 8) {
+ feature.properties?.layer?.iconImage
+ .frame(width: 18)
+ VStack(alignment: .leading) {
+ Text(feature.properties?.name ?? "")
+ if let subtitle = feature.subtitle {
+ Text(subtitle)
+ .font(.caption)
+ }
+ }
+ if let relativeTo {
+ let distance = relativeTo.distance(from: CLLocation(latitude: feature.geometry.coordinates[1], longitude: feature.geometry.coordinates[0]))
+ Text(formatter.string(fromDistance: distance))
+ .font(.caption)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ }
+ }
+}
+
+#Preview("Plain result") {
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .address, name: "Test")), relativeTo: nil)
+}
+
+#Preview("Result with locality") {
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .address, name: "Test", locality: "Some City")), relativeTo: nil)
+}
+
+#Preview("Relative distance") {
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .address, name: "Test")), relativeTo: CLLocation(latitude: 0.25, longitude: 0.25))
+}
+
+#Preview("Multiple Results") {
+ List {
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .address, name: "Test")), relativeTo: CLLocation(latitude: 0.25, longitude: 0.25))
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .street, name: "Test")), relativeTo: CLLocation(latitude: 0.25, longitude: 0.25))
+ SearchResult(feature: PeliasGeoJSONFeature(type: .feature, geometry: GeoJSONPoint(type: .point, coordinates: [0, 0]), properties: PeliasGeoJSONProperties(layer: .venue, name: "Test")), relativeTo: CLLocation(latitude: 0.25, longitude: 0.25))
+ }
+}
diff --git a/Tests/StadiaMapsAutocompleteSearchTests/SwiftUIAutocompleteSearchTests.swift b/Tests/StadiaMapsAutocompleteSearchTests/SwiftUIAutocompleteSearchTests.swift
new file mode 100644
index 0000000..594c3fa
--- /dev/null
+++ b/Tests/StadiaMapsAutocompleteSearchTests/SwiftUIAutocompleteSearchTests.swift
@@ -0,0 +1,12 @@
+@testable import StadiaMapsAutocompleteSearch
+import XCTest
+
+final class StadiaMapsAutocompleteSearchTests: XCTestCase {
+ func testExample() throws {
+ // XCTest Documentation
+ // https://developer.apple.com/documentation/xctest
+
+ // Defining Test Cases and Test Methods
+ // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
+ }
+}
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..f59ff49
Binary files /dev/null and b/screenshot.png differ