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:
-
@State private var count: Int = 0: Declares a private state variablecountinitialized to 0. The@Stateproperty wrapper manages this variable, which is local toControlPaneland modifiable within this view. -
Body: Contains a horizontal stack (
HStack) that aligns the increment and decrement buttons side by side. -
Buttons: Each button modifies the
count:
-
Increment Button: Adds 1 to
countwhen tapped. -
Decrement Button: Subtracts 1 from
countwhen 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:
-
Declare a binding in the child view: Start by declaring a
@Bindingvariable in the child view. This establishes a link to the state managed by the parent view without initializing it directly in the child. -
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. -
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)")
}
}
-
structdefinition: This defines a new SwiftUI view namedConsole. It’s a dedicated component for displaying the count. -
let count: Int: This line declares a constant property that will hold the count value. By makingcounta constant passed through the initializer,Consolebecomes a read-only view that can display but not modify the count. However, the view can display updated values whenCounterView’s count changes. A new instance ofConsolewill be created with every new count value. -
Body with
Textview: The body ofConsolecontains a singleTextview 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
Consoleview displays the current count. -
ControlPanelprovides the user interface for changing the count. Changes made here will reflect instantly in bothConsoleandControlPaneldue 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:
-
State ownership: The
CounterViewowns and initializes the count state to 0. It’s the single source of truth. -
First UI render: On the first UI render,
CounterViewdisplays the initial count in theConsoleview and displays buttons inControlPanelto change the count. -
User interaction: When a user presses a button in
ControlPanel, thecountbinding allows the buttons in this subview to directly modify the count stored inCounterView. -
State change propagation: As
countis a state property, any change triggersCounterViewto recompute its body. This recomputation involves creating a newConsoleview, passing it the updated count. -
UI updates: The new
Consoleview uses the updated count to re-render itsTextview, 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.