Inspecting SwiftUI views

In Composing SwiftUI views and SwiftUI patterns: passing & accepting views we've covered how composition is one of SwiftUI's most powerful tools at our disposal.

So far we've always accepted the given input, usually in the form of a @ViewBuilder content: () -> Content parameter, as a black box: it's a view, that's all we needed to know.

But what if we wanted to know more about that view? In this article, let's explore how we can do so.

A new picker component

Let's imagine that we've been tasked to build a new picker/segmented control:

A good place to start is SwiftUI's Picker:

public struct Picker<Label: View, SelectionValue: Hashable, Content: View>: View {
  public init(
    selection: Binding<SelectionValue>,
    label: Label,
    @ViewBuilder content: () -> Content
  )
}

Which we can use this way:

struct ContentView: View {
  @State private var selection = 0

  var body: some View {
    Picker(selection: $selection, label: Text("")) {
      Text("First").tag(0)
      Text("Second").tag(1)
      Text("Third").tag(2)
    }
    .pickerStyle(SegmentedPickerStyle())
  }
}

For simplicity's sake we will ignore the label parameter.

Picker uses tag(_:) to identify which element corresponds to which selection value.

This is very important, as the picker needs to:

  • highlight the selected element at any time
  • add a tap gesture to each element
  • update the current selection based on the user interaction.

FSPickerStyle

At first we might try to create a new PickerStyle, as we did with Label and Button, here's PickerStyle definition:

public protocol PickerStyle {
}

Cool, no requirements! Let's create our picker style then:

struct FSPickerStyle: PickerStyle {
}

This won't build. While there are no public requirements, PickerStyle actually has some private/internal ones, which look like this:

protocol PickerStyle {
  static func _makeView<SelectionValue>(
    value: _GraphValue<_PickerValue<FSPickerStyle, SelectionValue>>, 
    inputs: _ViewInputs
  ) -> _ViewOutputs where SelectionValue: Hashable

  static func _makeViewList<SelectionValue>(
    value: _GraphValue<_PickerValue<FSPickerStyle, SelectionValue>>,
    inputs: _ViewListInputs
  ) -> _ViewListOutputs where SelectionValue: Hashable
}

The alarming number of _ underscores, a.k.a. "private stuff", should tell us that is the not the way we want to go:
exploring such APIs is left for the curious/adventurous (if you uncover anything cool, please let me know!).

With PickerStyle not being a viable option, let's move to build our own SwiftUI picker from scratch.

FSPicker

Despite creating our own component, we still want to mimic SwiftUI's Picker APIs as close as possible:

public struct FSPicker<SelectionValue: Hashable, Content: View>: View {
  @Binding var selection: SelectionValue
  let content: Content

  public init(
    selection: Binding<SelectionValue>, 
    @ViewBuilder content: () -> Content
  ) {
    self._selection = selection
    self.content = content()
  }

  public var body: some View {
    ...
  }
}

So far so good, thanks to this declaration we can go back to original example, add a FS prefix to the Picker, and everything would build fine:

struct ContentView: View {
  @State private var selection = 0

  var body: some View {
    // 👇🏻 our picker!
    FSPicker(selection: $selection) {
      Text("First").tag(0)
      Text("Second").tag(1)
      Text("Third").tag(2)
    }
  }
}

Now comes the challenge: implementing FSPicker's body.

FSPicker's body

When we see a parameter such as @ViewBuilder content: () -> Content, we normally treat it as something that we put somewhere in our own view body, however we can't do just that for our (and SwiftUI's) picker.

This is because our picker's body needs take this content, highlight the selected element, and add gestures to react to user selections.

A workaround for this challenge would be to replace our generic Content parameter with something that we can directly play with. For example we could replace Content with an array of tuples, where each tuple contains a String and an associated SelectionValue:

public struct FSPicker<SelectionValue: Hashable>: View {
  @Binding var selection: SelectionValue
  let content: [(String, SelectionValue)]

  public init(
    selection: Binding<SelectionValue>,
    content: [(String, SelectionValue)]
  ) {
    self._selection = selection
    self.content = content
  }

  public var body: some View {
    HStack {
      ForEach(content, id: \.1) { (title, value) in
        Button(title) { selection = value }
      }
    }
  }
}

However we wouldn't follow SwiftUI's Picker APIs anymore.

Instead, let's make things more interesting: let's embrace our "black box" Content and use Swift's reflection!

Mirrors all the way down

While it's not always possible for our picker to know the actual content at build time, Swift lets us inspect this value at run time via Mirror.

Let's update our FSPicker declaration with the following:

public struct FSPicker<SelectionValue: Hashable, Content: View>: View {
  ...
  public init(...) { ... }

  public var body: some View {
    let contentMirror = Mirror(reflecting: content)
    let _ = print(contentMirror)
    EmptyView()
  }
}

If we now run this with our original example, we will see the following logs in Xcode's Debug Area Console (formatted for better readability):

Mirror for TupleView<
  (
    ModifiedContent<Text, _TraitWritingModifier<TagValueTraitKey<Int>>>, 
    ModifiedContent<Text, _TraitWritingModifier<TagValueTraitKey<Int>>>, 
    ModifiedContent<Text, _TraitWritingModifier<TagValueTraitKey<Int>>>
  )
>

...which shouldn't surprise us too much, beside maybe some unfamiliar terms.
For context, here's our original content:

Text("First").tag(0)
Text("Second").tag(1)
Text("Third").tag(2)

We can see that:

  • @ViewBuilder took our three Texts and put them in a TupleView with three blocks.
  • each "block" is formed by a ModifiedContent instance, which is the result of applying a tag(_:) view modifier to each Text.

These are already very good insights! Let's print the content instance next:

public struct FSPicker<SelectionValue: Hashable, Content: View>: View {
  ...
  public init(...) { ... }

  public var body: some View {
    let contentMirrorValue = Mirror(reflecting: content).descendant("value")!
    let _ = print(contentMirrorValue)
    EmptyView()
  }
}

We're force-unwrapping for brevity's sake: proper handling is left as a homework to the reader.

This time the console shows:

(
  ModifiedContent(
    content: Text(
      storage: Text.Storage.anyTextStorage(
        LocalizedTextStorage
      ), 
      modifiers: []
    ), 
    modifier: _TraitWritingModifier(
      value: TagValueTraitKey.Value.tagged(0)
    )
  ), 
  ModifiedContent(
    content: Text(
      storage: Text.Storage.anyTextStorage(
        LocalizedTextStorage
      ), 
      modifiers: []
    ), 
    modifier: _TraitWritingModifier(
      value: TagValueTraitKey.Value.tagged(1)
    )
  ), 
  ModifiedContent(
    content: Text(
      storage: Text.Storage.anyTextStorage(
        LocalizedTextStorage
      ), 
      modifiers: []
    ), 
    modifier: _TraitWritingModifier(
      value: TagValueTraitKey.Value.tagged(2)
    )
  )
)

Output formatted and slightly simplified for clarity's sake.

...which also shouldn't surprise us too much: this is the same output as before, where instead of TupleView's type we now see the actual value.

Note how everything we need is right there: all the Texts, and their associated .tag values.

Next we can use Mirror to navigate and pick each single Text and tag value separately:

public struct FSPicker<SelectionValue: Hashable, Content: View>: View {
  ...
  public init(...) { ... }

  public var body: some View {
    let contentMirror = Mirror(reflecting: content)
    // 👇🏻 The number of `TupleView` blocks.
    let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count

    HStack {
      ForEach(0..<blocksCount) { index in
        // 👇🏻 A single `TupleView` block.
        let tupleBlock = contentMirror.descendant("value", ".\(index)")
        let text: Text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
        let tag: SelectionValue = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue

        ...
      }
    }
  }
}

At this point we have gained access to each original Text and tag value, which we can use to build our view:

struct FSPicker<SelectionValue: Hashable, Content: View>: View {
  @Namespace var ns
  @Binding var selection: SelectionValue
  @ViewBuilder let content: Content

  public var body: some View {
    let contentMirror = Mirror(reflecting: content)
    let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count // How many children?
    HStack {
      ForEach(0..<blocksCount) { index in
        let tupleBlock = contentMirror.descendant("value", ".\(index)")
        let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
        let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue

        Button {
          selection = tag
        } label: {
          text
            .padding(.vertical, 16)
        }
        .background(
          Group {
            if tag == selection {
              Color.purple.frame(height: 2)
                .matchedGeometryEffect(id: "selector", in: ns)
            }
          },
          alignment: .bottom
        )
        .accentColor(tag == selection ? .purple : .black)
        .animation(.easeInOut)
      }
    }
  }
}

Thanks to this definition FSPicker works with any SelectionValue and matches Picker's APIs.

Further improvements

As it currently stands, FSPicker works great as long as the given content follows the same format as our example (a.k.a. a list of two or more Texts + tags).

This could be actually what we wanted: instead of trying to support every possible SwiftUI component, we can consider other components as bad inputs and ignore them as long as it's clearly documented.

If we'd like to support more components (e.g. Images), we could do so by expanding our inspection to also handle such elements, or even create our own view builder.

Of course, this is just the tip of the iceberg:
handling any content means handling more components, edge cases, multiple levels of ModifiedContent, and much, much more.

Conclusions

While Swift is known for being a statically typed language, it doesn't mean that we can only play with our build-time knowledge: thanks to Mirror we took another sneak peek into how dynamic both Swift and SwiftUI actually are.

Have you ever had to do this kind of inspection yourself? In which scenarios? Please let me know!

Thank you for reading and stay tuned for more articles.

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all

Explore Swift

Browse all