I’m running into a strange behavior in SwiftUI where using a custom Binding for TabView selection causes all child views (and their associated State models) to re-initialize every time the tab changes.
The Problem: I need to intercept a "re-tap" on a specific tab (so i could disable "scroll to top" action).
When I use the Custom Binding, the init() for child view models is called every single time I switch tabs. When I use a Direct Binding, this doesn't happen.
Code Example:
import SwiftUI
struct ContentView: View {
@State private var selection = 1
private var selectionBinding: Binding<Int> {
Binding(
get: { selection },
set: { newValue in
if newValue == selection {
if newValue == 2 {
print("; Tab 2 tapped again")
}
} else {
selection = newValue
}
}
)
}
var body: some View {
TabView(selection: selectionBinding) {
Tab("Tab 1", systemImage: "1.circle.fill", value: 1) {
Text("Tab 1")
}
Tab("Tab 2", systemImage: "2.circle.fill", value: 2) {
Tab2()
}
}
}
}
struct Tab2: View {
@State private var model = Tab2Model()
var body: some View {
Text("Tab 2")
}
}
class Tab2Model {
init() { print("; \(#function) called") }
}
What I've observed:
If I use TabView(selection: $selection), the model is initialized once and never again.
If I use TabView(selection: selectionBinding), the model is initialized every time I click a new tab.
Is there a better way to intercept a tab re-tap without forcing the entire TabView to re-evaluate its child initializers?
The solution is to derive bindings from other bindings through dynamicMemberLookup using properties instead of custom inits i.e.
struct ContentView: View {
@State
private var selection: Int = 1
// private var selectionBinding: Binding<Int> {
// $selection._printingChanges
// }
var body: some View {
TabView(selection: $selection._printingChanges) {
Tab("Tab 1", systemImage: "1.circle.fill", value: 1) {
Text("Tab 1")
}
Tab("Tab 2", systemImage: "2.circle.fill", value: 2) {
Tab2()
}
}
}
}
extension Hashable {
// If you need custom args, you have to use
// a subscript instead of a property
fileprivate var _printingChanges: Self {
get { self }
set {
print("old:", self)
print("new:", newValue)
self = newValue
}
}
}
Also I recently generalized this approach to avoid namespace pollution, the API requires a bit more boilerplate on the declaration site, however for me it’s an acceptable tradeoff for keeping my namespaces a bit cleaner
Btw if you need to run a custom action while keeping derived bindings, you can wrap this action into a Hashable struct and pass it through the subscript, here is the example
Hi Maxim, thanks for the response and it does fix the issue in the sample code. But my use case in the actual app is a bit more nuanced. I understood why the get/set Bindings don’t work now, but I’m struggling to understand the KeyPathMapper part.
I’m wondering if it’s possible to achieve the same behavior without using the library. My actual use case is fairly simple: I have another property in my view,
@State private var tabTwoTappedTwice: Bool
and I want to set it whenever the user taps a particular tab twice.
I get the idea that we could pass a Hashable custom action, but I’m not sure how to implement that. Would you be able to show a naive approach that works without KeyPathMapper or other dependencies? It’s just a one-time use case in my app.
struct ContentView: View {
@State private var selection = 1
// this needs to be toggled when the tap two is tapped twice
@State private var tabTwoTappedTwice: Bool = false
var body: some View {
TabView(selection: $selection._printingChanges) {
Tab("Tab 1", systemImage: "1.circle.fill", value: 1) {
Text("Tab 1")
}
Tab("Tab 2", systemImage: "2.circle.fill", value: 2) {
Tab2()
// this is how i would pass into my view to handle this action
.environment(\.tabTwoTappedTwice, tabTwoTappedTwice)
}
}
}
}