Inspecting the View Tree – Part 3: Nested Views

Dealing with Preferences for Nested Views

In the previous part of this series, we introduced SwiftUI anchor preferences. Now we are finally coming out of the forest. In this last part, we will put everything together. We will also learn how SwiftUI handles preferences of nested views, plus some other uses of Anchor<T>. As usual, we will begin with an example:

Our goal now is to create a mini map view, that will reflect the status of a form:

There are some things to notice about the example:

  • The mini map shows a scaled down representation of the form. Different colours will represent the title view, the text field and the text field containers.
  • As the title text view grows, the mini map reacts to it.
  • When we add a new view (i.e., twitter field), the mini map changes as well.
  • When frames change in the form, the mini map also updates.
  • The text field colours are red for no input, yellow for less than 3 characters and green for 3 or more.

Note that the mini map knows nothing about the form. It will only react to the changes in the preferences of the view hierarchy.

Let’s Begin Coding

We begin by defining some types. Since our view tree will have multiple kinds of views, when need something to tell them apart. For that purpose, we start by defining an enum:

enum MyViewType: Equatable {
    case formContainer // main container
    case fieldContainer // contains a text label + text field
    case field(Int) // text field (with an associated value that indicates the character count in the field)
    case title // form title
    case miniMapArea // view placed behind the minimap elements
}

Then we define the type of data we are going to set in the preference, and we add some useful methods we will use later. The data type will have two properties (vtype and bounds):

struct MyPreferenceData: Identifiable {
    let id = UUID() // required when using ForEach later
    let vtype: MyViewType
    let bounds: Anchor<CGRect>
    
    // Calculate the color to use in the minimap, for each view type
    func getColor() -> Color {
        switch vtype {
        case .field(let length):
            return length == 0 ? .red : (length < 3 ? .yellow : .green)
        case .title:
            return .purple
        default:
            return .gray
        }
    }
    
    // Returns true, if this view type must be shown in the minimap.
    // Only fields, field containers and the title are shown in the minimap
    func show() -> Bool {
        switch vtype {
        case .field:
            return true
        case .title:
            return true
        case .fieldContainer:
            return true
        default:
            return false
        }
    }
}

We define our PreferenceKey as usual:

struct MyPreferenceKey: PreferenceKey {
    typealias Value = [MyPreferenceData]
    
    static var defaultValue: [MyPreferenceData] = []
    
    static func reduce(value: inout [MyPreferenceData], nextValue: () -> [MyPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

Ok, so here is where the fun begins. We have many fields, each preceded by a text label and surrounded by a container. Let’s encapsulate that repeating pattern with a view called MyFormField. Additionally, we set preferences accordingly. Since the text field is a child of the containing VStack, and we need the bounds of both nested views, we cannot use anchorPreference() twice. Calling anchorPreference() on the Vstack would prevent the call on the TextField. Instead, we use transformAnchorPreference() on the VStack. This way we add data, instead of replacing it:

// This view draws a rounded box, with a label and a textfield
struct MyFormField: View {
    @Binding var fieldValue: String
    let label: String
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(label)
            TextField("", text: $fieldValue)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                    return [MyPreferenceData(vtype: .field(self.fieldValue.count), bounds: $0)]
                }
        }
        .padding(15)
        .background(RoundedRectangle(cornerRadius: 15).fill(Color(white: 0.9)))
        .transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
            $0.append(MyPreferenceData(vtype: .fieldContainer, bounds: $1))
        }
    }
}

Our ContentView puts all views together. You’ll see that here we set three preferences we will need later on our mini map. We are collecting the bounds of the form title, the form area and the minimap area:

struct ContentView : View {
    @State private var fieldValues = Array<String>(repeating: "", count: 5)
    @State private var length: Float = 360
    @State private var twitterFieldPreset = false
    
    var body: some View {
        
        VStack {
            Spacer()
            
            HStack(alignment: .center) {
                
                // This view puts a gray rectangle where the minimap elements will be.
                // We will reference its size and position later, to make sure the mini map elements
                // are overlayed right on top of it.
                Color(white: 0.7)
                    .frame(width: 200)
                    .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                        return [MyPreferenceData(vtype: .miniMapArea, bounds: $0)]
                    }
                    .padding(.horizontal, 30)
                
                // Form Container
                VStack(alignment: .leading) {
                    // Title
                    VStack {
                        Text("Hello \(fieldValues[0]) \(fieldValues[1]) \(fieldValues[2])")
                            .font(.title).fontWeight(.bold)
                            .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                                return [MyPreferenceData.init(vtype: .title, bounds: $0)]
                        }
                        Divider()
                    }
                    
                    // Switch + Slider
                    HStack {
                        Toggle(isOn: $twitterFieldPreset) { Text("") }
                        
                        Slider(value: $length, in: 360...540).layoutPriority(1)
                    }.padding(.bottom, 5)

                    // First row of text fields
                    HStack {
                        MyFormField(fieldValue: $fieldValues[0], label: "First Name")
                        MyFormField(fieldValue: $fieldValues[1], label: "Middle Name")
                        MyFormField(fieldValue: $fieldValues[2], label: "Last Name")
                    }.frame(width: 540)
                    
                    // Second row of text fields
                    HStack {
                        MyFormField(fieldValue: $fieldValues[3], label: "Email")
                        
                        if twitterFieldPreset {
                            MyFormField(fieldValue: $fieldValues[4], label: "Twitter")
                        }
                        
                        
                    }.frame(width: CGFloat(length))

                }.transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                    $0.append(MyPreferenceData(vtype: .formContainer, bounds: $1))
                }

                Spacer()
                
            }
            .overlayPreferenceValue(MyPreferenceKey.self) { preferences in
                GeometryReader { geometry in
                    MiniMap(geometry: geometry, preferences: preferences)
                }
            }
            
            Spacer()
        }.background(Color(white: 0.8)).edgesIgnoringSafeArea(.all)
    }
}

Finally, our MiniMap will iterate all preferences to draw each of the mini map elements:

struct MiniMap: View {
    let geometry: GeometryProxy
    let preferences: [MyPreferenceData]
    
    var body: some View {
        // Get the form container preference
        guard let formContainerAnchor = preferences.first(where: { $0.vtype == .formContainer })?.bounds else { return AnyView(EmptyView()) }
        
        // Get the minimap area container
        guard let miniMapAreaAnchor = preferences.first(where: { $0.vtype == .miniMapArea })?.bounds else { return AnyView(EmptyView()) }
        
        // Calcualte a multiplier factor to scale the views from the form, into the minimap.
        let factor = geometry[formContainerAnchor].size.width / (geometry[miniMapAreaAnchor].size.width - 10.0)
        
        // Determine the position of the form
        let containerPosition = CGPoint(x: geometry[formContainerAnchor].minX, y: geometry[formContainerAnchor].minY)
        
        // Determine the position of the mini map area
        let miniMapPosition = CGPoint(x: geometry[miniMapAreaAnchor].minX, y: geometry[miniMapAreaAnchor].minY)

        // -------------------------------------------------------------------------------------------------
        // iOS 13 Beta 5 Release Notes. Known Issues:
        // Using a ForEach view with a complex expression in its closure can may result in compiler errors.
        // Workaround: Extract those expressions into their own View types. (53325810)
        // -------------------------------------------------------------------------------------------------
        // The following view had to be encapsulated in two separate functions (miniMapView & rectangleView),
        // because beta 5 has a bug that fails to compile expressions that are "too complex".
        return AnyView(miniMapView(factor, containerPosition, miniMapPosition))
    }

    func miniMapView(_ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
        ZStack(alignment: .topLeading) {
            // Create a small representation of each of the form's views.
            // Preferences are traversed in reverse order, otherwise the branch views
            // would be covered by their ancestors
            ForEach(preferences.reversed()) { pref in
                if pref.show() { // some type of views, we don't want to show
                    self.rectangleView(pref, factor, containerPosition, miniMapPosition)
                }
            }
        }.padding(5)
    }
    
    func rectangleView(_ pref: MyPreferenceData, _ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
        Rectangle()
        .fill(pref.getColor())
        .frame(width: self.geometry[pref.bounds].size.width / factor,
               height: self.geometry[pref.bounds].size.height / factor)
        .offset(x: (self.geometry[pref.bounds].minX - containerPosition.x) / factor + miniMapPosition.x,
                y: (self.geometry[pref.bounds].minY - containerPosition.y) / factor + miniMapPosition.y)
    }

}

A Word About View-Tree Order

It is worth pausing for a second and think about the order in which preference closures are executed in nested views. For example, have a look at the MiniMap implementation. You may have noticed that the ForEach runs the loop in reversed order. Otherwise, the rectangles representing the textfield containers, would have been drawn last, covering their corresponding mini map textfields. So it is important to know how preferences are set.

Please note that there is no documentation about the order in which SwiftUI traverses the view tree. The declaration of the reduce method in PreferenceKey, does mention that values are supplied in view-tree order. However, it does not tell us what that order is. Still, we can be certain that it is not random and will be consistent for every refresh.

Everything I write next about the order in which closures run, I figured out exclusively through experimentation. Basically, I put breakpoints everywhere! Nevertheless, since it seems very reasonable, I am quite confident about it.

The following diagram, shows a simplified representation of the view hierarchy. Non-essential views have been omitted to make the diagram easier to read. The red arrows indicate the order in which the anchorPreference() and transformAnchorPreference() closures are executed. Note that not necessarily all closures are called, only those that SwiftUI deems necessary. For example, if the bounds of a view did not change, its .anchorPreference() closure may not run. When unsure, put a breakpoint or a print statement to debug.

View Tree Hierarchy

As observed in the graphic, it seems SwiftUI follows these two simple rules:

  1. Siblings are traversed in the same order in which they appear in code.
  2. Closures from children are executed before their parents.

Other Uses of Anchor<T>

As we’ve seen, an Anchor<T>.Source can be obtained through some static variables, such as .bounds, .topLeading, .bottom, etc. We typically pass them to the value parameter in the anchorPreference() modifier. However, you can also create your own Anchor<CGRect>.Source and Anchor<CGPoint>.Source using a static method in Anchor<T>.Source. For example, you could write this:

let a1 = Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))
let a2 = Anchor<CGPoint>.Source.point(CGPoint(x: 10, y: 30))
let a3 = Anchor<CGPoint>.Source.unitPoint(UnitPoint(x: 10, y: 30))

I hear you saying… but when will I use that? Well, you could use it to pass the value to the preference, if neither of the existing static variables work for you. But they can come specially handy when dealing with popovers:

.popover(isPresented: $showPopOver,
         attachmentAnchor: .rect(Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))),
         arrowEdge: .leading) { ... }

Let’s Wrap It Up

Congratulations. You made it to the end! I hope you enjoy your new tools and use them to conjure some amazing new apps. The possibilities are endless. Please feel free to comment below, drop me an email, or follow me on twitter.

Stay tuned for more posts… until then!

17 thoughts on “Inspecting the View Tree – Part 3: Nested Views”

  1. Thank you, these are great and useful topics.
    I used transformAnchorPreference instead of anchorPreference in the MyFormField structure, this also works.

    Reply
  2. Thank you very much for this great article,
    please one question, how can I iterate in subviews and modify specific view in SwiftUI?, like UIKit I can say:
    for view in view.subviews { // do something with view }

    Reply
    • While that is a perfectly normal approach in UIKit, SwiftUI is completely different. Because of its declarative nature, that is not the right approach of modifying views. Maybe you can share what you are trying to achieve?

      Cheers,
      Javier.-

      Reply
      • Thanks for your answer, and I am sorry for late, but I didn’t receive any notification.
        I need to do something on specific view in table view cell, so how I can get this specific view in table view cell

        Thanks
        Bokhary.

        Reply
  3. Great stuff, Javier. I notice you haven’t dealt with the issue of adjusting sizes and positions on view rotation. Is there a way to do it?

    Reply
    • Hi Michael,

      Under normal circumstances, views readjust to device rotation. However, if your UI requires some custom changes, you can do it. There are several approaches:

      The most straightforward: you may put a GeometryReader as your top view (it will expand to occupy the whole screen). Inside it, you compare width to height. If width > height, then you’re in landscape. You can set a variable in your model, which will propagate through the environment, and can be used in any view that requires it.

      Other options are described here: https://stackoverflow.com/q/57441654/7786555

      Cheers,
      Javier.-

      Reply
  4. Wow man, I can’t begin to fathom the amount of work you put into this. And pulling all this off without any kind of documentation whatsoever… jeez.
    Anyway, thanks so much for putting your blog out there, it’s some extraordinary work.

    Reply
  5. This is a lot of useful information. I appreciate all your research into this. I was wondering do you have any ideas on how to use this to make a multiline TextField in pure SwiftUI?

    Reply
    • Maybe it is technically possible, but when I face those questions I try to put myself in Apple’s shoes. How would they approach it, and the answer in this case is they would probably use Representables. It seems the safest (and quickest) way to achieve it. As a matter of fact, the current SwiftUI TextField view is indeed, wrapping a UITextField.

      Reply
  6. wow… this blows my mind… I mean… just using an enum for your vType is so elegant… and you come up with all of that without any documentation?? So… View preferences live in the heap? they feel like … Environment variables?
    I read it, I tried it, but I am not sure I understood it, yet, you have a high pace and it feels like everything you do is … magic!
    Still so much to learn…

    Reply
    • and could I achieve something similar using coordinate spaces? not as elegant, of course… or is there a limitation in respect to anchor preferences?

      Reply
      • Hi Alex. Coordinate spaces can be very useful. But each case should be considered separately, in order to determine which is the best approach. In any case, I’m hoping we get even more tools next week, during WWDC2020.

        Reply
  7. About the preference closure execution order, I’d like to add that SwiftUI may skip some closure calling because the framework calculates preferences lazily. For example, if the reduce function decides that it doesn’t need the nextValue thus doesn’t call nextValue(), the preference closures in the next view will be skipped. Another example, If a parent calls .preference or .anchorPreference instead of the two transform versions meaning that the parent doesn’t care about any subview preference and wants to override it anyway, all preference closures in the subviews will be skipped.

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close