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.
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) {
// ...