diff --git a/Documentation/Compass/README.md b/Documentation/Compass/README.md index 4956d70a5..e2274b49b 100644 --- a/Documentation/Compass/README.md +++ b/Documentation/Compass/README.md @@ -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, 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, 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, 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: @@ -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() + } + } } ``` diff --git a/Examples/Examples/CompassExampleView.swift b/Examples/Examples/CompassExampleView.swift index d92a3480c..f5954df67 100644 --- a/Examples/Examples/CompassExampleView.swift +++ b/Examples/Examples/CompassExampleView.swift @@ -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 ) } } diff --git a/Sources/ArcGISToolkit/Components/Compass/Compass.swift b/Sources/ArcGISToolkit/Components/Compass/Compass.swift index 544b5466b..d2f40b574 100644 --- a/Sources/ArcGISToolkit/Components/Compass/Compass.swift +++ b/Sources/ArcGISToolkit/Components/Compass/Compass.swift @@ -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, - 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 { @@ -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, - 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, - 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. diff --git a/Tests/ArcGISToolkitTests/CompassTests.swift b/Tests/ArcGISToolkitTests/CompassTests.swift index 23f831ff8..df0f69802 100644 --- a/Tests/ArcGISToolkitTests/CompassTests.swift +++ b/Tests/ArcGISToolkitTests/CompassTests.swift @@ -17,76 +17,37 @@ import XCTest @testable import ArcGISToolkit final class CompassTests: XCTestCase { - /// Verifies that the compass accurately indicates when the compass should be hidden when - /// `autoHide` is `false`. + /// Verifies that the compass accurately indicates it shouldn't be hidden when `autoHideDisabled` + /// is applied. func testHiddenWithAutoHideOff() { - let initialValue = 0.0 - let finalValue = 90.0 - var _viewpoint: Viewpoint? = makeViewpoint(rotation: initialValue) - let viewpoint = Binding(get: { _viewpoint }, set: { _viewpoint = $0 }) - let compass = Compass(viewpoint: viewpoint) + let compass1Heading = Double.zero + let compass1 = Compass(heading: compass1Heading) .autoHideDisabled() as! Compass - XCTAssertFalse(compass.shouldHide) - _viewpoint = makeViewpoint(rotation: finalValue) - XCTAssertFalse(compass.shouldHide) - } - - /// Verifies that the compass accurately indicates when the compass should be hidden when - /// `autoHide` is `true` (which is the default). - func testHiddenWithAutoHideOn() { - let initialValue = 0.0 - let finalValue = 90.0 - var _viewpoint: Viewpoint? = makeViewpoint(rotation: initialValue) - let viewpoint = Binding(get: { _viewpoint }, set: { _viewpoint = $0 }) - let compass = Compass(viewpoint: viewpoint) - XCTAssertTrue(compass.shouldHide) - _viewpoint = makeViewpoint(rotation: finalValue) - XCTAssertFalse(compass.shouldHide) - } - - /// Verifies that the compass correctly initializes when given a `nil` viewpoint. - func testInit() { - let compass = Compass(viewpoint: .constant(nil)) - XCTAssertTrue(compass.shouldHide) - } - - /// Verifies that the compass correctly initializes when given a `nil` viewpoint, and `autoHide` is - /// `false`. - func testAutomaticallyHidesNoAutoHide() { - let compass = Compass(viewpoint: .constant(nil)) + XCTAssertFalse(compass1.shouldHide(forHeading: compass1Heading)) + + let compass2Heading = 45.0 + let compass2 = Compass(heading: compass2Heading) .autoHideDisabled() as! Compass - XCTAssertFalse(compass.shouldHide) - } - - /// Verifies that the compass correctly initializes when given only a viewpoint. - func testInitWithViewpoint() { - let compass = Compass(viewpoint: .constant(makeViewpoint(rotation: .zero))) - XCTAssertTrue(compass.shouldHide) - } - - /// Verifies that the compass correctly initializes when given only a viewpoint. - func testInitWithViewpointAndAutoHide() { - let compass = Compass(viewpoint: .constant(makeViewpoint(rotation: .zero))) + XCTAssertFalse(compass2.shouldHide(forHeading: compass2Heading)) + + let compass3Heading = Double.nan + let compass3 = Compass(heading: compass3Heading) .autoHideDisabled() as! Compass - XCTAssertFalse(compass.shouldHide) - } -} - -extension CompassTests { - /// An arbitrary point to use for testing. - var point: Point { - Point(x: -117.19494, y: 34.05723, spatialReference: .wgs84) - } - - /// An arbitrary scale to use for testing. - var scale: Double { - 10_000.00 + XCTAssertFalse(compass3.shouldHide(forHeading: compass3Heading)) } - /// Builds viewpoints to use for tests. - /// - Parameter rotation: The rotation to use for the resulting viewpoint. - /// - Returns: A viewpoint object for tests. - func makeViewpoint(rotation: Double) -> Viewpoint { - return Viewpoint(center: point, scale: scale, rotation: rotation) + /// Verifies that the compass accurately indicates when it should be hidden. + func testHiddenWithAutoHideOn() { + let compass1Heading: Double = .zero + let compass1 = Compass(heading: compass1Heading) + XCTAssertTrue(compass1.shouldHide(forHeading: compass1Heading)) + + let compass2Heading = 45.0 + let compass2 = Compass(heading: compass2Heading) + XCTAssertFalse(compass2.shouldHide(forHeading: compass2Heading)) + + let compass3Heading = Double.nan + let compass3 = Compass(heading: compass3Heading) + XCTAssertTrue(compass3.shouldHide(forHeading: compass3Heading)) } }