Search code examples
swiftuitabviewswiftui-environment

Is injecting data using the environment equivalent to passing data in parameters?


I've often read that instead of passing data up through a view hierarchy with parameters I can instead inject data using an environment variable. But I've run into the following asymmetry (question at the end)...

I created a View containing a paging TabView which contains two views "Side-by-side", so I can swipe between them or select the other view programmatically by pressing a (red) button. I've done this by passing the selection as a parameter and this works as expected. I used a random background color to verify that the two views are not redrawn but simply "slipped into the iPhones display".

struct SwipeTabEnvView: View {
    @State var selectedTab = 1
    
    var body: some View {
        SwipeTabView(selectedTab: $selectedTab)
    }
}

struct SwipeTabView: View {
    
    @Binding var  selectedTab: Int
    
    var body: some View {
        VStack {
            Text("Main View")
            
            // A paged tabview containing two vies side by side.
            // Swipe within this main view to view the "off-screen" other view, or
            // click the red button to select it dynamically.
            TabView(selection: $selectedTab) {
                
                tab(tabID: 1, selectedTab: $selectedTab)
                    .tag(1)
                
                tab(tabID: 2, selectedTab: $selectedTab)
                    .tag(2)               
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
            .background(Color.rndCol())         
        }    
        .background(Color.rndCol())
    }
}

struct tab: View {
    var tabID: Int
    @Binding var selectedTab: Int
    
    var body: some View {
        VStack {
            Text("Tab \(tabID) ").foregroundColor(.black)

            Button(action:  {
                withAnimation{
                    selectedTab = tabID == 1 ? 2 : 1
                }
            }, label: {
                ZStack {
                    circle
                    label
                }
            })
        }
        
    }

    var circle: some View  {
        Circle()
            .frame(width: 100, height: 100)
            .foregroundColor(.red)
    }
    
    var label: some View {
        Text("\(tabID == 1 ? ">>" : "<<")").font(.title).foregroundColor(.black)
    }
}

extension Color {
    static func rndCol() -> Color {
        Color(
            red: .random(in: 0.5...1),
            green: .random(in: 0.5...1),
            blue: .random(in: 0.5...1)
        )
    }
}

I then changed the code to pass the selection as an injected environment variable but now every time I switch what is displayed by either swiping or pressing the button the views are completely redrawn. I can tell this is the case because the background colors of the views change.

@Observable class envTab {
    var selectedTab = 1
}

struct SwipeTabEnvView: View {
    @State var varEnvTab = envTab()
    
    var body: some View {
        SwipeTabView()
            .environment(varEnvTab)
    }
}

struct SwipeTabView: View {
    @Environment(envTab.self) var viewEnvTab
    
    var body: some View {
        VStack {
            Text("Main View")
            
            TabView(selection: Bindable(viewEnvTab).selectedTab) {
                tab(tabID: 1)
                    .tag(1)
                
                tab(tabID: 2)
                    .tag(2)               
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
            .background(Color.rndCol())           
        }
        .background(Color.rndCol())
    }    
}


struct tab: View {
    var tabID: Int
    @Environment(envTab.self) var viewEnvTab
    
    var body: some View {
        VStack {
            Text("Tab \(tabID) ").foregroundColor(.black)
            
            Button(action:  {
                withAnimation{
                    viewEnvTab.selectedTab = tabID == 1 ? 2 : 1
                }
            }, label: {
                ZStack {
                    circle
                    label
                }
            })
        }
    }
    
 ...
}

If the complete views are redrawn this will cost performance and should be avoided.

I'm worried that this is not just a quirk of TabViews but a more general aspect of injecting. I'd thought that injecting data instead of passing data as parameters was a valid way of structuring code to enable steep hierarchies, but having seen this behaviour I now wonder if the injection method is not equivalent to passing parameters and should be avoided?

BTW: Making the selectedTab @ObservationIgnored stops the redrawing behavior but also prevents programatically switching the tab displayed.


Solution

  • I'm worried that this is not just a quirk of TabViews but a more general aspect of injecting

    This is just a quirk of TabView. SwiftUI fails to recognise that the selectedTab state is a dependency of SwipeTabView, and hence doesn't call SwipeTabView.body when it changes.

    A Picker for example, doesn't have this problem. Try replacing the tab view with a Picker,

    Picker("Picker", selection: $selectedTab) {
        Text("1").tag(1)
        Text("2").tag(2)
    }
    .pickerStyle(.segmented)
    .background(Color.rndCol())
    

    The background color changes when you change the picker selection, which is the expected behaviour. After all, selectedTab is a dependency of SwipeTabView, so SwipeTabView.body should be called when selectedTab changes.

    This has nothing to do with the environment. All you need to do to get the expected behaviour is to have SwiftUI correctly recognise that selectedTab is a dependency. Using @Observable achieves that, because SwiftUI handles these objects differently. It doesn't have to be in the environment:

    @Observable
    class SomeObservable {
        var selectedTab = 1
    }
    
    struct SwipeTabView: View {
        
        @State var observable = SomeObservable()
        
        var body: some View {
            VStack {
                Text("Main View")
                TabView(selection: $observable.selectedTab) {
                    
                    tab(tabID: 1, selectedTab: $observable.selectedTab)
                        .tag(1)
                    
                    tab(tabID: 2, selectedTab: $observable.selectedTab)
                        .tag(2)
                }
                // ...
    

    Yet another way is to simply call the getter of selectedTab in SwipeTabView.body.

    struct SwipeTabView: View {
        
        @State var selectedTab = 1
        
        var body: some View {
            let x = selectedTab // this calls the getter of State.wrappedValue
            VStack {
                Text("Main View")
                TabView(selection: $selectedTab) {
                    // ...