Calculate number of items per row based on their width in SwiftUI
TL;DR
GeometryReader may be a "hacky" solution, but it is the solution we have at the moment. It is possible to create a solution that reflows a small number of items dynamically, or a large number of items with a delay. My demo code would be unwieldy here, but it sounds like describing my approach may be useful.
Working with what we've got
Behind the scenes, SwiftUI is doing all kinds of optimized constraint solving to layout your views efficiently. In theory, reflowing content like you describe could be part of that constraint solving; in today's SwiftUI, it is not. Therefore, the only way to do what you are describing is some variant of the following:
- Let SwiftUI lay everything out based on our data model.
- Get the widths that SwiftUI decided on using Geometry reader and preferences/callbacks.
- Use these widths to solve our reflow constraints.
- Update the data model, which will trigger step 1.
Hopefully, this process converges to a stable layout, rather than entering an endless loop.
My results
After playing around with it, here's what I've gotten so far. You can see that a small number of items (29 in my example) reflow almost instantaneously as the width is changed. With a large number of items (262 in my example), there is a noticable delay. This shouldn't be much of an issue if the content and view width don't change and won't need to be updated frequently. The time is spent almost entirely in step 1, so until we get proper reflow support in SwiftUI, I suspect this is as good as it gets. (In case you're wondering, the vertical scrollview scrolls with normal responsiveness once the reflow is finished.)
My strategy
Essentially, my data model starts with a [String]
array and transforms it to a [[String]]
array, where each internal array corresponds to one line that will fit horizontally in my view. (Technically it starts with a String
that is split on whitespace to form the [String]
, but in a generalized sense, I've got a collection I want to split into multiple lines.) Then I can lay it out using VStack
, HStack
, and ForEach
.
My first approach was to try to read the widths off the actual views I'm displaying. However, I quickly ran into infinite recursions or weirdly unstable oscillations because it might truncate a Text view (e.g. [Four] [score] [and] [se...]), and then un-truncate once once the reflow changed, back and forth (or just end in a truncated state.
So I decided to cheat. I lay out all the words in a second, invisible horizontal scrollview. This way, they all get to take up as much space as they want, never get truncated, and most importantly, because this layout only depends on the [String]
array and not the derived [[String]]
array, it can never enter a recursive loop. You may think that laying each view twice (once for measuring width and once for displaying) is inefficient, but I found it to be dozens of times faster than trying to measure the widths from the displayed views, and to produce proper results 100% of the time.
+---------- FIRST TRY - CYCLIC ----------+ +-------- SECOND TRY - ACYCLIC --------+
| | | |
| +--------+ [String] +----------+ | | +-------+ [String] +--------+ |
| | | | | | | |
| | +--------------------------+ | | | v v |
| | | | | | | Hidden +--> Widths +--> [[String]] |
| v v + v | | layout | |
| Display +--> Widths +--> [[String]] | | v |
| layout | | Display |
| | | layout |
+----------------------------------------+ +--------------------------------------+
To read and save the widths, I adapted the GeometryReader/PreferenceKey approach detailed on swiftui-lab.com. The widths are saved in the view model, and updated whenever the number or size of views in the hidden scrollview change. Such a change (or changing the width of the view) then reflows the [String]
array to [[String]]
based on the widths saved in the model.
Summary
Now, whether any of this is useful in a shipping application will depend on how many items you want to reflow, and whether they will be static once laid out or changing often. But I found it to be a fascinating diversion!