Most SwiftUI modifiers modify the views beneath them in the hierarchy, but some modifiers modify views above them in the hierarchy. In my example below .navigationTitle
, .toolbar
, and .presentationDetents
are modifying elements of the navigation stack and sheet they're presented in, but they're installed on a text view.
Conceptually, how do these modifiers work internally? More-so, how might I go about creating one of my own?
I'm creating my own navigation stack (for irrelevant reasons) and have an approach that seems promising but I have doubts about it. My current approach is a view modifier that sets properties on an environment object, and the environment object is a view model for the custom navigation stack.
Preference values pass data up the view hierarchy, similar to how environment values pass data down the view hierarchy.
Preference values can be set using preference
, or modified using transformPreference
. These values can be read by onPreferenceChange
, or backgroundPreferenceValue
or overlayPreferenceValue
if you want to add a background/overlay based on a preference value.
Here is a simple example to demonstrate that preference values can be read from the parent view:
struct SomeKey: PreferenceKey {
static let defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value += nextValue()
}
}
struct Parent<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.overlayPreferenceValue(SomeKey.self) { x in
Text("\(x)")
}
}
}
#Preview {
Parent {
VStack {
Color.red
.preference(key: SomeKey.self, value: 2)
Color.green
.preference(key: SomeKey.self, value: 3)
}
}
}
Notice the reduce
method in the preference key. This method is used to combine preference values of sibling views, since the parent must receive one value even if its subviews have different preference values set. In the above example, the overlay shows "5".
An important invariant that the implementation must satisfy is that the defaultValue
must be an identity of reduce
- reduce(value: &x, nextValue: {defaultValue})
should not change x
.
navigationTitle
uses transformPreference
to set some preference value representing some tool bar configuration. You can peek into the internals of SwiftUI by printing
print(type(of: Text("").navigationTitle("")))
This prints:
ModifiedContent<ModifiedContent<Text, TransactionalPreferenceTransformModifier<NavigationTitleKey>>, _PreferenceTransformModifier<ToolbarKey>>
_PreferenceTransformModifier
is the view modifier that transformPreference
applies, and in this case it is transforming some preference key called ToolbarKey
.
toolbar
and presentationDetents
are more opaque in how they work, and might not be implemented using a preference value.
There is also ContainerValues
. Unlike preference values which sends data up the view hierarchy as far as possible (similar to how environment values are sent as far down as possible), container values is only sent up one level, and the parent can read each individual subview's container values. This is similar to how tag
works.
For completeness, here is a demonstration:
extension ContainerValues {
@Entry var someValue = 0
}
struct Parent<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack {
ForEach(subviews: content) { subview in
subview
.overlay {
Text("\(subview.containerValues.someValue)")
}
}
}
}
}
#Preview {
Parent {
Color.red
.containerValue(\.someValue, 2)
Color.green
.containerValue(\.someValue, 3)
}
}