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

从ContentView调用View结构体方法无法更新@State变量问题

问题:SwiftUI中按钮状态更新不触发动画的问题

问题背景

我正在学习SwiftUI,将12个按钮封装为MultiplicationTableButton结构体,通过ForEach在ContentView中展示。需求是选中按钮时向前旋转,之前选中的按钮向后旋转。selectMultiplicationTable(number:)方法定义在ContentView中,负责切换按钮的选中状态;按钮的action通过初始化传入的闭包调用该方法,进而触发每个按钮的select()或deselect()。

问题现象

所有按钮的select()/deselect()方法能正常触发,但按钮内部的@State属性degreesToRotate始终保持0.0,无法更新:

func select() -> Void {
    print(self.degreesToRotate) // 初始为0.0
        
    self.degreesToRotate += 360 // 应增加至360.0
        
    print(self.degreesToRotate) // 仍保持0.0
        
    self.isSelected = true
}

如果直接在按钮action里调用self.select(),动画能正常运行;但通过闭包调用父视图的方法时,动画完全失效。

完整代码

import SwiftUI

// 使用十六进制值初始化Color
extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12位)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24位)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32位)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (1, 1, 1, 0)
        }

        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

// 乘法表按钮修饰器
struct MultiplicationTableButtonModifier: ViewModifier {
    let fillColor:   String
    let strokeColor: String
    
    func body(content: Content) -> some View {
        content
            .foregroundStyle(.white)
            .font(.title)
            .fontWeight(.bold)
            .frame(width: 50, height: 50)
            .background(
                Circle()
                    .fill(Color(hex: self.fillColor))
                    .stroke(Color(hex: self.strokeColor), lineWidth: 3)
            )
            .padding(.horizontal, 7)
    }
}

// 为View扩展自定义修饰器
extension View {
    func multiplicationTableButtonStyle(
        fillColor: String,
        strokeColor: String
    ) -> some View {
        modifier(
            MultiplicationTableButtonModifier(
                fillColor:      fillColor,
                strokeColor:    strokeColor
            )
        )
    }
}

// MultiplicationTableButton结构体
struct MultiplicationTableButton: View {
    let number:         Int
    let fillColor:      String
    let strokeColor:    String
    let onSelect:       () -> Void
    
    @State private var degreesToRotate  = 0.0
    @State private var isSelected       = false
    
    var body: some View {
        Button("\(number)") {
            self.onSelect()
            // self.select() // 直接调用此方法动画正常
        }
        .multiplicationTableButtonStyle(
            fillColor: fillColor,
            strokeColor: strokeColor
        )
        .foregroundStyle(self.isSelected ? .black : .white)
        .font(.title)
        .fontWeight(.bold)
        .frame(width: 50, height: 50)
        .background(
            Circle()
                .fill(Color(hex: self.isSelected ? "fff" : "fc4c4c"))
                .stroke(
                    Color(hex: self.isSelected ? "000" : "ca1919"),
                    lineWidth: 3
                )
        )
        .padding(.horizontal, 7)
        .rotation3DEffect(
            .degrees(self.degreesToRotate),
            axis: (
                x: (1...4).contains(self.number) ?   1 : 0,
                y: (5...8).contains(self.number) ?   1 : 0,
                z: (9...12).contains(self.number) ?  1 : 0
            )
        )
        .animation(.default, value: self.isSelected)
    }
    
    func select() -> Void {
        print(self.degreesToRotate)
        self.degreesToRotate += 360
        print(self.degreesToRotate)
        self.isSelected = true
    }
    
    func deselect() -> Void {
        self.degreesToRotate -= 360
        self.isSelected = false
    }
}

struct ContentView: View {
    static var multiplicationTableButtons: [MultiplicationTableButton] = []
    
    static let tableColors = [
        1: (fill: "fc4c4c", stroke: "ca1919"),
        2: (fill: "fcab4c", stroke: "ca7919"),
        3: (fill: "fcdd4c", stroke: "c1b207"),
        4: (fill: "50e316", stroke: "2eaf10"),
        5: (fill: "5ef7b4", stroke: "19ca81"),
        6: (fill: "4cf6fc", stroke: "19abca"),
        7: (fill: "4cabfc", stroke: "196cca"),
        8: (fill: "4c54fc", stroke: "1b10a5"),
        9: (fill: "9f4cfc", stroke: "5e11b4"),
        10: (fill: "dd4cfc", stroke: "ab19ca"),
        11: (fill: "fc4ca7", stroke: "ca196c"),
        12: (fill: "b46e09", stroke: "7b4514")
    ]
    
    @State private var gameState = "settings"
    
    init() {
        for buttonNumber in 1...12 {
            Self.multiplicationTableButtons.append(
                MultiplicationTableButton(
                    number:         buttonNumber,
                    fillColor:      Self.tableColors[buttonNumber]!.fill,
                    strokeColor:    Self.tableColors[buttonNumber]!.stroke,
                    onSelect: {
                        Self.selectMultiplicationTable(number: buttonNumber)
                    }
                )
            )
        }
    }
    
    var body: some View {
        if gameState == "settings" {
            NavigationStack {
                VStack(alignment: .leading) {
                    Text("Choose your multiplication table")
                        .font(.subheadline)
                        .foregroundStyle(.gray)
                    
                    HStack {
                        ForEach(0..<4, id: \.self) { number in
                            ContentView.multiplicationTableButtons[number]
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                    
                    HStack {
                        ForEach(4..<8, id: \.self) { number in
                            ContentView.multiplicationTableButtons[number]
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                    
                    HStack {
                        ForEach(8..<12, id: \.self) { number in
                            ContentView.multiplicationTableButtons[number]
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                }
                .navigationTitle("EduTables")
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 15)
                .padding(.top, 10)
                .border(.green)
                
                Spacer()
            }
        }
        
        if gameState == "game" {
            
        }
    }
    
    static func selectMultiplicationTable(number: Int) -> Void {
        for i in 0..<Self.multiplicationTableButtons.count {
            if Self.multiplicationTableButtons[i].number == number {
                Self.multiplicationTableButtons[i].select()
            } else {
                Self.multiplicationTableButtons[i].deselect()
            }
        }
    }
}

#Preview {
    ContentView()
}

问题原因

  1. 静态存储的视图实例失效:ContentView用static var multiplicationTableButtons存储按钮实例,但SwiftUI的@State是和当前渲染的视图实例绑定的,静态数组中的实例与界面上显示的实例并非同一对象,修改静态实例的@State不会触发界面更新。
  2. 违背单向数据流原则:按钮的选中状态由自身维护,父视图无法直接控制,导致状态同步混乱。

解决方案

1. 重构MultiplicationTableButton,由父视图驱动状态

将按钮的选中状态改为父视图传入,内部通过监听状态变化触发动画:

struct MultiplicationTableButton: View {
    let number: Int
    let fillColor: String
    let strokeColor: String
    let isSelected: Bool
    let onSelect: () -> Void
    
    @State private var degreesToRotate = 0.0
    
    var body: some View {
        Button("\(number)") {
            onSelect()
        }
        .multiplicationTableButtonStyle(
            fillColor: isSelected ? "fff" : fillColor,
            strokeColor: isSelected ? "000" : strokeColor
        )
        .foregroundStyle(isSelected ? .black : .white)
        .rotation3DEffect(
            .degrees(degreesToRotate),
            axis: (
                x: (1...4).contains(number) ? 1 : 0,
                y: (5...8).contains(number) ? 1 : 0,
                z: (9...12).contains(number) ? 1 : 0
            )
        )
        .animation(.default, value: degreesToRotate)
        .onChange(of: isSelected) { _, newValue in
            degreesToRotate += newValue ? 360 : -360
        }
    }
}

2. 修改ContentView,统一管理选中状态

移除静态数组,用@State变量管理当前选中的按钮编号,直接在ForEach中创建按钮实例:

struct ContentView: View {
    let tableColors = [
        1: (fill: "fc4c4c", stroke: "ca1919"),
        2: (fill: "fcab4c", stroke: "ca7919"),
        3: (fill: "fcdd4c", stroke: "c1b207"),
        4: (fill: "50e316", stroke: "2eaf10"),
        5: (fill: "5ef7b4", stroke: "19ca81"),
        6: (fill: "4cf6fc", stroke: "19abca"),
        7: (fill: "4cabfc", stroke: "196cca"),
        8: (fill: "4c54fc", stroke: "1b10a5"),
        9: (fill: "9f4cfc", stroke: "5e11b4"),
        10: (fill: "dd4cfc", stroke: "ab19ca"),
        11: (fill: "fc4ca7", stroke: "ca196c"),
        12: (fill: "b46e09", stroke: "7b4514")
    ]
    
    @State private var gameState = "settings"
    @State private var selectedNumber: Int? = nil
    
    var body: some View {
        if gameState == "settings" {
            NavigationStack {
                VStack(alignment: .leading) {
                    Text("Choose your multiplication table")
                        .font(.subheadline)
                        .foregroundStyle(.gray)
                    
                    HStack {
                        ForEach(1..<5, id: \.self) { number in
                            MultiplicationTableButton(
                                number: number,
                                fillColor: tableColors[number]!.fill,
                                strokeColor: tableColors[number]!.stroke,
                                isSelected: selectedNumber == number,
                                onSelect: {
                                    selectMultiplicationTable(number: number)
                                }
                            )
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                    
                    HStack {
                        ForEach(5..<9, id: \.self) { number in
                            MultiplicationTableButton(
                                number: number,
                                fillColor: tableColors[number]!.fill,
                                strokeColor: tableColors[number]!.stroke,
                                isSelected: selectedNumber == number,
                                onSelect: {
                                    selectMultiplicationTable(number: number)
                                }
                            )
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                    
                    HStack {
                        ForEach(9..<13, id: \.self) { number in
                            MultiplicationTableButton(
                                number: number,
                                fillColor: tableColors[number]!.fill,
                                strokeColor: tableColors[number]!.stroke,
                                isSelected: selectedNumber == number,
                                onSelect: {
                                    selectMultiplicationTable(number: number)
                                }
                            )
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.top, 15)
                }
                .navigationTitle("EduTables")
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 15)
                .padding(.top, 10)
                .border(.green)
                
                Spacer()
            }
        }
        
        if gameState == "game" {
            // 游戏视图内容
        }
    }
    
    func selectMultiplicationTable(number: Int) {
        selectedNumber = selectedNumber == number ? nil : number
    }
}

修复说明

  • 遵循SwiftUI单向数据流原则,由父视图统一管理所有按钮的选中状态,避免状态混乱。
  • 按钮通过onChange监听isSelected变化触发旋转动画,确保状态与动画同步。
  • 移除静态存储的视图实例,直接在界面渲染时创建按钮,保证操作的是当前显示的视图实例。

内容的提问来源于stack exchange,提问作者PHPMYSQLDEV

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.16 00:19:49