8
votes

This is an extension of my previous question (Get width of a view using in SwiftUI)

I need to implement a layout where number of items per row is determined dynamically, based on their combined width (basically, place items in a row until they no longer fit).

two muppets

I've been told that using the GeometryReader is a hacky way to do something in a declarative language, which is obviously true.

I have also been directed to this CollectionView-like component https://github.com/Q-Mobile/QGrid but the solution is static as the number of rows and cells per row is determined once, before any components are rendered.

I have no idea how to approach this, so any advice is very valuable for me!

❤️❤️❤️

2

2 Answers

6
votes

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:

  1. Let SwiftUI lay everything out based on our data model.
  2. Get the widths that SwiftUI decided on using Geometry reader and preferences/callbacks.
  3. Use these widths to solve our reflow constraints.
  4. 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.)

View reflow example

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!

-1
votes

It's a two step process using a GeometryReader.

  1. Measure for each item the width of content(item).
  2. Use measurements to lay items out into rows.

Only problem is that it has to recalculate on each redraw or cache the priorly measured width, which is not necessarily a problem with just a few items though.

I won't post the code here since it uses GeometryReader which is not something the author wants to use.

Example with padding