Skip to main content

Coding with Jeff

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: Protocol ‘MyGridItem’ can only be used as a generic constraint because it has Self or associated type requirements

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!