Passing Mutable @State using @Binding

In this section, you’ll refactor the Counter app from the previous lesson to better understand and apply SwiftUI’s principles of state management. You’ll learn how to pass a mutable state across different views within the app using @Binding. The term mutable refers to something that can be “mutated” or changed.

Refactoring the Counter Example Into Multiple Views

Your mission is to divide the counter example from Lesson 1 into three separate views:

  • ControlPanel view: This will house the buttons that modify the count.
  • Console view: This will display the current count.
  • CounterView: This will be the parent of both views.

This structure not only adheres to SwiftUI’s best practice of creating manageable, modular components but also provides a practical setup for learning to pass and modify state across various views using @Binding.

Creating the ControlPanel View

Open the starter Xcode project located at 02-managing-local-view-state-with-state-and-binding/02-instruction/Starter/Counter.xcodeproj.

For this lesson, all the app’s code is consolidated in CounterApp.swift for demonstration purposes. This approach allows you to easily observe the data flow throughout the entire app. However, in a real-world scenario, it is best practice to create separate files for each view to maintain a clean and organized codebase.

Step 1: Create the ControlPanel View

Currently, the Counter app has all the counter UI and logic inside CounterView. To divide the view, you’ll need to create new views for ControlPanel and Console. Start by building ControlPanel.

Add the following ControlPanel view at the end of CounterApp.swift:

struct ControlPanel: View {
  // 1
  @State private var count: Int = 0

  // 2
  var body: some View {
    HStack {
      // 3
      Button("Increment") {
        count += 1
      }
      Button("Decrement") {
        count -= 1
      }
    }
  }
}

In this code:

  1. @State private var count: Int = 0: Declares a private state variable count initialized to 0. The @State property wrapper manages this variable, which is local to ControlPanel and modifiable within this view.
  2. Body: Contains a horizontal stack (HStack) that aligns the increment and decrement buttons side by side.
  3. Buttons: Each button modifies the count:
  • Increment Button: Adds 1 to count when tapped.
  • Decrement Button: Subtracts 1 from count when tapped.

Step 2: Integrate ControlPanel Into CounterView

Incorporate ControlPanel into CounterView so the counter view can display the control panel buttons.

In CounterView‘s body, replace the button’s HStack with:

ControlPanel()

This places ControlPanel within the stack, managing its own state locally at this stage.

Evaluating the Current Solution

Review the integrated code:

struct CounterView: View {
  @State private var count: Int = 0

  var body: some View {
    VStack {
      Text("Count: \(count)")
      ControlPanel()
    }
  }
}

struct ControlPanel: View {
  @State private var count: Int = 0

  var body: some View {
    HStack {
      Button("Increment") {
        count += 1
      }
      Button("Decrement") {
        count -= 1
      }
    }
  }
}

Notice how there are two state properties managing the count — one in the original CounterView and one in the new ControlPanel. If you were to run the project, what do you think would happen? Tapping the Increment and Decrement buttons would no longer result in the count display changing. While tapping these buttons changes the internal state of ControlPanel, it’s CounterView whose count is shown on the display. The two views are out of sync.

Implementing a Single Source of Truth With @Binding

It’s a best practice in SwiftUI to manage state in one place and allow changes to propagate throughout the app. Without a single source of truth, values can easily become out of sync, leading to defects and displaying incorrect data. Sound familiar? Maintaining a single source of truth ensures consistency and reduces errors.

To address the issue of having two sources of state, you’ll utilize @Binding. Instead of having a separate state in ControlPanel, you’ll pass a binding from the parent CounterView, which will be the source of truth. The binding will allow the ControlPanel subview to modify the parent view’s count state directly, keeping its value synchronized for both views.

Step 1: Update ControlPanel to Use @Binding

Modify ControlPanel to use a binding instead of its own state. This allows the parent CounterView to pass count into ControlPanel so that ControlPanel can change it.

Replace @State private var count: Int = 0 with this line of code inside ControlPanel:

@Binding var count: Int

This line declares a binding to an integer variable. Unlike @State, which owns its data, @Binding does not own the data it holds. Instead, it references a piece of data managed elsewhere, typically in a parent view.

Step 2: Pass the @State as @Binding From CounterView

Update CounterView to pass @State as a @Binding to ControlPanel so that ControlPanel can change count. Remember, passing the parent’s state allows the app to have a single source of truth for the count.

Update ControlPanel() in CounterView’s body with this line:

ControlPanel(count: $count)

ControlPanel(count: $count) demonstrates how to pass a state variable as a binding. The $ symbol before count transforms the state into a binding. When you pass $count to ControlPanel, you’re not giving it the actual value but a reference to the value. This allows ControlPanel to modify the original state directly. Any changes made via ControlPanel will instantly reflect in CounterView.

You can also remove the HStack since it’s extraneous.

Trying It Out

Build and run the app.

Tap the Increment and Decrement buttons to see how the count updates across both views seamlessly. Using @Binding not only simplifies the code by avoiding state duplication but also aligns with SwiftUI’s design philosophy for state management.

Recap: Declaring and Passing a Binding

Here’s a brief overview of the steps to implement a binding in SwiftUI, which you practiced in the Counter app:

  1. Declare a binding in the child view: Start by declaring a @Binding variable in the child view. This establishes a link to the state managed by the parent view without initializing it directly in the child.

  2. Initialize state in the parent view: In the parent view, define the state using @State. This state acts as the single source of truth between the bound views.

  3. Pass the state as a binding to the child view: When incorporating the child view within the parent, pass the state as a binding. Use the $ prefix on your state variable to create this binding, enabling the child view to modify the parent’s state directly.

By following these steps, you create a seamless two-way connection between the parent and child views, allowing for dynamic updates across your user interface.

Propagating State Changes

Now that you’ve successfully built ControlPanel and connected it using binding, the next step is to refactor the text display that shows the count into a separate Console view. This will help you practice changing state in one subview and reflecting that change in another subview within the same view hierarchy. In effect, the state will be propagated from a single source of truth to multiple other views.

Step 1: Create a New Console View

Build the Console view so the count can be displayed from Console, which will be a subview of CounterView.

Add the following code at the end of CounterApp.swift:

// 1
struct Console: View {
  // 2
  let count: Int

  // 3
  var body: some View {
    Text("Count: \(count)")
  }
}
  1. struct definition: This defines a new SwiftUI view named Console. It’s a dedicated component for displaying the count.
  2. let count: Int: This line declares a constant property that will hold the count value. By making count a constant passed through the initializer, Console becomes a read-only view that can display but not modify the count. However, the view can display updated values when CounterView’s count changes. A new instance of Console will be created with every new count value.
  3. Body with Text view: The body of Console contains a single Text view that displays the count. Whenever the count changes in the parent view, SwiftUI automatically recreates this view to reflect the new count.

Step 2: Update CounterView to Use Console and Pass the Count to Console

Now, integrate the new Console view into the main view, CounterView. This will allow CounterView to create a new Console view with the current count on initial display and whenever count changes.

Replace Text("Count: \(count)") from the VStack in CounterView’s body with:

Console(count: count)

This creates a new Console view with the current count by passing the current count into Console’s initializer.

You’ll notice that Console and ControlPanel views are declared very similarly:

VStack {
  Console(count: count)
  ControlPanel(count: $count)
}

But why does Console review count whereas ControlPanel receives $count? Remember, ControlPanel needs to modify the shared count state. In that case, a binding is used and is signified by the $ character.

Build and run the app.

Observe how the structured views interact:

  • The Console view displays the current count.
  • ControlPanel provides the user interface for changing the count. Changes made here will reflect instantly in both Console and ControlPanel due to the binding and state management setup.

There’s a lot going on here, so let’s take a moment to break this process down further.

Understanding Data Flow and State Propagation

Here’s the data flow, step by step:

  1. State ownership: The CounterView owns and initializes the count state to 0. It’s the single source of truth.
  2. First UI render: On the first UI render, CounterView displays the initial count in the Console view and displays buttons in ControlPanel to change the count.
  3. User interaction: When a user presses a button in ControlPanel, the count binding allows the buttons in this subview to directly modify the count stored in CounterView.
  4. State change propagation: As count is a state property, any change triggers CounterView to recompute its body. This recomputation involves creating a new Console view, passing it the updated count.
  5. UI updates: The new Console view uses the updated count to re-render its Text view, reflecting the new count value.

This approach demonstrates the power of SwiftUI’s state management and the importance of maintaining a single source of truth for any piece of state in your apps. With the correct setup, state will propagate effortlessly to all affected components.

By learning how to use @Binding to pass, change, and reflect state throughout an app, you’re well on your way to becoming proficient in building dynamic and responsive SwiftUI apps.

Passing Bindings to SwiftUI Controls

Now that you understand how to pass a mutable state between custom subviews, it’s time to learn how to pass bindings into SwiftUI’s built-in user controls. This will allow you to create interactive interfaces where controls can modify your app’s state directly. Next, you’ll explore how this works with common SwiftUI controls like toggles and text fields.

Toggles

A Toggle in SwiftUI is a control that allows users to switch between on and off states. It requires a binding to a Boolean value. When the user interacts with the toggle, the bound Boolean value updates automatically, reflecting the current state of the toggle.

For example:

@State private var isSwitchedOn: Bool = false

var body: some View {
  Toggle("Enable Feature", isOn: $isSwitchedOn)
}

What’s the source of truth for state in this example? It’s the isSwitchedOn @State variable. Why is Toggle passed $isSwitchedOn? The $ character signifies a binding. A binding is used because Toggle doesn’t own the isSwitchedOn state but needs to:

  • Know its value to render its own UI properly.
  • Modify it when a user interacts with the UI.

Text Fields

Similarly, a TextField in SwiftUI uses a binding to a string value, allowing the text field to update its UI as the user types. The text field’s content is directly bound to state, making it easy to capture and respond to user input.

For example:

@State private var username: String = ""

var body: some View {
  TextField("Username", text: $username)
}

What’s the source of truth for state in this example? Why is TextField passed $username? [TODO: FPE: Should these questions be answered?]

Bindings Are Two-Way

So far, you’ve seen how a subview can change the state of a parent view. It’s important to note that this interaction isn’t one-sided; a parent can also modify its own state, even if it’s passed down as a binding to a subview. This indicates that bindings in SwiftUI are inherently two-way: Changes in state can originate from either the parent or the subview, and updates are reflected across both. This two-way flow ensures that your UI components remain synchronized. It’s important to keep this in mind as you design view hierarchies for your app.

Wrapping Up

Understanding how to bind state to both custom and provided SwiftUI views is crucial for creating dynamic and responsive apps. You’ve now learned to not only manage state within your custom views but also how to leverage SwiftUI’s powerful data-binding capabilities with built-in controls.

In the upcoming video demo, you’ll put these concepts into practice by building an entry form in a budget-tracking app. This exercise will involve integrating various SwiftUI controls with bindings, allowing you to apply what you’ve learned.

See forum comments
Download course materials from Github
Previous: Introduction Next: Building Financial Entry Form Using @State & @Binding Demo