This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We discuss some subtleties around SwiftUI's view updates with regard to environment and preference changes.

00:06 It's WWDC week, but we're recording this episode early, which means this is a bit of a filler episode until we get a chance to look at the new stuff presented by Apple.

00:31 Today, we want to discuss environment values, preferences, and view updates. When a view reads values from the environment, it isn't always clear how the environment changing reexecutes the view's body. Similarly, when we define a preference key, our expectations of when the key's reduce method is called aren't always correct.

Environment Observation

01:03 Let's first look at an environment example. We create a Nested view in which we read the entire environment. In the view's body, we display the environment's dynamicTypeSize value in a text view:

import SwiftUI

struct Nested: View {
    @Environment(\.self) var env
    
    var body: some View {
        Text("\(env.dynamicTypeSize)")
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Nested()
        }
        .padding()
    }
}

01:23 Every time we change the dynamic type size, the new value is displayed. But the question is, how expensive is it to read the entire environment? Does it mean the view gets updated with every change in the environment? We can test this out by adding a print statement to the body:

struct Nested: View {
    @Environment(\.self) var env
    
    var body: some View {
        let _ = print("Nested \(Date.timeIntervalSinceReferenceDate)")
        Text("\(env.dynamicTypeSize)")
    }
}

02:13 When we change the dynamic type setting, we obviously get a print to the console. But nothing actually prints when we change, say, the appearance setting. So it looks like SwiftUI keeps track of the values we actually use in a view. In this case, it doesn't seem to matter that we read the entire EnvironmentValues value, because SwiftUI sees we only access one of its properties in the body.

02:52 This makes us wonder what happens if we go even deeper by appending to the path we read:

struct Nested: View {
    @Environment(\.self) var env
    
    var body: some View {
        let _ = print("Nested \(Date.timeIntervalSinceReferenceDate)")
        Text("\(env.dynamicTypeSize.isAccessibilitySize)")
    }
}

03:04 We might expect to only see a print when the environment's dynamic type size changes from a regular size to a size associated with accessibility. But this prints every single time the dynamic type size changes. So, SwiftUI seems to track that we're accessing the dynamic type size, but it doesn't know that we're accessing a computed property of the DynamicTypeSize enum.

03:53 However, this changes when we make the key path passed to the Environment property wrapper more specific:

struct Nested: View {
    @Environment(\.dynamicTypeSize.isAccessibilitySize) var env
    
    var body: some View {
        let _ = print("Nested \(Date.timeIntervalSinceReferenceDate)")
        Text("\(env)")
    }
}

04:06 Now the body is only recomputed when we switch from a regular size to an accessible size or vice versa. So, the key path we provide to the Environment property wrapper enables us to specify when our view should be updated. Or rather, we can avert more unnecessary view updates by providing a more specific key path.

04:38 It seems like there are two stages. First, we access a root key path — in this case, it probably translates to something like a subscript of DynamicTypeSizeKey.self — and that gets rid of most of the updates already. Then, by appending a nested key path, we prevent even more updates.

05:08 Reading the entire environment isn't something we'd do in practice, but it demonstrates that there's some magic happening.

Preference Key's Reduce

05:35 The second topic we want to discuss today feels related to this. When implementing a preference key, we don't always know when the key's reduce method will be called.

05:48 Let's look at a different example, in which we want to use a preference key to indicate whether or not a certain view is present. The preference key's default value is false so that we can set the preference to true for a specific view:

struct TestKey: PreferenceKey {
    static let defaultValue = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        fatalError()
    }
}

06:43 For our sample view, we add a blue rectangle, and we make it set the TestKey preference to true. Higher up the view tree, we call onPreferenceChange with our key to observe and print out changes to the preference:

struct ContentView: View {
    var body: some View {
        VStack {
            Color.blue
                .preference(key: TestKey.self, value: true)
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

Implementing Reduce

07:16 We can run this in the simulator without crashing, which means the preference key's reduce is never called. This makes sense, because there's only one preference value in our tree.

07:50 Since we're using the preference in a way that we never actually have to combine multiple values — we're setting the preference value at one point in the tree — we might think we can avoid unnecessary work and implement reduce by executing the nextValue function and assigning it to value, like so:

struct TestKey: PreferenceKey {
    static let defaultValue = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = nextValue()
    }
}

08:15 When we teach our workshops, we often see people implementing reduce like this, but we can construct a different sample view for which this implementation won't work. Let's see what happens when we add a dynamic part to our view tree:

struct ContentView: View {
    @State var foo = false
    
    var body: some View {
        VStack {
            Color.blue
                .preference(key: TestKey.self, value: true)
            if foo {
            }
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

09:22 If we add a print statement to reduce, we'll see that it still doesn't get called for the view above:

struct TestKey: PreferenceKey {
    static let defaultValue = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        let new = nextValue()
        print("Reduce", value, new)
        value = new
    }
}

09:57 Only when we nest the dynamic subtree in something else do we get a reduce call:

struct ContentView: View {
    @State var foo = false
    
    var body: some View {
        VStack {
            Color.blue
                .preference(key: TestKey.self, value: true)
            ZStack {
                if foo {
                    
                }
            }
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

10:18 This prints: "On change: true false." The preference value of the blue rectangle is true, and it gets combined with the default value, false, that originates from the ZStack branch. If we swap the subviews, we get the print segments in a different order: "On change: false true."

11:06 Interestingly, the reduce combination only happens if there's a dynamic view in the mix. The reduce doesn't happen if we just add an empty ZStack, or a ZStack with a Text in it. The moment we give an ID to the view — even if it's a constant one — we get a print statement:

struct ContentView: View {
    @State var foo = false
    
    var body: some View {
        VStack {
            ZStack {
                Text("Hi")
                    .id("")
            }
            Color.blue
                .preference(key: TestKey.self, value: true)
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

11:45 We also get a print statement if we use AnyView:

struct ContentView: View {
    @State var foo = false
    
    var body: some View {
        VStack {
            ZStack {
                AnyView(Text("Hi"))
            }
            Color.blue
                .preference(key: TestKey.self, value: true)
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

11:53 Or a ForEach:

struct ContentView: View {
    @State var foo = false
    
    var body: some View {
        VStack {
            ZStack {
                ForEach(0..<0) { _ in }
            }
            Color.blue
                .preference(key: TestKey.self, value: true)
        }
        .padding()
        .onPreferenceChange(TestKey.self, perform: { value in
            print("On change: \(value)")
        })
    }
}

12:08 Any kind of dynamic sibling view causes reduce to be called, which means we have to be careful about how we implement reduce. If we just write value = nextValue(), it might cause an actual value we're setting in the preference system to be overwritten by a default value from another view. If we put the ZStack as the second view in the VStack, the value received in the onPreferenceChange closure will be false, even though we want it to be true.

12:45 To prevent this from happening, we need to improve our logic in the reduce method so that we end up with a true if any branch in the view tree propagates true:

struct TestKey: PreferenceKey {
    static let defaultValue = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = value || nextValue()
    }
}

Monoids

12:56 In our sample, we work with a Boolean preference value, so we use the OR operator to combine values. If we had an optional value instead, we'd use the nil-coalescing operator to combine values. For an array of values, we use append(contentsOf:). For a dictionary of values, we use merge.

13:12 Let's see if we generalize this. If the reduce method is called with some value, x, and the nextValue function returns the default value — or the other way around — then the result should always evaluate to x:

// reduce(x, { defaultValue }) => x
// reduce(defaultValue, { x }) => x

13:42 This situation, where we have a default value (i.e. the identity element) and a combine operation, is called a monoid. The theory states that when combining a value and the identity element, the result should always be the value, regardless of the order in which we combine the two.

14:14 Applying this idea to preference keys, the default value should be an identity element, and the reduce operation needs to be commutative, meaning the order in which we combine values doesn't matter. So, if our preference value is a dictionary, the default value should be an empty dictionary and the reduce method should merge dictionaries. If the preference value is an array, the default value should be an empty array and reduce should join arrays together using append(contentsOf:). If our preference value describes a maximum float, the default value should be zero or the lowest possible number, and reduce should combine values using max.

14:59 This all makes sense to us, but what might not always make sense is when reduce is or isn't called, but we'll have to live with it. And we can, as long as the logic in our preference keys is correct.

Until Next Week

15:15 At this point, everybody already knows what's been announced at WWDC, and we hope everyone enjoys it. We'll see what we can make out of the new stuff next week.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes