Search code examples
swiftswiftui

Rotating animation does not work with ViewBuilder function


I am playing around with SwiftUI to try understand it better but I can't understand this difference in behavior. In this sample code, why does the struct view able to rotate while the @ViewBuilder function does not rotate?

struct ContentView: View {
    @State var isSpinning = false
    
    var body: some View {
        StructView()
        funcView()
    }
    
    @ViewBuilder
    func funcView() -> some View {
        @State var isSpinning = false
        VStack {
            Button {
                isSpinning.toggle()
            }label: {
                Text("Spin me")
            }
            Image(systemName: "arrow.clockwise")
                .font(.system(size: 80))
                .foregroundColor(.blue)
                .rotationEffect(.degrees(isSpinning ? 360 : 0))
                .animation(.easeInOut(duration: 1), value: isSpinning)
        }
        .padding()
    }
    
    struct StructView: View {
        @State var isSpinning = false
        
        var body: some View {
            VStack {
                Button {
                    isSpinning.toggle()
                }label: {
                    Text("Spin me")
                }
                Image(systemName: "arrow.clockwise")
                    .font(.system(size: 80))
                    .foregroundColor(.blue)
                    .rotationEffect(.degrees(isSpinning ? 360 : 0))
                    .animation(.easeInOut(duration: 1), value: isSpinning)
            }
            .padding()
        }
    }

}

Solution

  • The fact that it's a @ViewBuilder function, or the fact that it's a separate function, doesn't really matter to SwiftUI. It is the same as if you inlined funcView and put it directly in body.

    var body: some View {
        StructView()
        
        @State var isSpinning = false
        VStack {
            Button {
                isSpinning.toggle()
            }label: {
                Text("Spin me")
            }
            Image(systemName: "arrow.clockwise")
                .font(.system(size: 80))
                .foregroundColor(.blue)
                .rotationEffect(.degrees(isSpinning ? 360 : 0))
                .animation(.easeInOut(duration: 1), value: isSpinning)
        }
        .padding()
    }
    

    And the problem with this, is that @State does not work as a local variable. SwiftUI is not designed to track @States that are local variables. @States should always be declared as instance properties in a View struct.

    In your code, you also did this. You declared @State var isSpinning = false directly in ContentView, but you declared it again in funcView, so references to isSpinning in funcView are all resolved to the @State declared in funcView.

    You should remove the line @State var isSpinning = false in funcView, and now references to isSpinning will resolve to the one declared in ContentView, and the view will work as expected.


    Note that there is a difference between writing everything in body (calling @ViewBuilder funds/properties is effectively equivalent to this), and separating view components into different View structs.

    By putting things into a separate View struct, you effectively create a "barrier" for view updates. For example, StructView.body won't be called every time something in ContentView changes, because StructView does not depend on anything in ContentView.

    On the other hand, funcView will be called every time something in ContentView changes, because well, you are literally calling it in ContentView.body.

    So in general, it is better to separate your views into individual View structs, compared to separating your views into individual @ViewBuilder funcs or @ViewBuilder vars in the same View struct. This way, an update at the top of the view hierarchy won't cause everything else to also update.

    Here I have added some prints to demonstrate the difference:

    struct ContentView: View {
        @State var isSpinning = false
        
        @State var something = "Foo"
        
        var body: some View {
            StructView()
            funcView()
            Button("Change something") {
                something = "Bar"
            }
            Text("Something: \(something)")
        }
        
        @ViewBuilder
        func funcView() -> some View {
            let _ = print("funcView Called")
            VStack {
                Button {
                    isSpinning.toggle()
                }label: {
                    Text("Spin me")
                }
                Image(systemName: "arrow.clockwise")
                    .font(.system(size: 80))
                    .foregroundColor(.blue)
                    .rotationEffect(.degrees(isSpinning ? 360 : 0))
                    .animation(.easeInOut(duration: 1), value: isSpinning)
            }
            .padding()
        }
        
        struct StructView: View {
            @State var isSpinning = false
            
            var body: some View {
                let _ = print("StructView.body Called")
                VStack {
                    Button {
                        isSpinning.toggle()
                    }label: {
                        Text("Spin me")
                    }
                    Image(systemName: "arrow.clockwise")
                        .font(.system(size: 80))
                        .foregroundColor(.blue)
                        .rotationEffect(.degrees(isSpinning ? 360 : 0))
                        .animation(.easeInOut(duration: 1), value: isSpinning)
                }
                .padding()
            }
        }
    }