Skip to content

[Compass] Remove action parameter, add mapViewProxy parameter, support rotation animation #298

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

Merged
merged 19 commits into from
Apr 13, 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
55 changes: 16 additions & 39 deletions Documentation/Compass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,15 @@ Compass:

## Key properties

`Compass` has the following initializers:
`Compass` has the following initializer:

```swift
/// Creates a compass with a binding to a heading based on compass
/// directions (0° indicates a direction toward true North, 90° indicates a
/// direction toward true East, etc.).
/// Creates a compass with a rotation (0° indicates a direction toward true North, 90° indicates
/// a direction toward true West, etc.).
/// - Parameters:
/// - heading: The heading of the compass.
/// - action: An action to perform when the compass is tapped.
public init(heading: Binding<Double>, action: (() -> Void)? = nil)
```

```swift
/// Creates a compass with a binding to a viewpoint rotation (0° indicates
/// a direction toward true North, 90° indicates a direction toward true
/// West, etc.).
/// - Parameters:
/// - viewpointRotation: The viewpoint rotation whose value determines the
/// heading of the compass.
/// - action: An action to perform when the compass is tapped.
public init(viewpointRotation: Binding<Double>, action: (() -> Void)? = nil)
```

```swift
/// Creates a compass with a binding to an optional viewpoint.
/// - Parameters:
/// - viewpoint: The viewpoint whose rotation determines the heading of the compass.
/// - action: An action to perform when the compass is tapped.
public init(viewpoint: Binding<Viewpoint?>, action: (() -> Void)? = nil)
/// - rotation: The rotation whose value determines the heading of the compass.
/// - mapViewProxy: The proxy to provide access to map view operations.
public init(rotation: Double?, mapViewProxy: MapViewProxy)
```

`Compass` has the following modifiers:
Expand All @@ -63,22 +43,19 @@ When the compass is tapped, the map orients back to north (zero bearing).
### Basic usage for displaying a `Compass`.

```swift
@StateObject var map = Map(basemapStyle: .arcGISImagery)
@State private var map = Map(basemapStyle: .arcGISImagery)

/// Allows for communication between the Compass and MapView or SceneView.
@State private var viewpoint: Viewpoint? = Viewpoint(
center: Point(x: -117.19494, y: 34.05723, spatialReference: .wgs84),
scale: 10_000,
rotation: -45
)
@State private var viewpoint: Viewpoint?

var body: some View {
MapView(map: map, viewpoint: viewpoint)
.onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
.overlay(alignment: .topTrailing) {
Compass(viewpoint: $viewpoint)
.padding()
}
MapViewReader { proxy in
MapView(map: map, viewpoint: viewpoint)
.onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
.overlay(alignment: .topTrailing) {
Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy)
.padding()
}
}
}
```

Expand Down
107 changes: 9 additions & 98 deletions Examples/Examples/CompassExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,121 +15,32 @@ import ArcGIS
import ArcGISToolkit
import SwiftUI

/// An example demonstrating how to use a compass in three different environments.
struct CompassExampleView: View {
/// A scenario represents a type of environment a compass may be used in.
enum Scenario: String {
case map
case scene
}

/// The active scenario.
@State private var scenario = Scenario.map

var body: some View {
Group {
switch scenario {
case .map:
MapWithViewpoint()
case .scene:
SceneWithCameraController()
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu(scenario.rawValue.capitalized) {
Button {
scenario = .map
} label: {
Label("Map", systemImage: "map.fill")
}

Button {
scenario = .scene
} label: {
Label("Scene", systemImage: "globe.americas.fill")
}
}
}
}
}
}

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

/// Allows for communication between the Compass and MapView or SceneView.
@State private var viewpoint: Viewpoint? = Viewpoint(
center: .esriRedlands,
scale: 10_000,
rotation: -45
)
@State private var viewpoint: Viewpoint? = .esriRedlands

var body: some View {
MapViewReader { proxy in
MapView(map: map, viewpoint: viewpoint)
.onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
.overlay(alignment: .topTrailing) {
Compass(viewpoint: $viewpoint) {
guard let viewpoint else { return }
Task {
// Animate the map view to zero when the compass is tapped.
await proxy.setViewpoint(
viewpoint.withRotation(0),
duration: 0.25
)
}
}
.padding()
Compass(rotation: viewpoint?.rotation, mapViewProxy: proxy)
.padding()
}
}
}
}

/// An example demonstrating how to use a compass with a scene view and camera controller.
struct SceneWithCameraController: View {
/// The data model containing the `Scene` displayed in the `SceneView`.
@State private var scene = Scene(basemapStyle: .arcGISImagery)

/// The current heading as reported by the scene view.
@State private var heading = Double.zero

/// The orbit location camera controller used by the scene view.
private let cameraController = OrbitLocationCameraController(
target: .esriRedlands,
distance: 10_000
)

var body: some View {
SceneView(scene: scene, cameraController: cameraController)
.onCameraChanged { newCamera in
heading = newCamera.heading.rounded()
}
.overlay(alignment: .topTrailing) {
Compass(viewpointRotation: $heading) {
// Animate the scene view when the compass is tapped.
Task {
await cameraController.moveCamera(
distanceDelta: .zero,
headingDelta: heading > 180 ? 360 - heading : -heading,
pitchDelta: .zero,
duration: 0.3
)
}
}
.padding()
}
}
}

private extension Point {
static var esriRedlands: Point {
private extension Viewpoint {
static var esriRedlands: Viewpoint {
.init(
x: -117.19494,
y: 34.05723,
spatialReference: .wgs84
center: .init(x: -117.19494, y: 34.05723, spatialReference: .wgs84),
scale: 10_000,
rotation: -45
)
}
}
107 changes: 41 additions & 66 deletions Sources/ArcGISToolkit/Components/Compass/Compass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,43 +14,35 @@
import ArcGIS
import SwiftUI

/// A `Compass` (alias North arrow) shows where north is in a `MapView` or
/// `SceneView`.
/// A `Compass` (alias North arrow) shows where north is in a `MapView`.
public struct Compass: View {
/// The opacity of the compass.
@State private var opacity: Double = .zero

/// An action to perform when the compass is tapped.
private let action: (() -> Void)?

/// A Boolean value indicating whether the compass should automatically
/// hide/show itself when the heading is `0`.
private var autoHide: Bool = true

/// A Boolean value indicating whether the compass should hide based on the
/// current heading and whether the compass automatically hides.
var shouldHide: Bool {
(heading.isZero || heading.isNaN) && autoHide
}
/// The heading of the compass in degrees.
private var heading: Double

/// The proxy to provide access to map view operations.
private var mapViewProxy: MapViewProxy?

/// The width and height of the compass.
private var size: CGFloat = 44

/// The heading of the compass in degrees.
@Binding private var heading: Double

/// Creates a compass with a binding to a heading based on compass
/// directions (0° indicates a direction toward true North, 90° indicates a
/// direction toward true East, etc.).
/// Creates a compass with a heading based on compass directions (0° indicates a direction
/// toward true North, 90° indicates a direction toward true East, etc.).
/// - Parameters:
/// - heading: The heading of the compass.
/// - action: An action to perform when the compass is tapped.
public init(
heading: Binding<Double>,
action: (() -> Void)? = nil
/// - mapViewProxy: The proxy to provide access to map view operations.
init(
heading: Double,
mapViewProxy: MapViewProxy? = nil
) {
_heading = heading
self.action = action
self.heading = heading
self.mapViewProxy = mapViewProxy
}

public var body: some View {
Expand All @@ -63,66 +55,49 @@ public struct Compass: View {
.aspectRatio(1, contentMode: .fit)
.opacity(opacity)
.frame(width: size, height: size)
.onAppear { opacity = shouldHide ? 0 : 1 }
.onChange(of: heading) { _ in
let newOpacity: Double = shouldHide ? .zero : 1
.onAppear { opacity = shouldHide(forHeading: heading) ? 0 : 1 }
.onChange(of: heading) { newHeading in
let newOpacity: Double = shouldHide(forHeading: newHeading) ? .zero : 1
guard opacity != newOpacity else { return }
withAnimation(.default.delay(shouldHide ? 0.25 : 0)) {
withAnimation(.default.delay(shouldHide(forHeading: newHeading) ? 0.25 : 0)) {
opacity = newOpacity
}
}
.onTapGesture {
if let action {
action()
} else {
heading = .zero
}
Task { await mapViewProxy?.setViewpointRotation(0) }
}
.accessibilityLabel("Compass, heading \(Int(heading.rounded())) degrees \(CompassDirection(heading).rawValue)")
}
}
}

public extension Compass {
/// Creates a compass with a binding to a viewpoint rotation (0° indicates
/// a direction toward true North, 90° indicates a direction toward true
/// West, etc.).
/// - Parameters:
/// - viewpointRotation: The viewpoint rotation whose value determines the
/// heading of the compass.
/// - action: An action to perform when the compass is tapped.
init(
viewpointRotation: Binding<Double>,
action: (() -> Void)? = nil
) {
let heading = Binding(get: {
viewpointRotation.wrappedValue.isZero ? .zero : 360 - viewpointRotation.wrappedValue
}, set: { newHeading in
viewpointRotation.wrappedValue = newHeading.isZero ? .zero : 360 - newHeading
})
self.init(heading: heading, action: action)
extension Compass {
/// Returns a Boolean value indicating whether the compass should hide based on the
/// provided heading and whether the compass has been configured to automatically hide.
/// - Parameter heading: The heading used to determine if the compass should hide.
/// - Returns: `true` if the compass should hide, `false` otherwise.
func shouldHide(forHeading heading: Double) -> Bool {
(heading.isZero || heading.isNaN) && autoHide
}

/// Creates a compass with a binding to an optional viewpoint.
}

public extension Compass {
/// Creates a compass with a rotation (0° indicates a direction toward true North, 90° indicates
/// a direction toward true West, etc.).
/// - Parameters:
/// - viewpoint: The viewpoint whose rotation determines the heading of the compass.
/// - action: An action to perform when the compass is tapped.
/// when the viewpoint's rotation is 0 degrees.
/// - rotation: The rotation whose value determines the heading of the compass.
/// - mapViewProxy: The proxy to provide access to map view operations.
init(
viewpoint: Binding<Viewpoint?>,
action: (() -> Void)? = nil
rotation: Double?,
mapViewProxy: MapViewProxy
) {
let viewpointRotation = Binding {
viewpoint.wrappedValue?.rotation ?? .nan
} set: { newViewpointRotation in
guard let oldViewpoint = viewpoint.wrappedValue else { return }
viewpoint.wrappedValue = Viewpoint(
center: oldViewpoint.targetGeometry.extent.center,
scale: oldViewpoint.targetScale,
rotation: newViewpointRotation
)
let heading: Double
if let rotation {
heading = rotation.isZero ? .zero : 360 - rotation
} else {
heading = .nan
}
self.init(viewpointRotation: viewpointRotation, action: action)
self.init(heading: heading, mapViewProxy: mapViewProxy)
}

/// Define a custom size for the compass.
Expand Down
Loading