45

I feel like I'm missing something very basic, but this example SwiftUI code will not modify the view (despite the Binding updating) when the button is clicked

Tutorials I have read suggest this is the correct way to use a binding and the view should refresh automatically

import SwiftUI

struct ContentView: View {
    @Binding var isSelected: Bool

    var body: some View {
        Button(action: {
            self.isSelected.toggle()
        }) {
            Text(isSelected ? "Selected" : "Not Selected")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    @State static var selected: Bool = false

    static var previews: some View {
        ContentView(isSelected: $selected)
    }
}
simeon
  • 4,466
  • 2
  • 38
  • 42

6 Answers6

27

You have not misunderstood anything. A View using a @Binding will update when the underlying @State change, but the @State must be defined within the view hierarchy. (Else you could bind to a publisher)

Below, I have changed the name of your ContentView to OriginalContentView and then I have defined the @State in the new ContentView that contains your original content view.

import SwiftUI

struct OriginalContentView: View {
    @Binding var isSelected: Bool

    var body: some View {
        Button(action: {
            self.isSelected.toggle()
        }) {
            Text(isSelected ? "Selected" : "Not Selected")
        }
    }
}

struct ContentView: View {
    @State private var selected = false

    var body: some View {
       OriginalContentView(isSelected: $selected)
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
ragnarius
  • 5,642
  • 10
  • 47
  • 68
  • Ragnarius thanks for the answer. But can you also give an example on how to do the other way you mentioned i.e. "Else you could bind to a publisher" I need this other way for handling views where the view is accepting a Binding in its initializer, like Toggle. Thanks :) – Ashish Bansal Jan 17 '21 at 15:07
14

SwiftUI View affects @Binding. @State affects SwiftUI View. @State var affects the view, but to affect another @State it must be used as binding by adding leading $ to value name and it works only inside SwiftUI.

To trigger SwiftUI change from outside, i.e. to deliver/update Image, use Publisher that looks like this:

// Declare publisher in Swift (outside SwiftUI).
public let imagePublisher = PassthroughSubject<Image, Never>()
    
// It must be handled within SwiftUI.
struct ContentView: View {
    // Declare a @State that updates View.
    @State var image: Image = Image(systemName: "photo")
    var body: some View {
        // Use @State image declaration
        // and subscribe this value to publisher "imagePublisher".
        image.onReceive(imagePublisher, perform: { (output: Image) in
            self.image = output  // Whenever publisher sends new value, old one to be replaced
        })
    }
}

// And this is how to send value to update SwiftUI from Swift:
imagePublisher.send(Image(systemName: "photo"))
Ted Klein Bergman
  • 9,146
  • 4
  • 29
  • 50
Niko
  • 161
  • 6
  • Your code was exactly was I was looking for. I didn't know how to have a `@State` property subscribe to a publisher. Thanks :D – Jorge Alegre Dec 31 '19 at 04:42
11

In the top Level of SwiftUI, @Binding cannot refresh View hierarchy unless manually adding a @state or other refreshing triggers.

struct ContentView: View {
    @Binding var isSelected : Bool
    @State var hiddenTrigger = false

    var body: some View {
        VStack {
            Text("\(hiddenTrigger ? "" : "")")
            Button(action: {
                self.isSelected.toggle()
                self.hiddenTrigger = self.isSelected
            }) {
                Text(self.isSelected? "Selected" : "not Selected")
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
            
    static var selected: Bool = false
            
    static var previews: some View {
        ContentView(isSelected: Binding<Bool>(get: {selected}, set: { newValue in
        selected = newValue}))
    }
}
Niall Kehoe
  • 416
  • 5
  • 17
E.Coms
  • 11,065
  • 2
  • 23
  • 35
  • Thank you, this is the behaviour I was misunderstanding. I didn't realise that binding to a global variable (via custom get/set closures) or binding to a static `@State` would not refresh the view hierarchy – simeon Dec 12 '19 at 21:48
4

Looking into this some more I think I understand what's happening.

In this instance I want to use @Binding as I'm building a custom control (like SwiftUI's native Toggle, which also binds to a Bool)

The issue is that the static state in ContentView_Previews (i.e., the line @State static var selected: Bool = false) does not trigger a re-render of the preview when the state changes, so even though the selected state has changed due to interaction with the control, the control (a child of ContentView_Previews) does not re-render itself

This makes it tough to test controls in isolation in the SwiftUI preview, however moving the state into a dummy ObservableObject instance functions correctly. Here's the code:

import SwiftUI
import Combine

class SomeData: ObservableObject {
    @Published var isOn: Bool = false
}

struct MyButton: View {
    @Binding var isSelected: Bool

    var body: some View {
        Button(action: {
            self.isSelected.toggle()
        }) {
            Text(isSelected ? "Selected" : "Not Selected")
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var data: SomeData

    var body: some View {
        MyButton(isSelected: $data.isOn)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(SomeData())
    }
}

It seems that a change in @State static var doesn't trigger a preview re-render. In the above code my @Binding example is moved into MyButton and the content view's dummy environment instance is bounds to its isSelected property. Tapping the button updates the view as expected in the SwiftUI preview.

simeon
  • 4,466
  • 2
  • 38
  • 42
  • 1
    The key to making previews work is pushing the property out of PreviewProvider and into a wrapper View (ContentView in your example). Once you do this, `@State` connected to `@Binding` works just as well as the `@EnvironmentObject` transformation. At least this is the case with Xcode 12 (beta 3). – mygzi Jul 29 '20 at 20:10
2

You need to use @State instead of @Binding.

  • If the UI should update when its value changes, you designate a variable as a @State variable. It is the source of truth.

  • You use @Binding instead of @State, when the view doesn't own this data and its not the source of truth.

Here is your variable:

@State var isSelected: Bool
Roland Lariotte
  • 2,606
  • 1
  • 16
  • 40
  • 1
    Thank you for the explanation. How does something like `Toggle` work, which accepts a `Binding` — is the toggle then unable to set the value for the `Binding`? My aim is to build a control can manipulate an externally bound value. – simeon Dec 12 '19 at 10:46
1

In my case, having the @Binding or @State control a top level if statement caused issues.

I put the if check inside a top level VStack and it started working fine.

struct ContentView: View {
    @Binding var value: Bool // @State breaks too
    
    var body: some View {
        // Add a VStack here to fix the bug
        if value { // Top level `if` based on @State or @Binding won't work
            Text("view 1")
        } else {
            Text("view 2")
                .onAppear {
                    value = true // Won't trigger update
                }
        }
    }
}

This was only sometimes though.. Depending on what the rest of the view hierarchy looked like. My view hierarchy was nested inside a NavigationView, a TabView, a ZStack, etc. I'm not sure what the minimum requirements are to trigger this. Really weird behavior.

joshuakcockrell
  • 5,200
  • 2
  • 34
  • 47