Why does a custom Binding in TabView trigger re-initialization of child View models, while a direct Binding does not?

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:

  1. If I use TabView(selection: $selection), the model is initialized once and never again.

  2. 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?

This post covers this pretty well Bindings — Chris Eidhof

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

import KeyPathMapper // product of swift-keypaths-extensions
import Hashed // transitive dependency of KeyPathMapper

extension KeyPathMapper where Value: Equatable {
  subscript(onChange action: Hashed<(Value, Value) -> Void>) -> Self {
    get { self }
    set {
      if self != newValue {
        action.wrappedValue(self.value, newValue.value)
      }
      self = newValue
    }
  }
}

Int(0)[mapPath: \.[onChange: Hashed( // hashable struct
  {
    print("old:", $0)
    print("new:", $1)
  },
  by: .uncheckedSendable(true)) // constant hashable
]]
1 Like

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)
            }
        }
    }
}