Advanced Environment propagation

In the first article of the SwiftUI environment series, we covered the foundations of this core SwiftUI component. Next, let's dive into more advanced use cases, which are a widespread source of ambiguity.

View presentation and environment propagation

Everything we've seen so far could be summarized as: children views inherit the environment from their parent (plus additional changes). Things get tricky when we add view presentation into the mix.

That is, navigating to other views or presenting sheets. Before diving into these, we need to step back and introduce one more topic: UIKit's view controller hierarchy.

The View Controller Hierarchy

Like the view hierarchy we covered we covered last time, we can also create a hierarchy among UIViewControllers.

We can split the view controllers parent-child relationships into two categories, contained and presenting.

Contained parent-child relationship

A given view controller might contain several child view controllers dedicated to different sections of the screen. When they do so, these controllers are called Container View Controllers, and are considered the parents of the child view controllers.

We can build our own container view controllers, and UIKit comes with a few pre-built ones, for example, UINavigationController and UISplitViewController.

It's essential to know and understand the relationship among its children.

Let's imagine that we have a navigation controller and have pushed two screens into the navigation stack. At this point, the navigation controller's viewControllers stack has three view controllers in total. this is the current hierarchy:

UINavigationController
├── RootViewController
├── FirstPushedViewController
└── SecondPushedViewController

Despite a view controller pushing the next into the navigation, all view controllers in the navigation stack are siblings, and all share the same parent view controller, the UINavigationController.

Next, if we take a look at an UITabBarController, we will see the same pattern again:

UITabBarController
├── FirstTabViewController
├── SecondTabViewController
└── ...

Note also that any container view controller can have one or more container view controllers as its children.

We use:

  • UIViewController's parentViewController: UIViewController? property to get the parent of a UIViewController
  • each container viewControllers: [UIViewController] property to get its children

We can print the view controller hierarchy at any time in lldb viapo UIWindow.value(forKeyPath: "keyWindow.rootViewController._printHierarchy")!

Presenting parent-child relationship

Instead of containing children, this relationship represents the connection between one view controller and the one presenting modally.

This relationship is established when we call present(_:animated:completion:) from a view controller to present another one.

In this case, the 1-1 relationship has the presenting view controller as the parent of the presented view controller:

PresentingViewController
└── PresentedViewController

In other words, unlike navigation, a controller presented modally will always have its presenting view controller as its parent. Here's an example of hierarchy where a modal view controller present another view controller modally, which then present another view controller modally:

NonModalViewController
└── FirstModalViewController
    └── SecondModalViewController
        └── ThirdModalViewController

We use:

  • UIViewController's presentingViewController: UIViewController? property to get the view controller that is presenting our view controller
  • UIViewController's presentedViewController: UIViewController? property to get the view controller presented by our view controller

SwiftUI's View Controller Hierarchy

Moving back to SwiftUI, UIViewControllers are just gone (for argument's sake, let's forget about UIHostingController). This is because:

  • container view controllers are now just Views. For example UINavigationController is NavigationView, and UITabBarController is TabView:
struct FSView: View {
  var body: some View {
    TabView {
      FirstTabView()
        .tabItem { Label("One", systemImage: "1.circle.fill") }
      SecondTabView()
        .tabItem { Label("Two", systemImage: "2.circle.fill") }
      ...
    }
  }
}
  • view presentations are controlled via bindings and view modifiers like sheet(isPresented:onDismiss:content:):
struct FSView: View {
  @State var showingSheet = false
  
  var body: some View {
    Button("Show sheet") {
      showingSheet.toggle()
    }
    .sheet(isPresented: $showingSheet) {
      FSSheetView()
    }
  }
}

Despite this, the view controller hierarchy has migrated 1-1 to SwiftUI. For example, these are the two view hierarchies for the last two examples:

  • TabBar example:
FSView
└── TabView
    ├── FirstTabView
    ├── SecondTabView
    └── ...
  • Sheet example (when the sheet is presented):
FSView
└── FSSheetView

SwiftUI Environment Propagation and View Controller Hierarchy

Since SwiftUI doesn't have UIViews or UIViewControllers, UIKit's view and view controller hierarchies are merged into a single hierarchy in SwiftUI.

SwiftUI's environment is propagated across views and view controllers without any behavior change. All the logic we've covered in the first article in the series still counts when dealing with view presentations.

In conclusion, understanding UIKit's view controller hierarchy enables us to understand advanced SwiftUI's environment propagation.

Let's now take a few examples to consolidate/confirm what we have uncovered. We will continue to use foregroundColor as our environment value.

TabView

struct FSView: View {
  var body: some View {
    TabView {
      FirstTabView()
        .tabItem { Label("One", systemImage: "1.circle.fill") }
      SecondTabView()
        .tabItem { Label("Two", systemImage: "2.circle.fill") }
      ...
    }
  }
}
FSView
└── TabView
    ├── FirstTabView
    ├── SecondTabView
    └── ...

If we set the foreground color to a specific tab, only that tab view and its descendants will inherit the color:

struct FSView: View {
  var body: some View {
    TabView {
      FirstTabView()
        .tabItem { ... }
      SecondTabView()
        .foregroundColor(.red) // 🔴
        .tabItem { ... }
      ...
    }
  }
}
FSView
└── TabView
    ├── FirstTabView
    ├── SecondTabView 🔴
    └── ...

All SecondTabView siblings won't be affected by any change made into SecondTabView's environment, as only the tab parent, TabView, can affect SecondTabView's siblings environment.

If we'd like to set the same foreground color to all tab views, we have two ways:

  1. set it to each tab
struct FSView: View {
  var body: some View {
    TabView {
      FirstTabView()
        .foregroundColor(.red) // 🔴
        .tabItem { ... }
      SecondTabView()
        .foregroundColor(.red) // 🔴
        .tabItem { ... }
      ...
        .foregroundColor(.red) // 🔴
    }
  }
}
FSView
└── TabView
    ├── FirstTabView 🔴
    ├── SecondTabView 🔴
    └── ... 🔴
  1. set it on the TabView (or a TabView's ancestor)
struct FSView: View {
  var body: some View {
    TabView {
      FirstTabView()
        .tabItem { ... }
      SecondTabView()
        .tabItem { ... }
      ...
    }
    .foregroundColor(.red) // 🔴
  }
}
FSView
└── TabView 🔴
    ├── FirstTabView 🔴
    ├── SecondTabView 🔴
    └── ... 🔴

Sheets

struct FSView: View {
  @State var showingSheet = false
  
  var body: some View {
    Button("Show sheet") {
      showingSheet.toggle()
    }
    .sheet(isPresented: $showingSheet) {
      FSSheetView()
    }
  }
}
FSView
└── FSSheetView

When we present sheets, the environment inherited by those sheets is the same as the environment seen by the associated sheet(...) modifier.

We can consider the sheet modifier as the anchor point of our sheet view, expanding the view controller hierarchy above:

FSView
└── sheet view modifier
    ├── Button
    └── FSSheetView

Note how the presented sheet, FSSheetView, and the view where the sheet is applied to, Button, are siblings.

With this in mind, we can study different scenarios:

  • the foreground color is set onto the sheet view modifier
struct FSView: View {
  @State var showingSheet = false
  
  var body: some View {
    Button("Show sheet") {
      showingSheet.toggle()
    }
    .sheet(isPresented: $showingSheet) {
      FSSheetView()
    }
    .foregroundColor(.red) // 🔴
  }
}
FSView
└── sheet view modifier 🔴
    ├── Button 🔴
    └── FSSheetView 🔴
  • the foreground color is set after the sheet view modifier
struct FSView: View {
  @State var showingSheet = false
  
  var body: some View {
    Button("Show sheet") {
      showingSheet.toggle()
    }
    .foregroundColor(.red) // 🔴
    .sheet(isPresented: $showingSheet) {
      FSSheetView()
    }
  }
}
FSView
└── sheet view modifier
    ├── Button 🔴
    └── FSSheetView
  • the foreground color is set on the sheet view
struct FSView: View {
  @State var showingSheet = false
  
  var body: some View {
    Button("Show sheet") {
      showingSheet.toggle()
    }
    .sheet(isPresented: $showingSheet) {
      FSSheetView()
        .foregroundColor(.red) // 🔴
    }
  }
}
FSView
└── sheet view modifier
    ├── Button
    └── FSSheetView 🔴

Which scenario we need/want to use is up to our needs. They're all acceptable and very justified to exist.

The same concepts are repeated when we have multiple sheet view modifiers and/or a chain of presented sheets.

iOS 13 vs iOS 14+

Before iOS 14 the environment was not propagated to the presented sheet. As we have seen in UIKit's view controller hierarchy, this was unexpected and is considered a bug in SwiftUI's environment behavior.

If our app targets iOS 13 and later, we need to keep this in mind and manually inject the environment values/objects that we need in our sheets.

Navigation

Imagine the following view definition:

struct FSView: View {
  var body: some View {
    NavigationView {
      FSRootView()
    }
  }
}

struct FSRootView: View {
  var body: some View {
    NavigationLink("tap", destination: FSFirstPushedView())
  }
}

struct FSFirstPushedView: View {
  var body: some View {
    NavigationLink("tap", destination: FSSecondPushedView())
  }
}

struct FSSecondPushedView: View {
  var body: some View {
    ...
  }
}

As we've seen in UIKit's view controller hierarchy, If we navigate from root to FSSecondPushedView, the resulting hierarchy will be:

FSView
└── NavigationView
    ├── FSRootView
    ├── FSFirstPushedView
    └── FSSecondPushedView

All pushed views (and root) are siblings, despite being declared in the NavigationLink of another view. In other words, NavigationLink's destination doesn't share the same environment seen by NavigationLink:

NavigationLink("tap", destination: DestinationView())
  .foregroundColor(.red) // 👈🏻 doesn't affect DestinationView

If we'd like to share environment values between pushing and pushed views, we have two options:

  1. manually set the values in each NavigationLink's destination:
struct FSView: View {
  var body: some View {
    NavigationView {
      FSRootView()
        .foregroundColor(.red) // 🔴
    }
  }
}

struct FSRootView: View {
  var body: some View {
    NavigationLink(
      "tap", 
      destination: FSFirstPushedView().foregroundColor(.red) // 🔴
    )
  }
}

struct FSFirstPushedView: View {
  var body: some View {
    NavigationLink(
      "tap", 
      destination: FSSecondPushedView().foregroundColor(.red) // 🔴
    )
  }
}

struct FSSecondPushedView: View {
  var body: some View {
    ...
  }
}
FSView
└── NavigationView
    ├── FSRootView 🔴
    ├── FSFirstPushedView 🔴
    └── FSSecondPushedView 🔴
  1. set the values in NavigationView or another common ancestor:
struct FSView: View {
  var body: some View {
    NavigationView {
      FSRootView()
    }
    .foregroundColor(.red) // 🔴
  }
}

...
FSView
└── NavigationView 🔴
    ├── FSRootView 🔴
    ├── FSFirstPushedView 🔴
    └── FSSecondPushedView 🔴

The latter option is recommended, especially when working with an app-wide state pattern.

Neither .navigationViewStyle(.stack) nor .isDetailLink(false) affect the environment propagation.

Conclusions

Despite SwiftUI being a massive departure from AppKit/UIKit, there's no way around it: to master SwiftUI, we need to master understand the UI frameworks SwiftUI relies on.

You've just completed the second entry of Five Stars' SwiftUI Environment series; make sure to subscribe via feed RSS or follow @FiveStarsBlog on Twitter to not miss out on upcoming entries and more deep dives.

Thank you for reading!

Questions? Feedback? Feel free to reach out via email or Twitter!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all

Explore iOS

Browse all