0

I am making a simple compass view with just basic things for a view and I would like to have it animated. I am using the compass of my physical device (iPhone 13 PRO). So, everything seems to be fine - heading is correct, view is rotating but... the animation does not work, actually any of the animation types. However if I use it to rotate the whole ZStack is fine. It doesn't work once I am trying to rotate the gauge markers. Please see code below:

//
//  CompassView.swift
//  ExtasyCompleteNavigation
//
//  Created by Vasil Borisov on 20.08.23.
//

import SwiftUI

struct CompassView: View {
    
    var heading: CGFloat
    
    var body: some View {
        VStack{
            ZStack{
                GeometryReader{ geometry in
                    
                    let width = geometry.size.width
                    let height = geometry.size.height
                    let fontSize = width/13
                    //compass background
                    Circle()
                    //compass heading display and direction
                    Text("\(-heading,specifier: "%.0f")°\n\(displayDirection())")
                        .font(Font.custom("AppleSDGothicNeo-Bold", size: width/13))
                        .foregroundColor(.white)
                        .position(x: width/2, y:height/2)
                    //compass degree ring
                    Group{
                        MyShape(sections: 12, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 5))
                        MyShape(sections: 360, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 2))
                        //compass arrow
                        Text("▼")
                            .font(Font.custom("AppleSDGothicNeo-Bold", size: fontSize))
                            .position(x:width/2, y: height/200 )
                            .foregroundColor(.red)
                        PseudoBoat()
                            .stroke(lineWidth: 4)
                            .foregroundColor(.white)
                            .scaleEffect(x: 0.30, y: 0.55, anchor: .top)
                            .offset(y: geometry.size.height/5)
                    }

                    //heading values
                    ForEach(GaugeMarkerCompass.labelSet()) { marker in
                        CompassLabelView(marker: marker,  geometry: geometry)
                            .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
                        
                    }
                    .rotationEffect(.init(degrees: heading))
                    .animation(Animation.easeInOut(duration: 3), value: heading)

                }.aspectRatio(1, contentMode: .fit)

            }
        }
    }
    
    //function can be moved in a structure with the rest of the tools in swift file
    func displayDirection() -> String {
        switch heading {
        case 0:
            return "N"
        case 90:
            return "E"
        case 180:
            return "S"
        case 270:
            return "W"
        case 90..<180:
            return "SE"
        case 180..<270:
            return "SW"
        case 270..<360:
            return "NW"
        default:
            return "NE"
        }
        
    }
    
    //to be moved to a swift file and keep it separate
    public struct CompassLabelView: View {
        let marker: GaugeMarker
        let geometry: GeometryProxy
        
        @State var fontSize: CGFloat = 12
        @State var paddingValue: CGFloat = 100
        
        public var body: some View {
            VStack {
                Text(marker.label)
                    .foregroundColor(Color.gray)
                    .font(Font.custom("AppleSDGothicNeo-Bold", size: geometry.size.width * 0.05))
                    .rotationEffect(Angle(degrees: 0))
                    .padding(.bottom, geometry.size.width * 0.72)
            }.rotationEffect(Angle(degrees: marker.degrees))
                .onAppear {
                    paddingValue = geometry.size.width * 0.72
                    fontSize = geometry.size.width * 0.07
                }
        }
    }
    struct GaugeMarkerCompass: Identifiable, Hashable {
        let id = UUID()
        
        let degrees: Double
        let label: String
        
        init(degrees: Double, label: String) {
            self.degrees = degrees
            self.label = label
        }
        
        // adjust according to your needs
        static func labelSet() -> [GaugeMarker] {
            return [
                GaugeMarker(degrees: 0, label: "N"),
                GaugeMarker(degrees: 30, label: "30"),
                GaugeMarker(degrees: 60, label: "60"),
                GaugeMarker(degrees: 90, label: "E"),
                GaugeMarker(degrees: 120, label: "120"),
                GaugeMarker(degrees: 150, label: "150"),
                GaugeMarker(degrees: 180, label: "S"),
                GaugeMarker(degrees: 210, label: "210"),
                GaugeMarker(degrees: 240, label: "240"),
                GaugeMarker(degrees: 270, label: "W"),
                GaugeMarker(degrees: 300, label: "300"),
                GaugeMarker(degrees: 330, label: "330")
            ]
        }
    }
}

struct CompassView_Previews: PreviewProvider {
    static var previews: some View {
        CompassView(heading: 0)
            .background(.white)
    }
}
  • Please include a [mre]. Without it, no one can build your code – jnpdx Aug 22 '23 at 18:17
  • The only animation modifier is based on the value ```heading```, but this is neither a state variable nor a binding, so it will not change during the lifetime of the view. Try making it a binding to the state variable that is presumably owned elsewhere. – Benzy Neez Aug 22 '23 at 19:03

2 Answers2

0

Try this approach, where you attach the rotation and the animation to the VStack, as shown in the example code, or if you prefer, you can attach the rotationEffect and animation to the CompassView itself in ContentView.

Also rename GaugeMarkerCompass to GaugeMarker

struct ContentView: View {
    @State var heading: CGFloat = 0.0  // <-- here
    
    var body: some View {
        VStack (alignment: .leading, spacing: 44) {
            // for testing
            Button("change heading") {
                heading += 10.0
            }.padding(10).buttonStyle(.bordered)
            
            CompassView(heading: heading)  // <-- here
//                .rotationEffect(.init(degrees: heading))  // <--- here
//                .animation(.linear(duration: 2), value: heading)  // <--- here
        }
    }
}

struct CompassView: View {
    let heading: CGFloat  // <-- here
    
    var body: some View {
        VStack{
            ZStack{
                GeometryReader{ geometry in
                    let width = geometry.size.width
                    let height = geometry.size.height
                    let fontSize = width/13
                    //compass background
                    Circle()
                    //compass heading display and direction
                    Text("\(-heading,specifier: "%.0f")°\n\(displayDirection())")
                        .font(Font.custom("AppleSDGothicNeo-Bold", size: width/13))
                        .foregroundColor(.white)
                        .position(x: width/2, y:height/2)
                    //compass degree ring
                    Group{
                        MyShape(sections: 12, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 5))
                        MyShape(sections: 360, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 2))
                        //compass arrow
                        Text("▼")
                            .font(Font.custom("AppleSDGothicNeo-Bold", size: fontSize))
                            .position(x:width/2, y: height/200 )
                            .foregroundColor(.red)
//                        PseudoBoat()
//                            .stroke(lineWidth: 4)
//                            .foregroundColor(.white)
//                            .scaleEffect(x: 0.30, y: 0.55, anchor: .top)
//                            .offset(y: geometry.size.height/5)
                    }
                    //heading values
                    ForEach(GaugeMarker.labelSet()) { marker in   // <-- here
                        CompassLabelView(marker: marker,  geometry: geometry)
                            .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
                    }
                    // <--- NOT here
                }.aspectRatio(1, contentMode: .fit)
            }
            // --- here to stop the fading of labels
            .transaction { transaction in
                transaction.animation = nil
            }
        }
        .rotationEffect(.init(degrees: heading))  // <--- here
        .animation(Animation.linear(duration: 2), value: heading)  // <--- here
    }
    
    //function can be moved in a structure with the rest of the tools in swift file
    func displayDirection() -> String {
        switch heading {
        case 0:
            return "N"
        case 90:
            return "E"
        case 180:
            return "S"
        case 270:
            return "W"
        case 90..<180:
            return "SE"
        case 180..<270:
            return "SW"
        case 270..<360:
            return "NW"
        default:
            return "NE"
        }
        
    }
    
}
0

Ok, so after I played around a little bit with the answer from @workingdog I have made the following changes and I would like to post them in case anybody needs that help.

I decided to split the fixed and the moving parts of my compass. here is the code and a picture of the fixed part: fixed_compass_part

here is the code

//
//  CompassStaticView.swift
//  ExtasyCompleteNavigation
//
//  Created by Vasil Borisov on 22.08.23.
//

import SwiftUI

struct CompassStaticView: View {
    
    var heading: CGFloat
    
    var body: some View {
        
        ZStack{
            
            GeometryReader{ geometry in
                
                let width = geometry.size.width
                //let width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = geometry.size.height


                let fontSize = width/15
                
                Circle()
                    .position(.init(x: width/2, y: height/2))
                Circle()
                    .position(.init(x: width/2, y: height/2))
                    .foregroundColor(.gray)
                    .scaleEffect(x: 0.02, y:0.02, anchor: .center)
                Text("▼")
                    .font(Font.custom("AppleSDGothicNeo-Bold", size: fontSize))
                    .position(x:width/2, y: height/2 )
                    .offset(y: -width/2.05)
                    .foregroundColor(.red)
                Text("\(heading, specifier: "%.0f")°\(displayDirection())")
                    .font(Font.custom("AppleSDGothicNeo-Bold", size: fontSize ))
                    .foregroundColor(.white)
                    .position(x: width/2, y:height/1.39)
                PseudoBoat()
                    .stroke(lineWidth: 4)
                    .foregroundColor(.white)
                    .scaleEffect(x: 0.28, y: 0.58, anchor: .top)
                    .offset(y: geometry.size.height/5.5)
                
            }//.overlay(CompassView(heading: 0))
            .aspectRatio(1, contentMode: .fit)
            
        }
    }
    
    //function can be moved in a structure with the rest of the tools in swift file
    func displayDirection() -> String {
        switch heading {
            
        case 22.5..<67.5:
            return "NE"
        case 67.5..<112.5:
            return "E"
        case 112.5..<157.5:
            return "SE"
        case 157.5..<202.5:
            return "S"
        case 202.5..<247.5:
            return "SW"
        case 247.5..<292.5:
            return "W"
        case 292.5..<337.5:
            return "NW"
        default:
            return "N"
        }
        
    }
}

struct CompassStaticView_Previews: PreviewProvider {
    static var previews: some View {
        CompassStaticView(heading: 0)
    }
}

Then I made the rotation and the animation suggested in the answer which I accepted and I applied the transaction which prevents the labels from fading away (very useful by the way) and added .rotationEffect and .animation to the VStack.

see picture: compass_moving_part here is the code:

//
//  CompassView.swift
//  ExtasyCompleteNavigation
//
//  Created by Vasil Borisov on 20.08.23.
//

import SwiftUI
import Foundation

struct CompassView: View {
    
    var heading: CGFloat
    
    var body: some View {
        
        VStack{
            ZStack{
                GeometryReader{ geometry in
                    
                    //compass degree ring
                    
                    Group{
                        MyCompassShape(sections: 12, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 5))
                        
                        MyCompassShape(sections: 360, lineLengthPercentage: 0.15)
                            .stroke(Color.white, style: StrokeStyle(lineWidth: 2))
                    }
                    .scaleEffect(x: 0.75, y:0.75)
                    
                    //heading values
                    ForEach(GaugeMarkerCompass.labelSet()) { marker in
                        CompassLabelView(marker: marker,  geometry: geometry)
                            .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
                        
                    }
                }
            }.aspectRatio(1, contentMode: .fit)
        }.transaction { transaction in
            transaction.animation = nil
        }
        //rotate only the second Vstack
        .rotationEffect(Angle(degrees: -heading))  // why it has to be minus? is it a bug?
        .animation(.easeInOut(duration: 2), value: heading)// <--- here

    }
    
    
    struct MyCompassShape : Shape {
        var sections : Int
        var lineLengthPercentage: CGFloat
        
        func path(in rect: CGRect) -> Path {
            let radius = rect.width / 1.6
            let degreeSeparation : Double = 360.0 / Double(sections)
            var path = Path()
            for index in 0..<Int(360.0/degreeSeparation) {
                let degrees = Double(index) * degreeSeparation
                let center = CGPoint(x: rect.midX, y: rect.midY)
                let innerX = center.x + (radius - rect.size.width * lineLengthPercentage / 2) * CGFloat(cos(degrees / 360 * Double.pi * 2))
                let innerY = center.y + (radius - rect.size.width * lineLengthPercentage / 2) * CGFloat(sin(degrees / 360 * Double.pi * 2))
                let outerX = center.x + radius * CGFloat(cos(degrees / 360 * Double.pi * 2))
                let outerY = center.y + radius * CGFloat(sin(degrees / 360 * Double.pi * 2))
                path.move(to: CGPoint(x: innerX, y: innerY))
                path.addLine(to: CGPoint(x: outerX, y: outerY))
            }
            return path
        }
    }
    //to be moved to a swift file and keep it separate
    public struct CompassLabelView: View {
        let marker: GaugeMarker
        let geometry: GeometryProxy
        
        @State var fontSize: CGFloat = 12
        @State var paddingValue: CGFloat = 100
        
        public var body: some View {
            VStack {
                Text(marker.label)
                    .foregroundColor(Color.gray)
                    .font(Font.custom("AppleSDGothicNeo-Bold", size: geometry.size.width * 0.05))
                    .rotationEffect(Angle(degrees: 0))
                    .padding(.bottom, geometry.size.width * 0.7)
            }.rotationEffect(Angle(degrees: marker.degrees))
                .onAppear {
                    paddingValue = geometry.size.width * 0.72
                    fontSize = geometry.size.width * 0.07
                    
                }
        }
    }
    
    struct GaugeMarkerCompass: Identifiable, Hashable {
        let id = UUID()
        
        let degrees: Double
        let label: String
        
        init(degrees: Double, label: String) {
            self.degrees = degrees
            self.label = label
        }
        
        // adjust according to your needs
        static func labelSet() -> [GaugeMarker] {
            return [
                GaugeMarker(degrees: 0, label: "N"),
                GaugeMarker(degrees: 30, label: "30"),
                GaugeMarker(degrees: 60, label: "60"),
                GaugeMarker(degrees: 90, label: "90"),
                GaugeMarker(degrees: 120, label: "120"),
                GaugeMarker(degrees: 150, label: "150"),
                GaugeMarker(degrees: 180, label: "S"),
                GaugeMarker(degrees: 210, label: "210"),
                GaugeMarker(degrees: 240, label: "240"),
                GaugeMarker(degrees: 270, label: "W"),
                GaugeMarker(degrees: 300, label: "300"),
                GaugeMarker(degrees: 330, label: "330")
                
            ]
        }
    }
}

struct CompassView_Previews: PreviewProvider {
    static var previews: some View {
        CompassView(heading: 0)
            .background(.black)
    }
}

At the end in the ContentView, I just overlay them and everything seems to work as expected: compass_complete_view see code:

//
//  ContentView.swift
//  ExtasyCompleteNavigation
//
//  Created by Vasil Borisov on 13.06.23.
//

import SwiftUI
import CocoaMQTT

struct ContentView: View {
    
    //instance of the ConnectionManager which is observableobject
    @StateObject var mqttManager = MQTTManager()
    @StateObject var compassHeading = CompassHeading()
    
    var body: some View {
        
        VStack {
            CompassStaticView(heading: compassHeading.degrees)
                .overlay(CompassView(heading: compassHeading.degrees))

            
            AnemometerView(windAngle: mqttManager.windAngle, windSpeed: mqttManager.windSpeed)
            
            
        }.onAppear{
            
            //that is commented so it doesn't connect every time to the RPi server
            //if you want to test, you have uncomment that
            _ = mqttManager.mqttClient.connect()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.white)
    }
}

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

I am using the internal compass of the iPhone for the moment and the heading is taken from there. It is located in a separate file called CompassLogic

code:

//
//  CompassLogic.swift
//  ExtasyCompleteNavigation
//
//  Created by Vasil Borisov on 21.08.23.
//

import Foundation
import Combine
import CoreLocation

class CompassHeading: NSObject, ObservableObject, CLLocationManagerDelegate {
    var objectWillChange = PassthroughSubject<Void, Never>()
    @Published var degrees: Double = .zero {
        didSet {
            objectWillChange.send()
        }
    }
    
    private let locationManager: CLLocationManager
    
    override init() {
        self.locationManager = CLLocationManager()
        super.init()
        
        self.locationManager.delegate = self
        self.setup()
    }
    
    private func setup() {
        self.locationManager.requestWhenInUseAuthorization()
        
        if CLLocationManager.headingAvailable() {
            self.locationManager.startUpdatingLocation()
            self.locationManager.startUpdatingHeading()
        }
    }
    
    
    //fix the logic here when passing through 0
    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        self.degrees = newHeading.magneticHeading
    }
}

  • If you have a new question, please ask it by clicking the [Ask Question](https://stackoverflow.com/questions/ask) button. Include a link to this question if it helps provide context. - [From Review](/review/low-quality-posts/34883754) – desertnaut Aug 24 '23 at 21:33
  • 1
    Sorry, I must have missed that, I just edited the answer to get rid of the additional question. – bacata.borisov Aug 25 '23 at 03:02