从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() }
问题原因
- 静态存储的视图实例失效:
ContentView用static var multiplicationTableButtons存储按钮实例,但SwiftUI的@State是和当前渲染的视图实例绑定的,静态数组中的实例与界面上显示的实例并非同一对象,修改静态实例的@State不会触发界面更新。 - 违背单向数据流原则:按钮的选中状态由自身维护,父视图无法直接控制,导致状态同步混乱。
解决方案
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
相关产品推荐
相关产品推荐

