A screen that never changes is only a picture. To react to taps and typing, SwiftUI needs a place to keep changing data. That place is state. The rule of SwiftUI is simple and strict: *when state changes, the views that read it are drawn again*. You never say *update this label*. You only change the data.
@State
A view is a struct, and structs are values that SwiftUI creates and throws away often. So an ordinary var inside a view would be lost. Putting @State in front of a property tells SwiftUI to keep that value safely for the life of the view and to redraw the view when it changes. By habit, state is private, because the view that owns it is the one that changes it.
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 16) {
Text("Taps: \(count)")
.font(.title)
Button("Add one") {
count += 1
}
.buttonStyle(.borderedProminent)
Button("Reset", role: .destructive) {
count = 0
}
}
.padding()
}
}
The closure after Button("Add one") is the code that runs on a tap. It changes count. SwiftUI sees the change and draws the Text again with the new number. There is no other step. Notice role: .destructive, which makes the button red, because a button that throws things away should look different.
The dollar sign: bindings
A text field must both *show* a value and *change* it when the user types. A plain value cannot do the second job. A binding is a two-way connection to a value that lives somewhere else. You make one from a state property by putting a dollar sign in front of its name.
struct AddExpenseForm: View {
@State private var title = ""
@State private var amountText = ""
@State private var isRecurring = false
var body: some View {
VStack(spacing: 12) {
TextField("What did you buy?", text: $title)
.textFieldStyle(.roundedBorder)
TextField("Amount", text: $amountText)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
Toggle("Repeats every month", isOn: $isRecurring)
Text("You typed: \(title)")
.foregroundStyle(.secondary)
}
.padding()
}
}
title(no dollar sign) is the value. Use it to *read*.$titleis a binding. Use it to let another view *read and write* the same value, asTextFieldandToggledo.- As you type, the text field writes to
title, SwiftUI notices, and theTextbelow updates on each key press.
@Binding: sharing state with a child view
When you split a form into smaller views, the child needs to change the parent's data. Give the child a @Binding property. The parent passes $value, and the child reads and writes it, without owning it. There is still only one real copy of the data, which is why the parent and child can never disagree.
struct AmountField: View {
@Binding var text: String
var body: some View {
HStack {
Text("$")
TextField("0.00", text: $text)
.keyboardType(.decimalPad)
}
.padding(10)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
}
struct ParentView: View {
@State private var amountText = ""
var body: some View {
VStack {
AmountField(text: $amountText)
Text("Value: \(amountText)")
}
.padding()
}
}
Turning text into a number
A text field holds a String, but an amount is a Double. Lesson 5 gave you the tool: Double(amountText) returns an optional. Use it to decide whether the form is valid, and disable the button when it is not.
private var amount: Double? { Double(amountText) }
private var isValid: Bool {
!title.trimmingCharacters(in: .whitespaces).isEmpty && (amount ?? 0) > 0
}
// in body:
Button("Add expense") {
// save it
}
.disabled(!isValid)
What should be state?
- Data that changes and that the screen shows: a counter, a text field's text, whether a sheet is open.
- Not data that can be worked out from other state. A total is a *computed property*, not a second state value. Two copies of one fact can go out of step.
- Keep state as low as you can and as local as you can. If two views need to change it, put it in their nearest shared parent and pass a binding.
Common mistakes
- Changing a
varproperty that has no@State. The screen does not update, and Xcode may refuse to compile it. - Forgetting the dollar sign in
TextField(text: title). The error says it expected aBinding<String>. - Putting state in a child view when the parent also needs it.
Your task
- Build
CounterViewand add a button that subtracts one but never goes below zero. - Build the form with title, amount and a toggle, and show a live summary line under it.
- Move the amount field into its own view with
@Binding. - Disable the Add button until the title is not empty and the amount is above zero.
How to check it worked
- Typing in the field updates the summary line on every key press.
- The button turns on only when the form is valid.
- You can say what
@State,$titleand@Bindingeach do.