Flowing Grid Layout in SwiftUI
Today we’re going to create a minor variation of FlexibleView
from Federico Zanetello (which itself is based on GridView
from Mauricio Meirelles). Before diving in, you should go read his post – Flexible layouts in SwiftUI.
Current State
Alright, let’s jump in. The public API for Federico’s version of this view is below:
struct ContentView: View {
// This needs to be a collection of Hashable elements
// Hashable is the part we're going to focus on
let myData = ["Hello", "darkness", "my", "old", "friend"]
var body: some View {
FlexibleView(
data: myData,
spacing: 8,
alignment: .leading,
content: { myHashableElement in
// some content
}
)
}
}
Now this works really well if you’re using it directly. But it becomes a bit more difficult when you want to wrap it in another reusable view that can display arbitrary data. Let’s say we have a protocol that defines our data model, perhaps because it’s defined in a Swift Package and we don’t want to force a specific concrete model type on clients.
protocol MyGridItem {
var title: String { get }
}
In order to use this with FlexibleView
, we need to make it conform to Hashable
, which is easy enough because String
conforms to it.
protocol MyGridItem: Hashable {
var title: String { get }
}
And now we can go and use it, right?
struct MyReusableView: View {
var models: [MyGridItem]
var body: some View {
FlexibleView(
data: models,
spacing: 8,
alignment: .leading,
content: { model in
Text(model.title)
.padding()
.background(Color.orange)
}
)
}
}
Well, unfortunately not. We run into everyone’s favorite error message:
The Hashable
protocol conforms to Equatable
, which has a requirement on Self
because you can only compare two objects of the same type.
public protocol Equatable {
static func == (lhs: Self, rhs: Self) -> Bool
}
To type-erase or not to type-erase
So how do we solve this? Well, one way is to use type erasure to hide the fact that we’re dealing with Equatable
instances. I’m not going to dive too deeply into this solution here for two reasons: first, type erasure still makes my brain hurt a bit, and second, I think it results in a less convenient API. But, here’s what that would look like:
struct Foo {
var title: String
}
struct ContentView: View {
var body: some View {
FlexibleView(
data: [
AnyGridItem(Foo(title: "I've")),
AnyGridItem(Foo(title: "come")),
AnyGridItem(Foo(title: "to")),
AnyGridItem(Foo(title: "talk")),
AnyGridItem(Foo(title: "with")),
AnyGridItem(Foo(title: "you")),
AnyGridItem(Foo(title: "again"))
],
...
)
}
}
We’d have to wrap our models in our type-erased struct. If you’re thinking, “Well, what about AnyHashable
? I don’t need to wrap that all the time…”, then you’d be 100% correct. The following code is completely valid:
let hashables: [AnyHashable] = ["Because", "a", "vision", "softly", "creeping", "left", "its", "seeds", "while", "I", "was", "sleeping"]
The reason for this is some Swift compiler magic. Take a look at the code for AnyHashable
, and you’ll see this:
@inlinable
public // COMPILER_INTRINSIC
func _convertToAnyHashable<H: Hashable>(_ value: H) -> AnyHashable {
return AnyHashable(value)
}
So the Swift compiler is automagically boxing the values for you, which isn’t something we can do for our own types. Thus, we move onto the solution we’re actually going to use here.
Hidden boxes
Let’s back up to why our data models need to be Hashable
in the first place. If we take a look at the FlexibleView
code, we can see that we’re storing a Dictionary<Data.Element, CGSize>
so that we can re-compute the layout as needed, and we’re passing our models to SwiftUI’s ForEach
. Both of these require elements that are Hashable
. Outside of those two places though, we don’t need Hashable
elements for anything.
Ok, so given that information, how can we remove the need for our data to be Hashable
? We can wrap our models in a box that itself is Hashable
. Now, in order to conform to the Hashable
protocol, we need to implement the hash(into:)
function (and the Equatable
requirements). However, since we don’t have any knowledge of what our data models are, we can’t exactly use them for this. So let’s introduce a basic Int
property for this.
struct FlexibleView ... {
private struct Box: Hashable {
var id: Int
var data: Data // This is the generic type, not the NSData equivalent
static func == (lhs: Box, rhs: Box) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
...
}
Now with this in place, we can modify FlexibleView
to use it. Let’s call our modified view FlowingGrid
, which I feel is a little more descriptive. The code below shows these modifications, but leaves out most of the code that’s the same compared to the FlexibleView
implementation.
struct FlowingGrid<Data, Content: View>: View {
private struct Box: Hashable {...}
// We now store the Box instead of the Data
@State private var contentSizes: [Box: CGSize]
private let data: [Box]
private let content: (Data) -> Content
init(data: [Data], ...) {
self.data = data.enumerated().map {
Box(id: $0, data: $1)
}
...
}
var body: some View {
...
ForEach(rowElements, id: \.self) { box in
content(box.data)
.fixedSize()
.readSize { contentSizes[box] = $0 }
}
}
private func computeLayout() -> [[Box]] {
...
for box in data {
let elementSize = contentSizes[box, default: ...]
...
}
...
}
}
Now we can use our FlowingGrid
view like so:
protocol MyGridItem {
var title: String { get }
}
struct Foo: MyGridItem {
var title: String
}
struct Bar: MyGridItem {
var title: String
}
struct ContentView: View {
let models: [MyGridItem] = [
Foo(title: "and the vision"),
Bar(title: "that was planted in my brain"),
Foo(title: "still remains"),
Bar(title: "within the sound"),
Foo(title: "of silence"),
]
var body: some View {
FlowingGrid(
data: models,
spacing: 8,
alignment: .leading,
content: { model in
Text(model.title)
.padding()
.background(Color.orange)
}
)
}
}
And just like that, we’ve removed the Hashable
requirement from our FlowingGrid
and made it much easier to use in some situations, like our protocol based model that lives in a separate Swift Package.
Conclusion
SwiftUI gives you a lot out of the box, but where it really shines is in its flexibility to allow you to create components like the one above. Hopefully this gives you a good idea of how you can work around some of the limitations in Swift to create that awesome API you’re thinking of.
Hope you enjoyed reading! Hit me up on Twitter and let me know what you think!