1

Stupid SwiftUI Tricks: Single-Axis Geometry Reader

 9 months ago
source link: https://www.wooji-juice.com/blog/stupid-swiftui-tricks-single-axis-geometry-reader.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Stupid SwiftUI Tricks

Single-Axis Geometry Reader

I mentioned in a previous article that, to find out the size of a view in SwiftUI, you use GeometryReader. It passes a GeometryProxy in to the content, which can read the size to make adjustments to itself — to calculate “responsive” layouts, figure out appropriate sizes for its own content, and so on.

But, when you actually use a GeometryReader, it changes the layout of the view, which can cause all sorts of other problems.

In this post, I’m going to dig into that in a bit more detail, and share the simplest solution I’ve found — not for every situation, but for a common category of them.

How GeometryReader Changes The Layout

In the header files1, the comments for GeometryReader note:

This view returns a flexible preferred size to its parent layout

In practice, this seems to be equivalent to .frame(maxWidth: .infinity, maxHeight: .infinity) and the effect is that the view expands to fill all available space.

This is often not what you want.

You might try to work around it, but most of the workarounds… don’t.

If you try to override it with .fixedSize() you’re going to be disappointed. Doing this inside the GeometryReader has no effect, and doing it outside causes it to collapse down to 10x10 points.

If you try to work around it by adding a spacer view to its container with a higher .layoutPriority(), the spacer grabs all the available space, collapsing the GeometryReader down to 0 points. (This is also the behaviour you’ll see if its container is, in turn, contained in a ScrollView.)

Note: GeometryReader doesn’t clip its contents by default, so depending on your content/layout, you might not notice the collapsing happening at first (e.g. if you’re previewing it in Xcode in its own separate file) as the contents spill outside the layout area. But when combined with other views (e.g. in a stack), they will overlap it, hit testing won’t work properly, etc. It’s just a big mess.

You can use .frame(width: height:) with your own fixed values to override the flexible preferred size — but of course then you need to know what those values should be, and if you knew that, you wouldn’t need the GeometryReader…

Why GeometryReader Changes The Layout

I don’t have access to its source code, nor have I talked with its designer, so I can’t say for sure, but here’s my hunch:

If it expands to fill all available space, the size passed in via the GeometryProxy should be fixed regardless of what the content view does.

However, if it was content-hugging, you would quickly run into “layout loops” and/or non-deterministic layout — situations where, for example, the size passed in via the GeometryProxy changes the layout of the content, which changes the final size of the view, which changes the size the GeometryReader content-hugs to, which changes the size passed in via GeometryProxy… which causes a feedback loop.

So you get unpredictable layout at best, and potentially an infinite loop leading to a crash at worst. By always expanding to fill available space, the GeometryProxy size is fixed, avoiding all those problems.2

So, the expanding GeometryReader sort-of makes sense — however, it’s also frustrating, because there are many situations where you want to read the GeometryProxy, want sibling views to fit snugly, and aren’t going to change the content in a way that causes a feedback loop.

Unknowable Geometry

For example, consider a custom slider view, like the ones Apple uses for volume and screen brightness in the iOS Control Centre. These are simple RoundRects that contain a symbol for the control being adjusted (a sun, a speaker), and “fill up” as you adjust the slider.

Since SwiftUI sadly no longer supports container-relative sizing, the slider needs a GeometryReader to find out the height of the bar, so that it can set the size of the “filled” section within that.

But it doesn’t want to also fill all horizontal space available, and nothing it reads from the GeometryProxy changes the bounding box of the content, so there are no feedback loops here. But you also don’t want to fix it to an absolute pixel width either — ideally, you want to support Dynamic Type, which changes the symbol size, and you’d want the bar to accomodate that size.

(For that specific situation… maybe you’d just bodge it with a @ScaledMetric? That might even have some advantages — but, it’s not a general solution, especially for layout views that need to respond to their content sizes.)

The case where I encounter this most is with “word-wrap”-style layouts. The container has a number of content views that it wants to lay out horizontally, providing there’s enough space. If there isn’t, it wants to wrap onto a new line, continue there, and rinse-and-repeat until it runs out of content. The content items may be all sorts of different widths, so the new Lazy Grid views don’t help here (they use preset column widths).

So, it needs to measure the size available to it horizontally, to decide when to wrap. That’s fine: it wants to expand to fill all horizontal space, so it won’t cause a feedback loop — on that axis, it’s behaving just like a GeometryReader does already.

But on the vertical axis, it only wants to take up the space required by the content, instead of expanding to fill the screen. And it can’t fix that in advance, because it depends on the item size and item count of the content. But that’s okay too, because it never needs to read or react to the GeometryProxy-supplied height, so there’s no feedback loop there either.

Or consider a list of items arranged in columns, where you want to determine the column widths relative to the available space (e.g. 4 columns, 25% each), but the row heights are determined by the content (to allow for Dynamic Type, word wrap, and of course the number of items in the list).

Again, a LazyVGrid doesn’t help here, as you have to specify column widths in pixels, not relative to the grid’s size, so you’d still need a GeometryReader to figure out what to pass in to its columns array. And again, it’s safe to read the width because it’s expanding to fill it, and while that can potentially change the height (e.g. due to word-wrap on labels), that’s a one-way influence, so there’s no feedback loop.

Single Axis

What these cases have in common is that they only want to measure one of the axes. They expand to fill all available space on that axis, so they can use a GeometryReader to find out what that space is without causing a feedback loop. It’s the same behaviour as a normal GeometryReader.

But only on that axis, beceause they want to be content-hugging on the other axis. That’s not a problem, because they’re not reading that other axis, so there are no feedback loops there either.

So what we want is a “single axis” GeometryReader that follows standard GeometryReader behaviour on the specified axis, and content-hugging behaviour on the perpendicular axis.

However, GeometryReader doesn’t have an option for that. I believe it should (and I guess I should file feedback on that with Apple), but for now, here’s the simplest implementation I’ve come up with:

Swift

Single Axis Geometry Reader:
struct SingleAxisGeometryReader<Content: View>: View { private struct SizeKey: PreferenceKey { static var defaultValue: CGFloat { 10 } static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } @State private var size: CGFloat = SizeKey.defaultValue var axis: Axis = .horizontal var alignment: Alignment = .center let content: (CGFloat)->Content var body: some View { content(size) .frame(maxWidth: axis == .horizontal ? .infinity : nil, maxHeight: axis == .vertical ? .infinity : nil, alignment: alignment) .background(GeometryReader { proxy in Color.clear.preference(key: SizeKey.self, value: axis == .horizontal ? proxy.size.width : proxy.size.height) }) .onPreferenceChange(SizeKey.self) { size = $0 } } }

It assumes the content is a single view (which it should be: if you need more, you’ll need a stack or something to contain them, and that will itself be a single view).

Note: it doesn’t pass in the correct size on the first layout pass. SwiftUI seems to run a second pass on the layout before sending it to the screen, so you should never see this, but on the first pass it will pass in a size of 10 points, so you should make your views robust to that.

(Why 10? Because that’s what GeometryProxy seems to default to, and using 0 is probably just begging for divide-by-zero errors.)

Since the views don’t get drawn at that size, they don’t need to look good for it, just avoid crashing or having weird side-effects. But avoiding this bodgery is one reason I’d like to see a “proper” single-axis GeometryReader in SwiftUI itself.

Using it is pretty straightforward:

Swift

Example of using the Single Axis Reader:
struct CustomSlider: View { @Binding var value: CGFloat let symbolName: String // e.g. "sun.max" var body: some View { SingleAxisGeometryReader { width in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 8) .fill(Color.secondary) RoundedRectangle(cornerRadius: 8) .fill(Color.primary) .frame(width: width * value) Image(systemName: symbolName) .foregroundColor(.gray) .font(.largeTitle) .padding() } } // handling drag gestures left as an exercise for the reader ;-) } }

This one’s horizontal rather than vertical like the ones in Control Centre (and also kinda ugly, I just threw it together as an example), but it’s easy enough to flip it: change width to height and use SingleAxisGeometryReader(axis: .vertical).

SingleAxisGeometryReader doesn’t handle all the cases, but it handles a big chunk of them, it’s simple (especially at the call site), and seems to be working well so far.


  1. Not actually header files.

  2. The hunch is, I think, lent credence by the fact that you can stick a GeometryReader in a background or overlay view without disrupting the layout. Their layout region is constrained to the actual size of the view, so they receive its actual size and do not trigger the “consume all of the things” behaviour. But they also cannot influence the view’s size, so this avoids feedback loops (at least, until you start abusing Preference Keys).

Posted

by Canis

23 September 2020


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK