Working with percentages in SwiftUI layout

12 months ago 41

SwiftUIs layout primitives generally dont provide relative sizing options, e.g. make this view 50?% of the width of its container. Lets build our own! Use case: chat bubbles Consider this chat conversation view as an example of what I...

SwiftUIs layout primitives generally dont provide relative sizing options, e.g. make this view 50?% of the width of its container. Lets build our own!

Use case: chat bubbles

Consider this chat conversation view as an example of what I want to build. The chat bubbles always remain 80?% as wide as their container as the view is resized:

The chat bubbles should become 80?% as wide as their container. Download video

Building a proportional sizing modifier

1. The Layout

We can build our own relative sizing modifier on top of the Layout protocol. The layout multiplies its own proposed size (which it receives from its parent view) with the given factors for width and height. It then proposes this modified size to its only subview. Heres the implementation (the full code, including the demo app, is on GitHub):

/// A custom layout that proposes a percentage of its /// received proposed size to its subview. /// /// - Precondition: must contain exactly one subview. fileprivate struct RelativeSizeLayout: Layout { var relativeWidth: Double var relativeHeight: Double func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { assert(subviews.count == 1, "expects a single subview") let resizedProposal = ProposedViewSize( width: proposal.width.map { $0 * relativeWidth }, height: proposal.height.map { $0 * relativeHeight } ) return subviews[0].sizeThatFits(resizedProposal) } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { assert(subviews.count == 1, "expects a single subview") let resizedProposal = ProposedViewSize( width: proposal.width.map { $0 * relativeWidth }, height: proposal.height.map { $0 * relativeHeight } ) subviews[0].place( at: CGPoint(x: bounds.midX, y: bounds.midY), anchor: .center, proposal: resizedProposal ) } }

Notes:

I made the type private because I want to control how it can be used. This is important for maintaining the assumption that the layout only ever has a single subview (which makes the math much simpler).

Proposed sizes in SwiftUI can be nil or infinity in either dimension. Our layout passes these special values through unchanged (infinity times a percentage is still infinity). Ill discuss below what implications this has for users of the layout.

2. The View extension

Next, well add an extension on View that uses the layout we just wrote. This becomes our public API:

extension View { /// Proposes a percentage of its received proposed size to `self`. public func relativeProposed(width: Double = 1, height: Double = 1) -> some View { RelativeSizeLayout(relativeWidth: width, relativeHeight: height) { // Wrap content view in a container to make sure the layout only // receives a single subview. Because views are lists! VStack { // alternatively: `_UnaryViewAdaptor(self)` self } } } }

Notes:

I decided to go with a verbose name, relativeProposed(width:height:), to make the semantics clear: were changing the proposed size for the subview, which wont always result in a different actual size. More on this below.

Were wrapping the subview (self in the code above) in a VStack. This might seem redundant, but its necessary to make sure the layout only receives a single element in its subviews collection. See Chris Eidhofs SwiftUI Views are Lists for an explanation.

Usage

The layout code for a single chat bubble in the demo video above looks like this:

let alignment: Alignment = message.sender == .me ? .trailing : .leading chatBubble .relativeProposed(width: 0.8) .frame(maxWidth: .infinity, alignment: alignment)

The outermost flexible frame with maxWidth: .infinity is responsible for positioning the chat bubble with leading or trailing alignment, depending on whos speaking.

You can even add another frame that limits the width to a maximum, say 400 points:

let alignment: Alignment = message.sender == .me ? .trailing : .leading chatBubble .frame(maxWidth: 400) .relativeProposed(width: 0.8) .frame(maxWidth: .infinity, alignment: alignment)

Here, our relative sizing modifier only has an effect as the bubbles become narrower than 400 points. In a wider window the width-limiting frame takes precedence. I like how composable this is!

80?% wont always result in 80?%

If you watch the debugging guides Im drawing in the video above, youll notice that the relative sizing modifier never reports a width greater than 400, even if the window is wide enough:

A Mac window showing a mockup of a chat conversation with bubbles for the speakers. Overlaid on the chat bubbles are debugging views showing the widths of different components. The total container width is 753. The relW=80% debugging guide shows a width of 400. The relative sizing modifier accepts the actual size of its subview as its own size.

This is because our layout only adjusts the proposed size for its subview but then accepts the subviews actual size as its own. Since SwiftUI views always choose their own size (which the parent cant override), the subview is free to ignore our proposal. In this example, the layouts subview is the frame(maxWidth: 400) view, which sets its own width to the proposed width or 400, whichever is smaller.

Understanding the modifiers behavior

Proposed size ? actual size

Its important to internalize that the modifier works on the basis of proposed sizes. This means it depends on the cooperation of its subview to achieve its goal: views that ignore their proposed size will be unaffected by our modifier. I dont find this particularly problematic because SwiftUIs entire layout system works like this. Ultimately, SwiftUI views always determine their own size, so you cant write a modifier that does the right thing (whatever that is) for an arbitrary subview hierarchy.

nil and infinity

I already mentioned another thing to be aware of: if the parent of the relative sizing modifier proposes nil or .infinity, the modifier will pass the proposal through unchanged. Again, I dont think this is particularly bad, but its something to be aware of.

Proposing nil is SwiftUIs way of telling a view to become its ideal size (fixedSize does this). Would you ever want to tell a view to become, say, 50?% of its ideal width? Im not sure. Maybe itd make sense for resizable images and similar views.

By the way, you could modify the layout to do something like this:

If the proposal is nil or infinity, forward it to the subview unchanged. Take the reported size of the subview as the new basis and apply the scaling factors to that size (this still breaks down if the child returns infinity). Now propose the scaled size to the subview. The subview might respond with a different actual size. Return this latest reported size as your own size.

This process of sending multiple proposals to child views is called probing. Lots of built-in containers views do this too, e.g. VStack and HStack.

Nesting in other container views

The relative sizing modifier interacts in an interesting way with stack views and other containers that distribute the available space among their children. I thought this was such an interesting topic that I wrote a separate article about it: How the relative size modifier interacts with stack views.

The code

The complete code is available in a Gist on GitHub.

Digression: Proportional sizing in early SwiftUI betas

The very first SwiftUI betas in 2019 did include proportional sizing modifiers, but they were taken out before the final release. Chris Eidhof preserved a copy of SwiftUIs header file from that time that shows their API, including quite lengthy documentation.

I dont know why these modifiers didnt survive the beta phase. The release notes from 2019 dont give a reason:

The relativeWidth(_:), relativeHeight(_:), and relativeSize(width:height:) modifiers are deprecated. Use other modifiers like frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) instead. (51494692)

I also dont remember how these modifiers worked. They probably had somewhat similar semantics to my solution, but I cant be sure. The doc comments linked above sound straightforward (Sets the width of this view to the specified proportion of its parents width.), but they dont mention the intricacies of the layout algorithm (proposals and responses) at all.


View Entire Post

Read Entire Article