Skip to content

Commit 1176a26

Browse files
committed
Unify rotatation code to use a single animation function
1 parent dc66cc1 commit 1176a26

5 files changed

Lines changed: 94 additions & 96 deletions

File tree

src/Shared/DisplayLink.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ final class DisplayLink {
1616
case rotateScreenSmoothing // smoothly rotates the screen when compass is enabled/disabled
1717
case automatedFramerateTest // automatically pans the screen during FPS test
1818
case fpsLabelUpdate // updates the FPS test label
19-
case compassSmoothHeading // smooths compass heading to reduce jitter
2019
case pinDragScroll // pans the screen when pushpin is dragged to edge
2120
case screenPanningInertia // applies inertia after user pan gesture ends
2221
}

src/Shared/LocationProvider.swift

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,20 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate {
2424
}
2525
}
2626

27-
private(set) var currentHeading: CLHeading? {
27+
struct Heading {
28+
var heading: Double // radians, north up
29+
var accuracy: Double // radians
30+
}
31+
32+
private(set) var currentHeading: Heading? {
2833
didSet {
2934
if let currentHeading {
3035
onChangeHeading.notify(currentHeading)
3136
}
3237
}
3338
}
3439

35-
private(set) var smoothHeading = 0.0 {
36-
didSet {
37-
onChangeSmoothHeading.notify((smoothHeading, currentHeading?.headingAccuracy ?? 0.0))
38-
}
39-
}
40-
41-
let onChangeHeading = NotificationService<CLHeading>()
42-
let onChangeSmoothHeading = NotificationService<(heading: Double, accuracy: Double)>()
40+
let onChangeHeading = NotificationService<Heading>()
4341
let onChangeLocation = NotificationService<CLLocation>()
4442

4543
var allowsBackgroundLocationUpdates: Bool {
@@ -144,7 +142,7 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate {
144142
self.currentLocation = newLocation
145143
}
146144

147-
static func headingAdjustedForInterfaceOrientation(_ clHeading: CLHeading) -> Double {
145+
private static func headingAdjustedForInterfaceOrientation(_ clHeading: CLHeading) -> Double {
148146
var heading = clHeading.trueHeading * .pi / 180
149147
if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first {
150148
switch scene.interfaceOrientation {
@@ -166,30 +164,8 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate {
166164
// MARK: CLLocationManagerDelegate
167165

168166
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
169-
let heading = Self.headingAdjustedForInterfaceOrientation(newHeading)
170-
171-
self.currentHeading = newHeading
172-
173-
if DisplayLink.shared.has(.rotateScreenSmoothing) {
174-
return
175-
}
176-
DisplayLink.shared.add(.compassSmoothHeading, block: { [self] in
177-
var delta = heading - self.smoothHeading
178-
if delta > .pi {
179-
delta -= 2 * .pi
180-
} else if delta < -.pi {
181-
delta += 2 * .pi
182-
}
183-
delta *= 0.15
184-
if abs(delta) < 0.0001 {
185-
self.smoothHeading = heading
186-
} else {
187-
self.smoothHeading += delta
188-
}
189-
if heading == self.smoothHeading {
190-
DisplayLink.shared.remove(.compassSmoothHeading)
191-
}
192-
})
167+
self.currentHeading = Heading(heading: Self.headingAdjustedForInterfaceOrientation(newHeading),
168+
accuracy: newHeading.headingAccuracy * .pi / 180)
193169
}
194170

195171
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

src/iOS/CustomViews/LocationBallView.swift

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,7 @@ final class LocationBallView: UIView, MapPositionedView {
4444
viewPort.mapTransform.onChange.subscribe(self) { [weak self] in
4545
guard let self else { return }
4646
self.updateScreenPosition()
47-
48-
guard !isHidden else { return }
49-
let screenAngle = viewPort.mapTransform.rotation()
50-
let mainView = AppDelegate.shared.mainView!
51-
if mainView.gpsState == .HEADING,
52-
abs(heading - -.pi / 2) < 0.001
53-
{
54-
// don't pin location ball to North until we've animated our rotation to north
55-
heading = -.pi / 2
56-
} else {
57-
if let heading = LocationProvider.shared.currentHeading {
58-
let heading = LocationProvider.headingAdjustedForInterfaceOrientation(heading)
59-
self.heading = CGFloat(screenAngle + heading - .pi / 2)
60-
}
61-
}
47+
setNeedsLayout()
6248
}
6349
}
6450
}
@@ -179,6 +165,9 @@ final class LocationBallView: UIView, MapPositionedView {
179165
super.layoutSubviews()
180166

181167
if showHeading, headingAccuracy > 0 {
168+
let screenAngle = viewPort?.mapTransform.rotation() ?? 0.0
169+
let heading = CGFloat(screenAngle + heading - .pi / 2)
170+
182171
// draw heading
183172
let shapeLayer = headingLayer.mask as! CAShapeLayer
184173
let radius = shapeLayer.bounds.width / 2

src/iOS/MainViewController.swift

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,9 @@ final class MainViewController: UIViewController, DPadDelegate,
332332
self?.locationBallView.updateGpsLocation(location)
333333
}
334334

335-
LocationProvider.shared.onChangeSmoothHeading.subscribe(self) { [weak self] heading, accuracy in
336-
self?.headingChanged(heading, accuracy: accuracy)
335+
LocationProvider.shared.onChangeHeading.subscribe(self) { [weak self] heading in
336+
self?.headingChanged(heading.heading,
337+
accuracy: heading.accuracy)
337338
}
338339

339340
settings.$enableRotation.subscribe(self) { [weak self] newValue in
@@ -342,7 +343,9 @@ final class MainViewController: UIViewController, DPadDelegate,
342343
// remove rotation
343344
let centerPoint = viewPort.screenCenterPoint()
344345
let angle = CGFloat(viewPort.mapTransform.rotation())
345-
viewPort.animateRotation(by: -angle, aroundPoint: centerPoint)
346+
viewPort.rotate(by: -angle,
347+
around: centerPoint,
348+
animation: .largeShift)
346349
}
347350
}
348351

@@ -960,7 +963,9 @@ final class MainViewController: UIViewController, DPadDelegate,
960963
let centerPoint = rotationGesture.location(in: mapView)
961964
#endif
962965
let angle = rotationGesture.rotation
963-
viewPort.rotate(by: angle, aroundScreenPoint: centerPoint)
966+
viewPort.rotate(by: angle,
967+
around: centerPoint,
968+
animation: .none)
964969
rotationGesture.rotation = 0.0
965970

966971
if gpsState == .HEADING {
@@ -1103,20 +1108,27 @@ final class MainViewController: UIViewController, DPadDelegate,
11031108
}
11041109
}
11051110

1111+
/// Called when GPS indicates a new heading. Updates location ball or screen rotation.
1112+
/// - Parameters:
1113+
/// - heading: The user's current heading, in radians, measured clockwise from true north.
1114+
/// - accuracy: The estimated heading accuracy, in radians. Larger values indicate less
1115+
/// certainty; passed through to the location ball for visual display.
11061116
func headingChanged(_ heading: Double, accuracy: Double) {
11071117
let screenAngle = viewPort.mapTransform.rotation()
11081118

11091119
if gpsState == .HEADING {
1110-
// rotate to new heading
1120+
// rotate view to new heading
11111121
let center = viewPort.screenCenterPoint()
11121122
let delta = -(heading + screenAngle)
1113-
viewPort.rotate(by: CGFloat(delta), aroundScreenPoint: center)
1114-
} else {
1115-
// rotate location ball
1116-
locationBallView.headingAccuracy = CGFloat(accuracy * (.pi / 180))
1117-
locationBallView.showHeading = true
1118-
locationBallView.heading = CGFloat(heading + screenAngle - .pi / 2)
1123+
viewPort.rotate(by: delta,
1124+
around: center,
1125+
animation: .smallShift)
11191126
}
1127+
1128+
// update location ball
1129+
locationBallView.headingAccuracy = CGFloat(accuracy)
1130+
locationBallView.heading = heading
1131+
locationBallView.showHeading = true
11201132
}
11211133

11221134
/// Called to indicate that GPS has provided us with an update to our location.
@@ -1149,6 +1161,8 @@ final class MainViewController: UIViewController, DPadDelegate,
11491161
zoom: nil,
11501162
rotation: nil)
11511163
}
1164+
1165+
locationBallView.updateLocation(LatLon(newLocation.coordinate))
11521166
}
11531167

11541168
func moveToLocation(_ location: MapLocation) {
@@ -1263,17 +1277,16 @@ final class MainViewController: UIViewController, DPadDelegate,
12631277

12641278
@IBAction func compassPressed(_ sender: Any?) {
12651279
switch gpsState {
1280+
case .NONE:
1281+
viewPort.rotateToHeading(0.0)
12661282
case .HEADING:
12671283
gpsState = .LOCATION
1268-
viewPort.rotateToNorth()
1284+
viewPort.rotateToHeading(0.0)
12691285
case .LOCATION:
12701286
gpsState = .HEADING
1271-
if let clHeading = LocationProvider.shared.currentHeading {
1272-
let heading = LocationProvider.headingAdjustedForInterfaceOrientation(clHeading)
1287+
if let heading = LocationProvider.shared.currentHeading?.heading {
12731288
viewPort.rotateToHeading(heading)
12741289
}
1275-
case .NONE:
1276-
viewPort.rotateToNorth()
12771290
}
12781291
}
12791292

src/iOS/MapViewPort.swift

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import CoreLocation
1010
import UIKit
1111

12+
enum ViewPortRotationStyle { case none, smallShift, largeShift }
13+
1214
// Allows other layers of the map to view changes to the map view
1315
protocol MapViewPort: AnyObject {
1416
var mapTransform: MapTransform { get }
@@ -97,22 +99,24 @@ extension MapViewPort {
9799
mapTransform.transform = t
98100
}
99101

100-
func rotate(by angle: CGFloat, aroundScreenPoint zoomCenter: CGPoint) {
102+
private func rotate(by angle: CGFloat, around center: CGPoint) {
101103
guard angle != 0.0 else {
102104
return
103105
}
104-
105-
let offset = mapTransform.mapPoint(forScreenPoint: OSMPoint(zoomCenter), birdsEye: false)
106+
let offset = mapTransform.mapPoint(forScreenPoint: OSMPoint(center), birdsEye: false)
106107
var t = mapTransform.transform
107108
t = t.translatedBy(dx: offset.x, dy: offset.y)
108109
t = t.rotatedBy(Double(angle))
109110
t = t.translatedBy(dx: -offset.x, dy: -offset.y)
110111
mapTransform.transform = t
111112
}
112113

113-
func animateRotation(by deltaHeading: Double, aroundPoint center: CGPoint) {
114-
var deltaHeading = deltaHeading
114+
func rotate(by deltaHeading: Double,
115+
around center: CGPoint,
116+
animation: ViewPortRotationStyle)
117+
{
115118
// don't rotate the long way around
119+
var deltaHeading = deltaHeading
116120
while deltaHeading < -.pi {
117121
deltaHeading += 2 * .pi
118122
}
@@ -124,20 +128,25 @@ extension MapViewPort {
124128
return
125129
}
126130

127-
let startTime = CACurrentMediaTime()
128-
129-
let duration = 0.4
130-
var prevHeading: Double = 0
131-
DisplayLink.shared.remove(.compassSmoothHeading)
132-
DisplayLink.shared.add(.rotateScreenSmoothing, block: { [weak self] in
133-
guard let self else { return }
131+
switch animation {
132+
case .none:
133+
self.rotate(by: deltaHeading, around: center)
134+
135+
case .smallShift:
136+
var currentDelta = 0.0
137+
DisplayLink.shared.add(.rotateScreenSmoothing, block: { [weak self] in
138+
guard let self else { return }
139+
var delta = deltaHeading - currentDelta
140+
if abs(delta) < 0.0001 {
141+
DisplayLink.shared.remove(.rotateScreenSmoothing)
142+
} else {
143+
delta *= 0.15
144+
}
145+
currentDelta += delta
146+
self.rotate(by: delta, around: center)
147+
})
134148

135-
var elapsedTime = CACurrentMediaTime() - startTime
136-
if elapsedTime > duration {
137-
elapsedTime = CFTimeInterval(duration) // don't want to over-rotate
138-
}
139-
// Rotate using an ease-in/out curve. This ensures that small changes in direction don't cause jerkiness.
140-
// result = interpolated value, t = current time, b = initial value, c = delta value, d = duration
149+
case .largeShift:
141150
func easeInOutQuad(_ t: Double, _ b: Double, _ c: Double, _ d: Double) -> Double {
142151
var t = t
143152
t /= d / 2
@@ -147,13 +156,27 @@ extension MapViewPort {
147156
t -= 1
148157
return -c / 2 * (t * (t - 2) - 1) + b
149158
}
150-
let miniHeading = easeInOutQuad(elapsedTime, 0, deltaHeading, duration)
151-
self.rotate(by: CGFloat(miniHeading - prevHeading), aroundScreenPoint: center)
152-
prevHeading = miniHeading
153-
if elapsedTime >= duration {
154-
DisplayLink.shared.remove(.rotateScreenSmoothing)
155-
}
156-
})
159+
160+
let duration = 0.4
161+
var prevHeading: Double = 0
162+
let startTime = CACurrentMediaTime()
163+
DisplayLink.shared.add(.rotateScreenSmoothing, block: { [weak self] in
164+
guard let self else { return }
165+
166+
var elapsedTime = CACurrentMediaTime() - startTime
167+
if elapsedTime > duration {
168+
elapsedTime = CFTimeInterval(duration) // don't want to over-rotate
169+
}
170+
// Rotate using an ease-in/out curve. This ensures that small changes in direction don't cause jerkiness.
171+
// result = interpolated value, t = current time, b = initial value, c = delta value, d = duration
172+
let miniHeading = easeInOutQuad(elapsedTime, 0, deltaHeading, duration)
173+
self.rotate(by: CGFloat(miniHeading - prevHeading), around: center)
174+
prevHeading = miniHeading
175+
if elapsedTime >= duration {
176+
DisplayLink.shared.remove(.rotateScreenSmoothing)
177+
}
178+
})
179+
}
157180
}
158181

159182
func rotateBirdsEye(by angle: Double) {
@@ -190,11 +213,9 @@ extension MapViewPort {
190213
// Rotate to face current compass heading
191214
let center = screenCenterPoint()
192215
let screenAngle = mapTransform.rotation()
193-
animateRotation(by: -(screenAngle + heading), aroundPoint: center)
194-
}
195-
196-
func rotateToNorth() {
197-
rotateToHeading(0.0)
216+
rotate(by: -(screenAngle + heading),
217+
around: center,
218+
animation: .largeShift)
198219
}
199220
}
200221

0 commit comments

Comments
 (0)