7

Stupid SwiftUI Tricks: Emulating Equal-Size Constraints

 9 months ago
source link: https://www.wooji-juice.com/blog/stupid-swiftui-tricks-equal-sizes.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

Emulating Equal-Size Constraints

SwiftUI is quite a new technology, and it still has some rough edges. There are bugs, and there are missing features – but mostly, the design is pretty solid. Over time the bugs will get fixed, and the missing features filled in.

But the layout engine has a significant limitation: unlike Auto Layout, it’s strictly one-way. Superviews tell subviews how much space they have available, not the other way around. This makes many common tasks fairly straightforward, but it lacks some of the features of Auto Layout – most notably, equal-size constraints.

These are useful where you want some controls to be the same width, height, or both, but you don’t know in advance what that size will be – typically because it’s based on the size of a text label, and you want to support accessibility and localisation, which means different size fonts and different length labels. As a result, you don’t want to hard-code a specific size.

Some layouts are very easy. If you want to make two buttons, and have them be the same width, and arranged one below the other, that’s easy: make a VStack and have them fill the available width. But if you want them side-by-side instead? Now you’re in for a world of pain.

And a common layout, where you have two controls, each with a label, and you want them arranged in a neat 2x2 grid of label-view, label-view, is likewise quite difficult, because if you make a VStack of HStacks, or vice-versa, you can line up one of the axes, but not the other.

Towards a Solution

I really hoped Apple would have a solution in iOS 14, and a couple of new additions seemed promising: the new LazyHGrid and LazyVGrid seemed like they could help, but so far (they’re still in beta) they don’t seem to solve these problems (if you could specify column/row sizes as percentages/ratios, that might help). And matchedGeometryEffect caught my attention as I was skimming the API change lists, but it’s still one-way, and looks like it’s intended mainly for transitions between layouts.

So far, I’ve only found one way around this. I don’t know if this is the “right” way or an “intended” solution by Apple, and I hope we’ll eventually see a system more like matchedGeometryEffect but bidirectional, but in the meantime, here we go.

There are three steps:

  • First, we need to get the sizes of the items we want to line up.

  • Next, we need to have these values “bubble up” to a superview somewhere that’s going to handle the layout

  • Finally, that superview needs to use that info to re-do the layout.

It’s quite a bit of hassle, but, one of the nice things about SwiftUI’s design is that you can isolate most of it in resuable pieces.

Measuring Views

To measure views, SwiftUI provides GeometryReader. It in turn gives you a GeometryProxy, which you can use to find out the width and height of a view. However, just when you think you’re all sorted, it has a couple of issues: you can only get these values “inside” the thing being measured, and using it seems to mess up the layout of almost anything I apply it to. Fortunately, we can work around both of these, but first, let’s see how it’s used in the basic case:

Swift

GeometryReader:
struct ExampleView: View { var body: some View { GeometryReader { proxy in // view rendering code goes here; can access proxy.size } } }

As you can see, ExampleView can find out its size. But we need to get that info out of there somehow, so that other views can do layout based on it.

The way we do this is with Preferences. These are confusingly-named, as they’re nothing to do with Settings/User Defaults/System Preferences etc. They’re ways that views can politely express to their superviews what they would prefer (e.g. their preferred width or height).

(Note: for simplicity, I’m going to talk just about handling equal widths from here on. All of this applies just as well to height, or both dimensions, though.)

Preferences

Preferences are a bit like a dictionary: you have different preference keys, and a view can set a particular value for a particular key, and any of its superviews can see that value when they look up the key.

Because a superview can have multiple subviews, a key can have multiple values, and these need to be combined somehow. You choose the method when you add a new preference key to the system, and since you do this by writing a function for combining the values, you can do whatever you want. You could gather them all into a list, add them up, whatever. Here, what we really want to do, is get the maximum, so that when we do the layout, every item is as wide as the widest item. Here’s an example:

Swift

Maximum Width Preference Key:
struct MaximumWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } }

This defines a new key, sets the default to zero, and as new values get added, takes the widest.

(If this is the first time you’re seeing it, it might seem weird that we’re using a struct as a key, but this is a common idiom in SwiftUI. Note that it’s using the struct’s type, not an instance of the struct, as the key. In Swift, types are cheap, and as good a way of getting a unique token as any.)

Here’s a view that passes its width up as a preference:

Swift

Determine Width view:
struct DetermineWidth: View { typealias Key = MaximumWidthPreferenceKey var body: some View { GeometryReader { proxy in Color.clear .anchorPreference(key: Key.self, value: .bounds) { anchor in proxy[anchor].size.width } } } }

This view is just an empty (Color.clear) background, but with our new preference set to its width. We’ll get back to that clear background part in a moment, but first: there are several ways to set preferences, here, we’re using anchorPreference. Why?

Well, anchorPreference has “No Overview Available” so I don’t actually have a good answer for that, other than it seems to be more reliable in practice. Yeah, cargo-cult code. Whee! I have a hunch that, what with it taking a block and all, SwiftUI can re-run that block to get an updated value when there are changes that affect layout.

Another hope I have is that this stuff will get better documented, so that we can better understand how these different types are intended to be used and new SwiftUI developers can get on board without spending all their time on Stack Overflow or reading blog posts like this one.

Anyway, an anchor is a token that represents a dimension or location in a view, but it doesn’t give you the value directly, you have to cash it in with a GeometryProxy to get the actual value, so, that’s what we did – to get the value, you subscript a proxy with it, so proxy[anchor].size.width gets us what we want, when anchor is .bounds (which is the value we passed in to the anchorPreference call). It’s kind of twisted, but it gets the job done.

Using DetermineWidth

Okay, now to put it into practice. Remember that part about GeometryReader messing up layout? I’ve found that the best way around this is to use an overlay or background. Essentially you’re putting an invisible view on top of/behind the view you want to measure; there, it’s safely kept away from messing up the real view’s layout.

This also explains why we had a Color.clear view inside DetermineWidth. And no, you can’t use EmptyView() – that seems to just get optimised away, and your code doesn’t get run!

So, let’s say you have a Label to measure, all you need to do is this:

Swift

Measuring a Label:
Label("Hello world", systemImage: "figure.wave") .overlay(DetermineWidth())

See, we can tuck away most of the gnarly pieces in a separate file somewhere, and just drop DetermineWidth() in on the views we want to measure.

But how to read the preference values? The easiest way is with onPreferenceChange. This takes a preference key, runs a block of code when it changes, and has the advantage of being a place where it’s safe for a view to change its own @State without getting into an infinite loop. So, you can do something like:

Swift

Getting a Preference Key
struct LayoutView: View { @State var maximumSubViewWidth: CGFloat = 0 var body: some View { HStack { Button(action: {}) { Text("Item 1") .padding() .frame(minWidth: maximumSubViewWidth) } .background(Color.secondary.opacity(0.25)) .overlay(DetermineWidth()) Button(action: {}) { Text("Item 2 is long") .padding() .frame(minWidth: maximumSubViewWidth) } .background(Color.secondary.opacity(0.25)) .overlay(DetermineWidth()) } .onPreferenceChange(DetermineWidth.Key.self) { maximumSubViewWidth = $0 } } }

If you do this a lot with buttons, you might want to bundle it up into a ButtonStyle. If you have this kind of column layout with more general views, you can do something like make yourself a more generalised container view. It ends up being a lot less messy than the above, and if you like, you can roll in additional features, like switching to a vertical stack if space is tight (narrow screen/giant font size). In the end, you can make using it look something like this:

Swift

Neater example:
ButtonGrid { Button(action: {}) { Label("Edit", systemImage: "square.and.pencil") } Button(action: {}) { Label("Tools", systemImage: "ellipsis.circle") } Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } Button(action: {}) { Label("Delete", systemImage: "trash") } } .buttonStyle(EqualWidthButtons())

So while it was a long road to get here (and frankly one that shouldn’t be necessary), at least it turns out to be very neat when we actually go to use it, and it can be reused in different situations without being a huge pain every time.

When Apple introduced SwiftUI, they described it as a declarative, reactive and composable UI system. Everyone focuses on the declarative part, because that one word leaked out into the media before it was officially announced, and so people latched on to it.

But the other parts are important too, and here we see the composability part in action: even though there’s a gap in the layout engine, and it’s quite a bit of hassle to fill, we can take that mess, and turn it into an easily-reusable component or set of components.

Anyway, I hope that’s useful! If you need more background on SwiftUI Preferences, there’s always the official docu– just kidding. I recommend checking out Javier’s SwiftUI Lab, as that’s the first site I found that managed to figure them out – in particular the series on inspecting the view tree.

Posted

by Canis

4 August 2020


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK