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

I quickly prototyped dependency-free version

Binding+onDidSet.swift

import SwiftUI

// MARK: - Core

extension Binding {
	@usableFromInline
	struct HashableDidSetAction: Hashable {
		@usableFromInline
		static func == (lhs: Self, rhs: Self) -> Bool {
			lhs.id._isEqualToAnyEquatable(rhs.id)
		}

		private var id: any Hashable
		fileprivate var action: (Value, Value) -> Void

		@usableFromInline
		init(id: any Hashable, action: @escaping (Value, Value) -> Void) {
			self.id = id
			self.action = action
		}

		@usableFromInline
		func hash(into hasher: inout Hasher) {
			self.id.hash(into: &hasher)
		}
	}
}

// MARK: - Public API

extension Binding where Value: Hashable {
	/// Tracks didSet event without breaking SwiftUI identity tracking
	///
	/// - Note: I'm not 100% sure that this function wouldn't break anything,
	///         I use subscripts directly. It has to be tested, however
	///         it looks fine to me 🌚
	///
	/// - Parameters:
	///   - id: Identifier for the action, default is 0, 
	///         usually there is no need to specify it explicitly
	///   - action: Action to execute after a new value is set
	@inlinable
	public func onDidSet(
		_ id: any Hashable = 0,
		perform action: @escaping (Value, Value) -> Void
	) -> Self {
		self[onDidSet: .init(id: id, action: action)]
	}
}

// MARK: - Internal implementation

extension Hashable {
	// You can also provide such subscripts for other standard container types
	// like Optional, Result, Array etc. but Hashable covers a ton of them.
	// And provide corresponding functions for the public API
	//
	// Unfortunately it's not possible to extend Any to enable such APIs
	// for all types automatically (or fortunately since there is less space
	// for namespace pollution) 💁‍♂️
	@usableFromInline
	subscript(onDidSet action: Binding<Self>.HashableDidSetAction) -> Self {
		get { self }
		set {
			let oldValue = self
			self = newValue
			action.action(oldValue, newValue)
		}
	}
}

extension Equatable {
	fileprivate func _isEqualToAnyEquatable(_ other: any Equatable) -> Bool {
		guard let other = other as? Self else { return false }
		return self == other
	}
}

MyView.swift

struct MyView: View {
	@State var selection = 0
	@State var isMultipleOf2: Bool = true

	var body: some View {
		TabView(selection: $selection.onDidSet { old, new in
			isMultipleOf2 = new.isMultiple(of: 2)
		}) {
			Tab(value: 0) {
				Text("First")
			}
			Tab(value: 1) {
				Text("Second")
			}
		}
	}
}