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

Location Button #862

Draft
wants to merge 9 commits into
base: v.next
Choose a base branch
from
Draft
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
4 changes: 4 additions & 0 deletions Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
75C37C9227BEDBD800FC9DCE /* BookmarksExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */; };
75D41B2B27C6F21400624D7C /* ScalebarExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */; };
882899FD2AB5099300A0BDC1 /* FlyoverExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */; };
88519D3F2C8A7967007D7015 /* LocationButtonExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88519D3E2C8A7967007D7015 /* LocationButtonExampleView.swift */; };
E42BFBE92672BF9500159107 /* SearchExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42BFBE82672BF9500159107 /* SearchExampleView.swift */; };
E4624A25278CE815000D2A38 /* FloorFilterExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */; };
E47ABE442652FE0900FD2FE3 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47ABE432652FE0900FD2FE3 /* ExamplesApp.swift */; };
Expand Down Expand Up @@ -55,6 +56,7 @@
75C37C9127BEDBD800FC9DCE /* BookmarksExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksExampleView.swift; sourceTree = "<group>"; };
75D41B2A27C6F21400624D7C /* ScalebarExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalebarExampleView.swift; sourceTree = "<group>"; };
882899FC2AB5099300A0BDC1 /* FlyoverExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlyoverExampleView.swift; sourceTree = "<group>"; };
88519D3E2C8A7967007D7015 /* LocationButtonExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationButtonExampleView.swift; sourceTree = "<group>"; };
E42BFBE82672BF9500159107 /* SearchExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchExampleView.swift; sourceTree = "<group>"; };
E4624A24278CE815000D2A38 /* FloorFilterExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorFilterExampleView.swift; sourceTree = "<group>"; };
E47ABE402652FE0900FD2FE3 /* Toolkit Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Toolkit Examples.app"; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -103,6 +105,7 @@
1CC376D32ABA0B3700A83300 /* TableTopExampleView.swift */,
75230DAD28614369009AF501 /* UtilityNetworkTraceExampleView.swift */,
1C40F3312B46118800C00ED5 /* WorldScaleExampleView.swift */,
88519D3E2C8A7967007D7015 /* LocationButtonExampleView.swift */,
);
name = Examples;
sourceTree = "<group>";
Expand Down Expand Up @@ -269,6 +272,7 @@
75657E4827ABAC8400EE865B /* CompassExampleView.swift in Sources */,
E48A73452658227100F5C118 /* Examples.swift in Sources */,
75C37C9227BEDBD800FC9DCE /* BookmarksExampleView.swift in Sources */,
88519D3F2C8A7967007D7015 /* LocationButtonExampleView.swift in Sources */,
75230DAE28614369009AF501 /* UtilityNetworkTraceExampleView.swift in Sources */,
E48A73432658227100F5C118 /* Example.swift in Sources */,
E47ABE442652FE0900FD2FE3 /* ExamplesApp.swift in Sources */,
Expand Down
43 changes: 43 additions & 0 deletions Examples/Examples/LocationButtonExampleView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2024 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArcGIS
import ArcGISToolkit
import SwiftUI

/// An example demonstrating how to use the location button with a map view.
struct LocationButtonExampleView: View {
/// The `Map` displayed in the `MapView`.
@State private var map = Map(basemapStyle: .arcGISImagery)

/// The location display to set on the map view.
@State private var locationDisplay = {
let locationDisplay = LocationDisplay(dataSource: SystemLocationDataSource())
locationDisplay.autoPanMode = .recenter
locationDisplay.initialZoomScale = 40_000
return locationDisplay
}()

var body: some View {
MapView(map: map)
.locationDisplay(locationDisplay)
.overlay(alignment: .topTrailing) {
LocationButton(locationDisplay: locationDisplay)
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding()
}
}
}
1 change: 1 addition & 0 deletions Examples/ExamplesApp/Examples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ extension ExampleList {
AnyExample("Compass", content: CompassExampleView()),
AnyExample("Feature Form", content: FeatureFormExampleView()),
AnyExample("Floor Filter", content: FloorFilterExampleView()),
AnyExample("Location Button", content: LocationButtonExampleView()),
AnyExample("Overview Map", content: OverviewMapExampleView()),
AnyExample("Popup", content: PopupExampleView()),
AnyExample("Scalebar", content: ScalebarExampleView()),
Expand Down
199 changes: 199 additions & 0 deletions Sources/ArcGISToolkit/Components/LocationButton/LocationButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright 2024 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArcGIS
import CoreLocation
import SwiftUI

/// A button that allows a user to show their location on a map view.
/// Gives the user a variety of options to set the auto pan mode or stop the
/// location datasource.
public struct LocationButton: View {
/// The location display which the button controls.
@State private var locationDisplay: LocationDisplay
/// The current status of the location display's datasource.
@State private var status: LocationDataSource.Status = .stopped
/// The autopan mode of the location display.
@State private var autoPanMode: LocationDisplay.AutoPanMode = .off
/// The last selected autopan mode by the user.
@State private var lastSelectedAutoPanMode: LocationDisplay.AutoPanMode
/// The auto pan options that the user can choose from the context menu of the button.
private let autoPanOptions: Set<LocationDisplay.AutoPanMode>

/// Creates a location button with a location display.
/// - Parameter locationDisplay: The location display that the button will control.
public init(
locationDisplay: LocationDisplay,
autoPanOptions: Set<LocationDisplay.AutoPanMode> = [.off, .recenter, .compassNavigation, .navigation]
) {
self.locationDisplay = locationDisplay
self.autoPanMode = locationDisplay.autoPanMode
self.autoPanOptions = autoPanOptions
if autoPanOptions.isEmpty || (autoPanOptions.count == 1 && autoPanOptions.first == .off) {
lastSelectedAutoPanMode = .off
} else {
lastSelectedAutoPanMode = LocationDisplay.AutoPanMode.orderedOptions
.lazy
.filter { $0 != .off }
.first { autoPanOptions.contains($0) } ?? .recenter
}
}

public var body: some View {
Button {
buttonAction()
} label: {
buttonLabel()
}
.contextMenu(
ContextMenu { contextMenuContent() }
)
.disabled(status == .starting || status == .stopping)
.onReceive(locationDisplay.dataSource.$status) { status = $0 }
.onReceive(locationDisplay.$autoPanMode) { autoPanMode = $0 }
.onChange(of: autoPanMode) { autoPanMode in
if autoPanMode != locationDisplay.autoPanMode {
locationDisplay.autoPanMode = autoPanMode
if autoPanMode != .off {
// Do not update the last selected autopan mode here if
// `off` was selected by the user.
lastSelectedAutoPanMode = autoPanMode
}
}
}
}

@MainActor
private func buttonAction() {
// Decide the button behavior based on the status.
switch status {
case .stopped, .failedToStart:
// If the datasource is a system location datasource, then request authorization.
if locationDisplay.dataSource is SystemLocationDataSource,
CLLocationManager.shared.authorizationStatus == .notDetermined {
CLLocationManager.shared.requestWhenInUseAuthorization()
}
Task {
// Start the datasource, set initial auto pan mode.
do {
locationDisplay.autoPanMode = lastSelectedAutoPanMode
try await locationDisplay.dataSource.start()
} catch {
print("Error starting location display: \(error)")
}
}
case .started:
// If the datasource is started then decide what to do based
// on the autopan mode.
switch autoPanMode {
case .off:
// If autopan is off, then set it to the last selected autopan mode.
locationDisplay.autoPanMode = lastSelectedAutoPanMode
default:
// Otherwise set it to off.
locationDisplay.autoPanMode = .off
}
case .starting, .stopping:
break
@unknown default:
fatalError()
}
}

@ViewBuilder
private func buttonLabel() -> some View {
// Decide what what image is in the button based on the status
// and autopan mode.
switch status {
case .stopped:
Image(systemName: "location.slash")
case .starting, .stopping:
ProgressView()
case .started:
switch autoPanMode {
case .compassNavigation:
Image(systemName: "location.north.circle")
case .navigation:
Image(systemName: "location.north.line.fill")
case .recenter:
Image(systemName: "location.fill")
case .off:
Image(systemName: "location")
@unknown default:
fatalError()
}
case .failedToStart:
Image(systemName: "exclamationmark.triangle")
.tint(.secondary)
@unknown default:
fatalError()
}
}

@MainActor
@ViewBuilder
private func contextMenuContent() -> some View {
if status == .started {
if autoPanOptions.count > 1 {
Section("Autopan") {
Picker("Autopan", selection: $autoPanMode) {
ForEach(LocationDisplay.AutoPanMode.orderedOptions, id: \.self) { autoPanMode in
if autoPanOptions.contains(autoPanMode) {
Text(autoPanMode.pickerText).tag(autoPanMode)
}
}
}
}
}

Button {
Task {
await locationDisplay.dataSource.stop()
}
} label: {
Label("Hide Location", systemImage: "location.slash")
}
}
}
}

private extension LocationDisplay.AutoPanMode {
/// The options that will appear in the picker, in order.
static let orderedOptions: [Self] = [.off, .recenter, .compassNavigation, .navigation]

/// The label that should appear in the picker.
var pickerText: String {
switch self {
case .off:
"Auto Pan Off"
case .recenter:
"Recenter"
case .compassNavigation:
"Compass"
case .navigation:
"Navigation"
@unknown default:
fatalError()
}
}
}

@MainActor
private extension CLLocationManager {
static let shared = CLLocationManager()
}

#Preview {
LocationButton(locationDisplay: LocationDisplay(dataSource: SystemLocationDataSource()))
}