Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MKLocalSearch to the Location pane #180

Merged
merged 5 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions ControlRoom.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
551F8CED23F489A50006D1BD /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CEC23F489A50006D1BD /* SidebarView.swift */; };
551F8CEF23F48B030006D1BD /* SplitLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CEE23F48B030006D1BD /* SplitLayoutView.swift */; };
551F8CF123F498C30006D1BD /* SimulatorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF023F498C30006D1BD /* SimulatorsController.swift */; };
551F8CF523F4AF7B0006D1BD /* FilterField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF423F4AF7B0006D1BD /* FilterField.swift */; };
551F8CF523F4AF7B0006D1BD /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF423F4AF7B0006D1BD /* SearchField.swift */; };
551F8CF723F4BEAC0006D1BD /* TypeIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF623F4BEAC0006D1BD /* TypeIdentifier.swift */; };
551F8CF923F4C45A0006D1BD /* SimulatorSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF823F4C45A0006D1BD /* SimulatorSidebarView.swift */; };
551F8CFE23F5C9EF0006D1BD /* SimCtl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CFD23F5C9EF0006D1BD /* SimCtl.swift */; };
Expand Down Expand Up @@ -77,6 +77,9 @@
ACDF076823F7E91A00597B3B /* ApplicationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDF076723F7E91A00597B3B /* ApplicationType.swift */; };
B07F584923F99A1700256D5D /* SimCtl+SubCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B07F584823F99A1700256D5D /* SimCtl+SubCommands.swift */; };
B07F585123F9F83800256D5D /* SimCtl+SubCommandsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B07F585023F9F83800256D5D /* SimCtl+SubCommandsTests.swift */; };
C9C203E42B1788270081E1EF /* LocalSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203E32B1788270081E1EF /* LocalSearchController.swift */; };
C9C203E62B1788C90081E1EF /* LocalSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */; };
C9C203ED2B17FEA80081E1EF /* LocalSearchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */; };
E04B47322B07B66F00DAE338 /* LocationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47312B07B66F00DAE338 /* LocationsController.swift */; };
E04B47342B07B72C00DAE338 /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47332B07B72C00DAE338 /* Location.swift */; };
/* End PBXBuildFile section */
Expand Down Expand Up @@ -135,7 +138,7 @@
551F8CEC23F489A50006D1BD /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
551F8CEE23F48B030006D1BD /* SplitLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitLayoutView.swift; sourceTree = "<group>"; };
551F8CF023F498C30006D1BD /* SimulatorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorsController.swift; sourceTree = "<group>"; };
551F8CF423F4AF7B0006D1BD /* FilterField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterField.swift; sourceTree = "<group>"; };
551F8CF423F4AF7B0006D1BD /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = "<group>"; };
551F8CF623F4BEAC0006D1BD /* TypeIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeIdentifier.swift; sourceTree = "<group>"; };
551F8CF823F4C45A0006D1BD /* SimulatorSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorSidebarView.swift; sourceTree = "<group>"; };
551F8CFD23F5C9EF0006D1BD /* SimCtl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimCtl.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -167,6 +170,9 @@
B07F584E23F9F83800256D5D /* ControlRoomTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ControlRoomTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B07F585023F9F83800256D5D /* SimCtl+SubCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SimCtl+SubCommandsTests.swift"; sourceTree = "<group>"; };
B07F585223F9F83800256D5D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C9C203E32B1788270081E1EF /* LocalSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchController.swift; sourceTree = "<group>"; };
C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchResult.swift; sourceTree = "<group>"; };
C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchRowView.swift; sourceTree = "<group>"; };
E04B47312B07B66F00DAE338 /* LocationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsController.swift; sourceTree = "<group>"; };
E04B47332B07B72C00DAE338 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -238,20 +244,20 @@
511BA5AB23F434B500E3E660 /* ControlScreens */ = {
isa = PBXGroup;
children = (
51AB56E12A13D3D1002B5A67 /* SystemView */,
51B0241B25C3103B0042394E /* AppView */,
70BE435923F54B7200FD6282 /* LocationView.swift */,
51AB56E82A141055002B5A67 /* ColorsView.swift */,
C9C203EB2B17FE900081E1EF /* LocationVIew */,
519732492A0863B400B1F68C /* OverridesView.swift */,
511BA59F23F4197200E3E660 /* StatusBarView.swift */,
51AB56E82A141055002B5A67 /* ColorsView.swift */,
51AB56E12A13D3D1002B5A67 /* SystemView */,
);
path = ControlScreens;
sourceTree = "<group>";
};
511BA5CF23F455D500E3E660 /* NSViewWrappers */ = {
isa = PBXGroup;
children = (
551F8CF423F4AF7B0006D1BD /* FilterField.swift */,
551F8CF423F4AF7B0006D1BD /* SearchField.swift */,
);
path = NSViewWrappers;
sourceTree = "<group>";
Expand Down Expand Up @@ -281,6 +287,7 @@
51AB56EC2A143B90002B5A67 /* Double-Rounding.swift */,
41C44ACC2616FCB50016B1E4 /* FFMPEGConverter.swift */,
5534158523FE1AC4005C0A41 /* Flow.swift */,
C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */,
E04B47332B07B72C00DAE338 /* Location.swift */,
51AB56E62A140D53002B5A67 /* NSColor-Conversions.swift */,
51AB56EE2A143BC0002B5A67 /* PickedColor.swift */,
Expand Down Expand Up @@ -385,13 +392,14 @@
555A146323F762AD00313BC5 /* Controllers */ = {
isa = PBXGroup;
children = (
51AB56DA2A129637002B5A67 /* ChromeRendering */,
ACDF076523F7D1C300597B3B /* Application.swift */,
ACDF076723F7E91A00597B3B /* ApplicationType.swift */,
51DCEFB32A0BC6B600561C9B /* CaptureController.swift */,
51AB56DA2A129637002B5A67 /* ChromeRendering */,
51AB56EA2A141C57002B5A67 /* ColorHistoryController.swift */,
51AB56DF2A13D189002B5A67 /* DeepLinksController.swift */,
5179289925C37D2A000F6F3A /* KeyboardShortcuts.swift */,
C9C203E32B1788270081E1EF /* LocalSearchController.swift */,
E04B47312B07B66F00DAE338 /* LocationsController.swift */,
5523A7E023F99D7200F25EEC /* Preferences.swift */,
ACCD798D240AE82C0004ECE5 /* PushNotification.swift */,
Expand Down Expand Up @@ -441,6 +449,15 @@
path = Controllers;
sourceTree = "<group>";
};
C9C203EB2B17FE900081E1EF /* LocationVIew */ = {
isa = PBXGroup;
children = (
70BE435923F54B7200FD6282 /* LocationView.swift */,
C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */,
);
path = LocationVIew;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -598,6 +615,7 @@
5534158623FE1AC4005C0A41 /* Flow.swift in Sources */,
51B0241D25C3104C0042394E /* AppIcon.swift in Sources */,
511BA5A823F42EE400E3E660 /* AppView.swift in Sources */,
C9C203E42B1788270081E1EF /* LocalSearchController.swift in Sources */,
5523A7E123F99D7200F25EEC /* Preferences.swift in Sources */,
51AB56E02A13D189002B5A67 /* DeepLinksController.swift in Sources */,
551F8CED23F489A50006D1BD /* SidebarView.swift in Sources */,
Expand All @@ -607,6 +625,7 @@
54C2092B27DBDE2200E47016 /* DocumentPickerConfig.swift in Sources */,
ACCD798E240AE82C0004ECE5 /* PushNotification.swift in Sources */,
517928C225C47392000F6F3A /* AVAssetToGIF.swift in Sources */,
C9C203E62B1788C90081E1EF /* LocalSearchResult.swift in Sources */,
55AF68B723F9D2E200C5D87A /* UIState.swift in Sources */,
511BA59223F4031F00E3E660 /* ControlView.swift in Sources */,
5534158223FE0539005C0A41 /* Contributors.swift in Sources */,
Expand Down Expand Up @@ -634,6 +653,7 @@
51AB56DE2A13D14A002B5A67 /* DeepLink.swift in Sources */,
51AB56E52A13D3F6002B5A67 /* DeepLinkEditorView.swift in Sources */,
51AB56DC2A129647002B5A67 /* ChromeRendererTypes.swift in Sources */,
C9C203ED2B17FEA80081E1EF /* LocalSearchRowView.swift in Sources */,
551F8CF723F4BEAC0006D1BD /* TypeIdentifier.swift in Sources */,
551F8CF123F498C30006D1BD /* SimulatorsController.swift in Sources */,
ACCD798C240ADEE20004ECE5 /* NotificationEditorView.swift in Sources */,
Expand All @@ -656,7 +676,7 @@
55AF68B523F9CFD600C5D87A /* SettingsView.swift in Sources */,
51AB56EF2A143BC0002B5A67 /* PickedColor.swift in Sources */,
B07F584923F99A1700256D5D /* SimCtl+SubCommands.swift in Sources */,
551F8CF523F4AF7B0006D1BD /* FilterField.swift in Sources */,
551F8CF523F4AF7B0006D1BD /* SearchField.swift in Sources */,
AC472CCF240D46D7007FF521 /* KeyedEncodingContainer-NotEmpty.swift in Sources */,
51AB56E72A140D53002B5A67 /* NSColor-Conversions.swift in Sources */,
5FA79DA92A5752A9006F5477 /* XcodeHelper.swift in Sources */,
Expand Down
130 changes: 130 additions & 0 deletions ControlRoom/Controllers/LocalSearchController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// LocalSearchController.swift
// ControlRoom
//
// Created by John McEvoy on 29/11/2023.
// Copyright © 2023 Paul Hudson. All rights reserved.
//

import Foundation
import MapKit

@MainActor
class LocalSearchController: NSObject, ObservableObject {
/// Prevents duplicate queries from being made
private var lastQuery: String = ""

/// Completion handler is called by the `MKLocalSearchCompleter` success callback
private var callback: (([LocalSearchResult]) -> Void)?

/// the MKLocalSearchCompleter used to make local search requests
private lazy var localSearchCompleter: MKLocalSearchCompleter = {
let completer = MKLocalSearchCompleter()
completer.resultTypes = [.address, .pointOfInterest]
completer.delegate = self
return completer
}()

/**
Finds places and POIs using a query string and a geographical point to focus on.

- Parameter for: The partial (autocomplete) query to search for.
- Parameter around: Provides a hint for `MKLocalSearchCompleter` to search around a geographical point.
- Parameter completion: Called if valid search results are found.

- Returns: If a location is found immediately (a coordinate was pasted in, for example), returns a `Location`.
*/
func search(
for query: String,
around location: Location,
completion: @escaping ([LocalSearchResult]) -> Void
) -> Location? {
guard query.isNotEmpty, query != lastQuery else { return nil }
callback = completion
lastQuery = query

if let location = parseCoordinates(query) {
return location
}

localSearchCompleter.queryFragment = query
localSearchCompleter.region = MKCoordinateRegion(
center: location.center,
latitudinalMeters: CLLocationDistance(20000),
longitudinalMeters: CLLocationDistance(20000)
)

return nil
}

/**
Converts an incomplete `LocalSearchResult` to a `Location` with coordinates and map bounds.

- Parameter result: The `LocalSearchResult` to convert.
- Parameter completion: Called if a valid `Location` is created.
*/
func select(_ result: LocalSearchResult, completion: @escaping (Location) -> Void) {
guard let completer = result.completer else { return }

Task {
do {
let request = MKLocalSearch.Request(completion: completer)
let response = try await MKLocalSearch(request: request).start()
guard let mapItem = response.mapItems.first else { return }
let location = Location(
id: result.id,
name: result.title,
latitude: mapItem.placemark.coordinate.latitude,
longitude: mapItem.placemark.coordinate.longitude,
latitudeDelta: response.boundingRegion.span.latitudeDelta,
longitudeDelta: response.boundingRegion.span.longitudeDelta)
completion(location)
} catch {
print("\(error)")
}
}
}

/// Uses a regex to detect if a string is a lat/long coordinate (e.g. `'37.33467, -122.00898'`)
private func parseCoordinates(_ coordinateString: String) -> Location? {
do {
let regexSearch = try Regex("^-?(?:[1-8]?\\d(?:\\.\\d+)?|90(?:\\.0+)?),\\s*-?(?:180(?:\\.0+)?|1[0-7]\\d(?:\\.\\d+)?|\\d{1,2}(?:\\.\\d+)?)$")

guard coordinateString.ranges(of: regexSearch).isNotEmpty else {
return nil
}

let components = coordinateString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }

guard let latitude = Double(components[0]), let longitude = Double(components[1]) else {
return nil
}

return Location(
id: UUID(),
name: "Map coordinate",
latitude: latitude,
longitude: longitude)

} catch {
return nil
}
}
}

/// Adds `MKLocalSearchCompleterDelegate` conformance so the controller can use the delegate's callback methods
extension LocalSearchController: MKLocalSearchCompleterDelegate {
/// Called if `MKLocalSearchCompleter` return valid results from a query string
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
guard let callback else { return }
let results = completer.results.map {
LocalSearchResult( result: $0 )
}
callback(results)
}

/// Called if `MKLocalSearchCompleter` encounters an error
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
print(error)

This comment was marked as resolved.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MKLocalSearchCompleter is an old delegate-based protocol, so I don't think it throws (I could be wrong, I don't spend much time with delegates any more) - it just sends the Error to the didFailWithError function. Suggestions to improve?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No suggestion, I didn't know about MKLocalSearchCompleter, it was just for know why you did that, thanks.

}
}
38 changes: 38 additions & 0 deletions ControlRoom/Helpers/LocalSearchResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// LocalSearchResult.swift
// ControlRoom
//
// Created by John McEvoy on 29/11/2023.
// Copyright © 2023 Paul Hudson. All rights reserved.
//

import Foundation
import MapKit

/// A local search result item
struct LocalSearchResult: Identifiable {
var id: UUID
var title: String
var subtitle: String?
var completer: MKLocalSearchCompletion?

init(result: MKLocalSearchCompletion) {
id = UUID()
self.title = result.title
self.subtitle = result.subtitle.clean()
self.completer = result
}
}

/// if a string is empty or whitespace, convert it to `nil`
extension String {
func clean() -> String? {
let cleanString = self.trimmingCharacters(in: .whitespacesAndNewlines)

if cleanString.isEmpty {
return nil
}

return cleanString
}
}
25 changes: 25 additions & 0 deletions ControlRoom/Helpers/Location.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,36 @@
//

import Foundation
import CoreLocation
import MapKit

/// The user's saved location.
struct Location: Identifiable, Codable {
var id: UUID
var name: String
var latitude: Double
var longitude: Double
var latitudeDelta: Double = 15
var longitudeDelta: Double = 15

var center: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}

var region: MKCoordinateRegion {
get {
return MKCoordinateRegion(
center: center,
span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
} set {
latitude = newValue.center.latitude
longitude = newValue.center.longitude
latitudeDelta = newValue.span.latitudeDelta
longitudeDelta = newValue.span.longitudeDelta
}
}

func toString() -> String {
String(format: "%.5f, %.5f", latitude, longitude)
}
}
2 changes: 1 addition & 1 deletion ControlRoom/Main Window/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ struct SidebarView: View {
.buttonStyle(.borderless)
.padding(.leading, 3)

FilterField("Filter", text: $filterText.onChange(controller.filterSimulators))
SearchField("Filter", text: $filterText.onChange(controller.filterSimulators), onClear: {})
}
.padding(2)
.sheet(isPresented: $shouldShowDeleteAlert) {
Expand Down
Loading