animationswiftuicompassswiftui-animation

Why my animation doesn't work for a compass made in SwiftUI


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)
    }
}

Solution

  • 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"
            }
            
        }
        
    }