You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI指南针刻度标记旋转动画不生效问题咨询

SwiftUI指南针刻度标记动画失效问题

我用SwiftUI制作了一款基于iPhone 13 Pro物理指南针数据的简易指南针视图。当前航向数值准确,视图可正常旋转,但所有动画均无法作用于刻度标记——不过旋转整个ZStack时动画表现正常。相关代码如下:

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

问题根源

  1. 不稳定的视图标识:GaugeMarkerCompass使用UUID()作为id,每次heading变化时,labelSet()生成的新数组会包含全新标识的元素,SwiftUI会销毁旧刻度视图并重建,直接跳过动画。
  2. GeometryProxy传递干扰:直接将GeometryProxy传给子视图CompassLabelView,GeometryProxy是值类型,布局变化时会生成新实例,触发子视图强制重建,打断动画流程。
  3. 不必要的@State变量:CompassLabelView中的fontSize和paddingValue未实际使用,反而可能引发视图状态异常。

修复方案

1. 替换为稳定的视图ID

将GaugeMarkerCompass的id改为基于唯一的degrees值,确保视图标识稳定:

struct GaugeMarkerCompass: Identifiable, Hashable {
    let id: Double // 用度数作为唯一ID,每个标记度数唯一
    let degrees: Double
    let label: String
    
    init(degrees: Double, label: String) {
        self.id = degrees
        self.degrees = degrees
        self.label = label
    }
    
    // ... 其余代码不变
}

2. 避免直接传递GeometryProxy

将子视图需要的尺寸参数从父视图计算后传递,而非整个GeometryProxy:

// 修改CompassLabelView
public struct CompassLabelView: View {
    let marker: GaugeMarkerCompass
    let size: CGFloat // 直接传入计算好的尺寸
    
    public var body: some View {
        VStack {
            Text(marker.label)
                .foregroundColor(Color.gray)
                .font(Font.custom("AppleSDGothicNeo-Bold", size: size * 0.05))
                .padding(.bottom, size * 0.72)
        }
        .rotationEffect(Angle(degrees: marker.degrees))
    }
}

// 父视图中调用时传递尺寸
ForEach(GaugeMarkerCompass.labelSet()) { marker in
    CompassLabelView(marker: marker, size: width)
        .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
}

3. 优化动画配置

调整动画时长为更符合指南针的流畅速度,并确保动画仅作用于旋转变化:

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

4. 移除无用变量

删除CompassLabelView中未使用的@State变量:

public struct CompassLabelView: View {
    let marker: GaugeMarkerCompass
    let size: CGFloat
    
    public var body: some View {
        VStack {
            Text(marker.label)
                .foregroundColor(Color.gray)
                .font(Font.custom("AppleSDGothicNeo-Bold", size: size * 0.05))
                .padding(.bottom, size * 0.72)
        }
        .rotationEffect(Angle(degrees: marker.degrees))
    }
}

修复后完整代码

//
//  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, size: width)
                            .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
                    }
                    .rotationEffect(.init(degrees: heading))
                    .animation(.easeInOut(duration: 0.3), value: heading)

                }.aspectRatio(1, contentMode: .fit)

            }
        }
    }
    
    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"
        }
    }
    
    public struct CompassLabelView: View {
        let marker: GaugeMarkerCompass
        let size: CGFloat
        
        public var body: some View {
            VStack {
                Text(marker.label)
                    .foregroundColor(Color.gray)
                    .font(Font.custom("AppleSDGothicNeo-Bold", size: size * 0.05))
                    .padding(.bottom, size * 0.72)
            }
            .rotationEffect(Angle(degrees: marker.degrees))
        }
    }
    
    struct GaugeMarkerCompass: Identifiable, Hashable {
        let id: Double
        let degrees: Double
        let label: String
        
        init(degrees: Double, label: String) {
            self.id = degrees
            self.degrees = degrees
            self.label = label
        }
        
        static func labelSet() -> [GaugeMarkerCompass] {
            return [
                GaugeMarkerCompass(degrees: 0, label: "N"),
                GaugeMarkerCompass(degrees: 30, label: "30"),
                GaugeMarkerCompass(degrees: 60, label: "60"),
                GaugeMarkerCompass(degrees: 90, label: "E"),
                GaugeMarkerCompass(degrees: 120, label: "120"),
                GaugeMarkerCompass(degrees: 150, label: "150"),
                GaugeMarkerCompass(degrees: 180, label: "S"),
                GaugeMarkerCompass(degrees: 210, label: "210"),
                GaugeMarkerCompass(degrees: 240, label: "240"),
                GaugeMarkerCompass(degrees: 270, label: "W"),
                GaugeMarkerCompass(degrees: 300, label: "300"),
                GaugeMarkerCompass(degrees: 330, label: "330")
            ]
        }
    }
}

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

内容的提问来源于stack exchange,提问作者bacata.borisov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.12 21:28:10