0

When setting up a SwiftUI TabView with two tabs, the onChange closures of inactive (non visible) tabs that listen to a @State or @Binding variable will always get triggered when the variable is changed from the active tab.

I'd expect as long as the tab is not active the onChange should not get triggered. Basically the same it works when using two different navigation links. But I assume somehow it keeps the inactive tab fully alive in the background.

I've tried assigning tags to each tab view so that the view can identify the active tab. I read that this could solve the issue, however it does not.

Here is a simple SwiftUI app that shows the issues. When the second tab is active and I click the button, the onChange of the first tab will get triggered and print to the console.

Let me know if I need to provide a different example or if there are questions.

Note: This happens also when using onReceive and for example listening to the changes of an @Published variable of an ObservableObject.

import SwiftUI

struct ContentView: View {

    @State var tabViewSelection: String = "Ant"
    @State var value: Bool = false

    var body: some View {

        TabView(selection: $tabViewSelection) {

            AntView(value: $value)
                .tag("Ant")
                .tabItem {
                    Image(systemName: "ant")
                }

            LadybugView(value: $value)
                .tag("Ladybug")
                .tabItem {
                    Image(systemName: "ladybug")
                }
        }
    }
}

struct AntView: View {
    @Binding var value: Bool

    var body: some View {
        Text("AntView")
            .onChange(of: value) { value in
                print("onChange new value = \(value)") // <-- will get triggered...
            }
    }
}

struct LadybugView: View {
    @Binding var value: Bool

    var body: some View {
        VStack {
            Text("LadybugView")
            Button(action: {
                value.toggle() // <-- ...when changing value here.
            }, label: {
                Text("Toggle value")
            })
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Marco Boerner
  • 1,243
  • 1
  • 11
  • 34
  • setup a different binding for each of them. Eg. (antViewValue = false, ladybugViewValue = false) then bind them to the appropriate view. – xTwisteDx Aug 03 '21 at 16:11
  • I was thinking of something like that, but the problem is it needs to be the same binding because in my app it's a published value from a single store. – Marco Boerner Aug 03 '21 at 16:15
  • 1
    Then whenever the value is published, use some logic to determine which value should be changed. For example, `if antView == true { antViewValue = true } else { ladyBugViewValue = true }` – xTwisteDx Aug 03 '21 at 16:40
  • I guess I could check inside the onChange closure which tab is active. And it does work with `if tabViewSelection == "Ant" { ... }` adding tabViewSelection as a `@Binding` to the AntView. Was hoping there is a solution that doesn't require those extra checks. I'm just surprised that the onChange is triggering at all after the view has disappeared. – Marco Boerner Aug 03 '21 at 16:59
  • 1
    Appeared/Disappeared is just a state, whereas `.onChange` is a function call to entity which `just exists` and originated from a referenced source of truth... so conceptually you just have wrong expectation - it behaves as it should by design. How to react on that is your app logic, so condition inside is a right way. – Asperi Aug 03 '21 at 17:31
  • Thanks @Asperi I'll use the condition then. Can I assume that those views then still exist and are not destroyed? Whereas when using two navigation links the other view would be destroyed? – Marco Boerner Aug 03 '21 at 17:38
  • @xTwisteDx if you want to form an answer that uses conditions I'll mark it as correct. Thanks for your help! : ) – Marco Boerner Aug 03 '21 at 17:38

1 Answers1

1

The first thing you want to do is to separate your bindings.

struct ContentView: View {

    @State var tabViewSelection: String = "Ant"
    @State var antViewValue: Bool = false
    @State var ladyViewValue: Bool = false

        var body: some View {
    
            TabView(selection: $tabViewSelection) {
    
                AntView(value: $antViewValue)
                    .tag("Ant")
                    .tabItem {
                        Image(systemName: "ant")
                    }
    
                LadybugView(value: $ladyViewValue)
                    .tag("Ladybug")
                    .tabItem {
                        Image(systemName: "ladybug")
                    }
            }
        }
    }

Then once you've got them separated you'll want to use some logic to determine which value needs to be updated and at what time.

Button(action: { 
        //Add a condition to update whatever value you're wanting to update.
        if ladyViewValue == true {
            ladyViewValue.toggle()
        } else {
            antViewValue.toggle()
        }
    }, label: { 
        Text("Toggle value")
    })

Ultimately you're splitting your value into two possible cases. This is a common thing that you'll do and there is a myriad of different ways to do this. This is a simple example and a critical foundation skill. The best takeaway that you can get from this is learning to break things down into their smallest form.

xTwisteDx
  • 2,152
  • 1
  • 9
  • 25
  • I think we slightly misunderstood each other in the following comments below the question. This is a good answer that ultimately lead me to the right way and will get my upvote. However in my case breaking down my state into different bindings adds a lot of extra code and is a bit confusing. Using a condition inside the onChange that checks if the current view is active and only then executing the closure works perfect. : ) – Marco Boerner Aug 03 '21 at 17:57