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