Skip to content

Mhd/refactor compass action #286

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 22 commits into from
Apr 3, 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
23 changes: 11 additions & 12 deletions Documentation/Compass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ The ArcGIS Maps SDK for Swift currently supports rotating MapViews and SceneView

Compass:

- Can be configured to automatically hide when the rotation is zero.
- Automatically hides when the rotation is zero.
- Can be configured to be always visible.
- Will reset the map/scene rotation to North when tapped on.

## Key properties
Expand All @@ -23,9 +24,8 @@ Compass:
/// direction toward true East, etc.).
/// - Parameters:
/// - heading: The heading of the compass.
/// - autoHide: A Boolean value that determines whether the compass
/// automatically hides itself when the heading is `0`.
public init(heading: Binding<Double>, autoHide: Bool = true)
/// - action: An action to perform when the compass is tapped.
public init(heading: Binding<Double>, action: (() -> Void)? = nil)
```

```swift
Expand All @@ -35,27 +35,26 @@ Compass:
/// - Parameters:
/// - viewpointRotation: The viewpoint rotation whose value determines the
/// heading of the compass.
/// - autoHide: A Boolean value that determines whether the compass
/// automatically hides itself when the viewpoint rotation is 0 degrees.
public init(viewpointRotation: Binding<Double>, autoHide: Bool = true)
/// - 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.
/// - autoHide: A Boolean value that determines whether the compass automatically hides itself
/// when the viewpoint's rotation is 0 degrees.
public init(viewpoint: Binding<Viewpoint?>, autoHide: Bool = true)
/// - action: An action to perform when the compass is tapped.
public init(viewpoint: Binding<Viewpoint?>, action: (() -> Void)? = nil)
```

`Compass` has the following modifier:
`Compass` has the following modifiers:

- `func compassSize(size: CGFloat)` - The size of the `Compass`, specifying both the width and height of the compass.
- `func automaticallyHides(_:)` - Specifies whether the ``Compass`` should automatically hide when the heading is 0.

## Behavior:

Whenever the map is not orientated North (non-zero bearing) the compass appears. When reset to north, it disappears. An initializer argument allows you to disable the auto-hide feature so that it always appears.
Whenever the map is not orientated North (non-zero bearing) the compass appears. When reset to north, it disappears. The `automaticallyHides` view modifier allows you to disable the auto-hide feature so that it is always displayed.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this then be named more like how SwiftUI names modifiers like this?

maybe disableAutoHide(:)?

image

Copy link
Member Author

Choose a reason for hiding this comment

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

That would be OK. @dfeinzimer?

Copy link
Collaborator

Choose a reason for hiding this comment

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

#291

disableAutocorrection was deprecated in favor of autocorrectionDisabled so I'll follow the new scheme. e.g. autoHideDisabled.


When the compass is tapped, the map orients back to north (zero bearing).

Expand Down
116 changes: 105 additions & 11 deletions Examples/Examples/CompassExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,121 @@ import ArcGIS
import ArcGISToolkit
import SwiftUI

/// An example demonstrating how to use a compass in three different environments.
struct CompassExampleView: View {
/// The data model containing the `Map` displayed in the `MapView`.
@StateObject private var dataModel = MapDataModel(
map: Map(basemapStyle: .arcGISImagery)
)
/// 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 {
/// 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: Point(x: -117.19494, y: 34.05723, spatialReference: .wgs84),
center: .esriRedlands,
scale: 10_000,
rotation: -45
)

var body: some View {
MapView(map: dataModel.map, viewpoint: viewpoint)
.onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
.overlay(alignment: .topTrailing) {
Compass(viewpoint: $viewpoint)
// Optionally provide a different size for the compass.
// .compassSize(size: <#T##CGFloat#>)
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
Comment on lines +79 to +81
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a lot of point to the compass component if the user has to animate to zero themselves?

Copy link
Member Author

Choose a reason for hiding this comment

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

It still draws the compass, which is non-trivial, automatically allows it to track map view rotation, and will allow tapping to perform an action. The default is to rotate the map view to zero, but without animation, as that requires a MapViewProxy. So yea, there is value in it, but we can re-evaluate the default behavior to see if we can include animation in a seamless way.

Copy link
Contributor

Choose a reason for hiding this comment

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

Good points. Thank you.

)
}
}
.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 {
.init(
x: -117.19494,
y: 34.05723,
spatialReference: .wgs84
)
}
}
2 changes: 1 addition & 1 deletion Examples/Examples/PopupExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct PopupExampleView: View {
static func makeMap() -> Map {
let portalItem = PortalItem(
portal: .arcGISOnline(connection: .anonymous),
id: Item.ID(rawValue: "9f3a674e998f461580006e626611f9ad")!
id: Item.ID("9f3a674e998f461580006e626611f9ad")!
)
return Map(item: portalItem)
}
Expand Down
52 changes: 34 additions & 18 deletions Sources/ArcGISToolkit/Components/Compass/Compass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,24 @@ import SwiftUI
/// A `Compass` (alias North arrow) shows where north is in a `MapView` or
/// `SceneView`.
public struct Compass: View {
/// A Boolean value indicating whether the compass should automatically
/// hide/show itself when the heading is `0`.
private let autoHide: Bool

/// 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 width and height of the compass.
var size: CGFloat = 44
private var size: CGFloat = 44

/// The heading of the compass in degrees.
@Binding private var heading: Double
Expand All @@ -41,14 +44,13 @@ public struct Compass: View {
/// direction toward true East, etc.).
/// - Parameters:
/// - heading: The heading of the compass.
/// - autoHide: A Boolean value that determines whether the compass
/// automatically hides itself when the heading is `0`.
/// - action: An action to perform when the compass is tapped.
Copy link
Contributor

Choose a reason for hiding this comment

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

some doc about when you need to specify this action might be helpful to the user. For example, does the compass set the heading to zero and call the action if I have an action set? What is the behavior when no action is set?

Copy link
Member Author

Choose a reason for hiding this comment

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

The default is to rotate the map view to zero, but without animation.

We'll update the doc.

public init(
heading: Binding<Double>,
autoHide: Bool = true
action: (() -> Void)? = nil
) {
_heading = heading
self.autoHide = autoHide
self.action = action
}

public var body: some View {
Expand All @@ -60,16 +62,22 @@ public struct Compass: View {
}
.aspectRatio(1, contentMode: .fit)
.opacity(opacity)
.onTapGesture { heading = .zero }
.frame(width: size, height: size)
.onAppear { opacity = shouldHide ? 0 : 1 }
.onChange(of: heading) { _ in
let newOpacity: Double = shouldHide ? .zero : 1
guard opacity != newOpacity else { return }
withAnimation(.default.delay(shouldHide ? 0.25 : 0)) {
opacity = newOpacity
}
}
.onAppear { opacity = shouldHide ? 0 : 1 }
.onTapGesture {
if let action {
action()
} else {
heading = .zero
}
}
.accessibilityLabel("Compass, heading \(Int(heading.rounded())) degrees \(CompassDirection(heading).rawValue)")
}
}
Expand All @@ -82,28 +90,27 @@ public extension Compass {
/// - Parameters:
/// - viewpointRotation: The viewpoint rotation whose value determines the
/// heading of the compass.
/// - autoHide: A Boolean value that determines whether the compass
/// automatically hides itself when the viewpoint rotation is 0 degrees.
/// - action: An action to perform when the compass is tapped.
init(
viewpointRotation: Binding<Double>,
autoHide: Bool = true
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, autoHide: autoHide)
self.init(heading: heading, action: action)
}

/// Creates a compass with a binding to an optional viewpoint.
/// - Parameters:
/// - viewpoint: The viewpoint whose rotation determines the heading of the compass.
/// - autoHide: A Boolean value that determines whether the compass automatically hides itself
/// - action: An action to perform when the compass is tapped.
/// when the viewpoint's rotation is 0 degrees.
init(
viewpoint: Binding<Viewpoint?>,
autoHide: Bool = true
action: (() -> Void)? = nil
) {
let viewpointRotation = Binding {
viewpoint.wrappedValue?.rotation ?? .nan
Expand All @@ -115,7 +122,7 @@ public extension Compass {
rotation: newViewpointRotation
)
}
self.init(viewpointRotation: viewpointRotation, autoHide: autoHide)
self.init(viewpointRotation: viewpointRotation, action: action)
}

/// Define a custom size for the compass.
Expand All @@ -125,4 +132,13 @@ public extension Compass {
copy.size = size
return copy
}

/// Specifies whether the ``Compass`` should automatically hide when the heading is 0.
/// - Parameter flag: A Boolean value indicating whether the compass should automatically
/// hide/show itself when the heading is `0`.
func automaticallyHides(_ flag: Bool) -> some View {
var copy = self
copy.autoHide = flag
return copy
}
}
35 changes: 35 additions & 0 deletions Sources/ArcGISToolkit/Extensions/Viewpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2023 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
// http://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

public extension Viewpoint {
/// Creates a new viewpoint with the same target geometry and scale but with a new rotation.
/// - Parameter rotation: The rotation for the new viewpoint.
/// - Returns: A new viewpoint.
func withRotation(_ rotation: Double) -> Viewpoint {
switch self.kind {
case .centerAndScale:
return Viewpoint(
center: self.targetGeometry as! Point,
scale: self.targetScale,
rotation: rotation
)
case.boundingGeometry:
return Viewpoint(
boundingGeometry: self.targetGeometry,
rotation: rotation
)
}
}
}
Loading