Alexito's World

A world of coding 💻, by Alejandro Martinez

SwiftUI TupleView and equally spaced stacks

Yesterday I shared with you a post where I was trying to understand SwiftUI's layout system in order to implement equally spaced distribution for views inside stacks.

I was able to do it by requiring a ForEach but I was not happy as that's not how HStack works. SwiftUI stacks allow you to nest views individually or use a ForEach. I wanted to support both systems also.

Then, while editing the video about Opaque Result Types, I realised how HStack keeps the information of its views in the type thanks to TupleView.

TupleView is a generic view with a tuple as its type. This allows it to keep the information of every single subview. Then I remembered some discussions on the forums about Swift not having, yet, variadic generics, and a possible solution came to my mind.

The trick is to make different init with @ViewBuilder that are overloaded on the return type of the closure.

init<A: View, B: View>(@ViewBuilder content: () -> TupleView<(A, B)>)
init<A: View, B: View, C: View>(@ViewBuilder content: () -> TupleView<(A, B, C)>)
init<A: View, B: View, C: View, D: View>(@ViewBuilder content: () -> TupleView<(A, B, C, D)>)
...

You can see the pattern.

This allows me to get access to the specific subviews of the stack, store them in an array and then add a Spacer between them in the body, in the same way that was done in the previous post.

But this journey doesn't end here. There are still a couple of things that I don't like.

To support a single subview overloading the init with a tuple of 1 element doesn't work, the TupleView needs to disappear:

// This doesnt' allow for a single subview:
init<A: View>(@ViewBuilder content: () -> TupleView<(A)>)
// It needs this overload instead:
init<A: View>(@ViewBuilder content: () -> A)

The problem with having that overload is that it's called when there are a number of subviews that we don't have another init for, so in that init we receive a TupleView and we treat it as a single view without adding spacing. Too fragile.

The other problem is that in order to gather the views they need to be wrapped in AnyView, a type erased container. (Watch Opaque Result Types to know more about this view)

So this is still not the ideal solution implementation wise, but at the API surface is what I wanted:

Text("With reusable view manual")

EquallySpacedHStack {
    Color.red.frame(width: 50, height: 50)
    Color.blue.frame(width: 50, height: 50)
    Color.green.frame(width: 50, height: 50)
}

Text("With reusable view for each")

EquallySpacedHStack {
    ForEach(1...4) { _ in
        Color.red.frame(width: 50, height: 50)
    }
}

Check the GitHub repo.

If you liked this article please consider supporting me