Extensions to SwiftUI Binding, that don't cause extra redraws.
When a view redraws, every Binding(get:set:) it creates is a new instance. SwiftUI treats new binding instances as changes, causing child views to redraw unnecessarily even if the underlying values haven't changed.
BetterBinding solves this by using hashable subscripts instead of closures, allowing SwiftUI to recognize when bindings are equivalent and avoid unnecessary redraws.
Run the demo app to see this in action.
Use BetterBinding when:
- Transforming optional to boolean
- Selecting from enums/sets with boolean toggles
- Accessing collection elements by ID
- Adding logging/analytics to bindings
- Using standard transformation patterns
Use Binding(get:set:) when:
- Custom business logic is required
- Non-standard type transformations
- Complex multi-step computations in get/set
- The pattern doesn't match any BetterBinding extension
Instead of:
@State var username: String?
Toggle("Has username", isOn: Binding(
get: { username != nil },
set: { newValue in
if newValue {
username = "Guest"
} else {
username = nil
}
}
))You can use:
@State var username: String?
Toggle("Has username", isOn: $username.hasValue(default: "Guest"))Instead of:
@State var count: Int?
TextField("Count", value: Binding(
get: { count ?? 0 },
set: { count = $0 }
), format: .number)You can use:
@State var count: Int?
TextField("Count", value: $count.withDefault(0), format: .number)Instead of:
@State var selectedTags: Set<String> = []
Toggle("Swift", isOn: Binding(
get: { selectedTags.contains("Swift") },
set: { newValue in
if newValue {
selectedTags.insert("Swift")
} else {
selectedTags.remove("Swift")
}
}
))You can use:
@State var selectedTags: Set<String>
Toggle("Swift", isOn: $selectedTags.contains("Swift"))Instead of:
@State var theme: Theme = .light
Toggle("Dark mode", isOn: Binding(
get: { theme == .dark },
set: { newValue in
theme = newValue ? .dark : .light
}
))You can use:
@State var theme: Theme = .light
Toggle("Dark mode", isOn: $theme.equals(to: .dark, default: .light))Instead of:
@State var items: [Item]
let itemID: UUID // The ID of the item to edit
TextField("Name", text: Binding(
get: { items.first(where: { $0.id == itemID })?.name ?? "" },
set: { newValue in
if let index = items.firstIndex(where: { $0.id == itemID }) {
items[index].name = newValue
}
}
))You can use:
@State var items: [Item]
TextField("Name", text: $items.element(with: itemID).name.withDefault(""))Run the demo target (Demo/BetterBindingDemo) and watch the console. If you tap the first toggle, and then the second, you should see output similar to this.
ContentView body drawn
ContentView: _getSetFlag changed.
NestedToggle(Binding(get:set:)) body drawn
NestedToggle: @self, _binding changed. ← ✓ Expected redraw (BetterBinding does not redraw)
ContentView body drawn
ContentView: _betterFlag changed.
NestedToggle(Binding(get:set:)) body drawn
NestedToggle: @self, _binding changed. ← ⚠️ Unnecessary redraw!
NestedToggle(BetterBinding) body drawn
NestedToggle: @self, _binding changed. ← ✓ Expected redrawThe important detail is which toggles are redrawn and report that _binding changed. When _getSetFlag changes, only the NestedToggle containing the Binding(get:set:) is redrawn, the BetterBinding remains stable and isn't redrawn even though ContentView redraws. When _betterFlag is changed, both toggles are redrawn: the BetterBinding toggle redraws because its value changed (expected), but the Binding(get:set:) toggle also redraws unnecessarily because the binding is recreated even though getSetFlag didn't change.
This shows that Binding(get:set:) is recreated whenever anything triggers the containing view to redraw, whereas the BetterBinding binding keeps its identity and only redraws when its value actually changes.