How can I fix my compass application that makes a complete turn when going 359° from 0°?
Asked Answered
C

2

6

I am creating a compass application in swiftUI. It works, but when I add animation to move the compass, the behavior below appears:

application screen shot

For example, when it goes from the 5° orientation to 350° it decides to do a complete turn. It is not the natural behavior for a compass.

My ContentView code:

import SwiftUI
import CoreLocation

struct ContentView: View {

  var locationManager = CLLocationManager()
  @ObservedObject var location: LocationProvider = LocationProvider()
  @State var angle: CGFloat = 0

  var body: some View {
    GeometryReader { geometry in
      VStack {
        Rectangle()
          .frame(width: 10, height: 30)
          .background(Color(.red))
          .foregroundColor(Color(.clear))
        Spacer()
        Text(String(Double(self.location.currentHeading).stringWithoutZeroFraction) + "°")
          .font(.system(size: 40))
          .foregroundColor(Color(.black))
        Spacer()
      }
      .frame(width: 300, height: 300, alignment: .center)
      .border(Color(.black))
      .onReceive(self.location.heading) { heading in
        withAnimation(.easeInOut(duration: 0.2)) {
          self.angle = heading
        }
      }
      .modifier(RotationEffect(angle: self.angle))
    }.background(Color(.white))
  }
}

struct RotationEffect: GeometryEffect {
  var angle: CGFloat

  var animatableData: CGFloat {
    get { angle }
    set { angle = newValue }
  }

  func effectValue(size: CGSize) -> ProjectionTransform {
    return ProjectionTransform(
      CGAffineTransform(translationX: -150, y: -150)
        .concatenating(CGAffineTransform(rotationAngle: -CGFloat(angle.degreesToRadians)))
        .concatenating(CGAffineTransform(translationX: 150, y: 150))
    )
  }
}

public extension CGFloat {
  var degreesToRadians: CGFloat { return self * .pi / 180 }
  var radiansToDegrees: CGFloat { return self * 180 / .pi }
}

public extension Double {
  var degreesToRadians: Double { return Double(CGFloat(self).degreesToRadians) }
  var radiansToDegrees: Double { return Double(CGFloat(self).radiansToDegrees) }

  var stringWithoutZeroFraction: String {
    return truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(self)
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Here, I'm using the CGAffineTransform to rotate the compass with a matrix animation to solve the problem. I thought this would work because when I used this method in UIKit, the problem did not exist.

My LocationProvider code:

import SwiftUI
import CoreLocation
import Combine

public class LocationProvider: NSObject, CLLocationManagerDelegate, ObservableObject {

  private let locationManager: CLLocationManager
  public let heading = PassthroughSubject<CGFloat, Never>()

  @Published var currentHeading: CGFloat {
    willSet {
      heading.send(newValue)
    }
  }

  public override init() {
    currentHeading = 0
    locationManager = CLLocationManager()
    super.init()
    locationManager.delegate = self
    locationManager.desiredAccuracy = kCLLocationAccuracyBest
    locationManager.startUpdatingHeading()
    locationManager.requestWhenInUseAuthorization()
  }

  public func updateHeading() {
    locationManager.startUpdatingHeading()
  }

  public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
    DispatchQueue.main.async {
      self.currentHeading = CGFloat(newHeading.trueHeading)
    }
  }
}

How can I resolve this problem?

Correa answered 18/11, 2019 at 16:58 Comment(4)
I like the animation! You should keep itOg
what is the purpose of dividing by 1? CGFloat(round(1 * newHeading.trueHeading) / 1). You have also a double string initialisation (String(String(Contrapose
Can you make the logic as follows. Instead of limiting yourself between [0-360], try to calculate the new heading as follows: newAngle = oldAngle +/- change. This is not the best way but is a good initial step.Reaves
https://mcmap.net/q/399716/-slice-a-list-based-on-an-index-and-items-behind-it-in-python might help. Specifically, (end_angle - start_angle + 180) % 360 - 180 to get the shortest rotation between two angles.Raskind
A
4

Updated: This will now work when rotating multiple times. Thanks to krjw for pointing out the issue.

There's no reason you need your angle property to stay within 360°. Instead of assigning heading to angle directly, calculate the difference and add it.

Here's a working example. Outside your body property in ContentView, add the following functions:

// If you ever need the current value of angle clamped to 0..<360,
//   use clampAngle(self.angle)
func clampAngle(_ angle: CGFloat) -> CGFloat {
    var angle = angle
    while angle < 0 {
        angle += 360
    }
    return angle.truncatingRemainder(dividingBy: 360)
}

// Calculates the difference between heading and angle
func angleDiff(to heading: CGFloat) -> CGFloat {
    return (clampAngle(heading - self.angle) + 180).truncatingRemainder(dividingBy: 360) - 180
}

Then change the line that assigns angle to

self.angle += self.angleDiff(to: heading)
Archaeological answered 18/11, 2019 at 18:40 Comment(2)
Why should it rotate multiple times?Raskind
In case you walk around in a circle. Originally, my answer didn't work if you turned in a circle multiple times.Archaeological
O
4

The answer of John M. based on the comments works, however when going into the positive direction, over 360 degrees, the animation fails again.

The simple solution would be to look for a change of -300 and smaller and then add 360 degrees. Theres is probably a better solution, but until then I will share mine:

.onReceive(self.location.heading) { heading in
    var diff = (heading - self.angle + 180).truncatingRemainder(dividingBy: 360) - 180
    if diff < -300 {
        diff += 360
    }
    withAnimation {
        self.angle += diff
    }
}

Og answered 19/11, 2019 at 11:16 Comment(2)
I had also met this problem, thank you for your sharing :)Correa
@Correa I think John M. answer is way more elegant and should be the accepted answer!Og

© 2022 - 2024 — McMap. All rights reserved.