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 UIViewController
s.
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
'sparentViewController: UIViewController?
property to get the parent of aUIViewController
- 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
'spresentingViewController: UIViewController?
property to get the view controller that is presenting our view controllerUIViewController
'spresentedViewController: UIViewController?
property to get the view controller presented by our view controller
SwiftUI's View Controller Hierarchy
Moving back to SwiftUI, UIViewController
s are just gone (for argument's sake, let's forget about UIHostingController
). This is because:
- container view controllers are now just
View
s. For exampleUINavigationController
isNavigationView
, andUITabBarController
isTabView
:
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 UIView
s or UIViewController
s, 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:
- 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 🔴
└── ... 🔴
- set it on the
TabView
(or aTabView
'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:
- manually set the values in each
NavigationLink
'sdestination
:
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 🔴
- 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!