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 threeText
s and put them in aTupleView
with three blocks.- each "block" is formed by a
ModifiedContent
instance, which is the result of applying atag(_:)
view modifier to eachText
.
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 Text
s, 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 Text
s + tag
s).
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. Image
s), 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.