SwiftUI’s layout primitives generally don’t provide relative sizing options, e.g. “make this view 50 % of the width of its container”. Let’s 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:
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. Here’s 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). I’ll discuss below what implications this has for users of the layout.
2. The View extension
Next, we’ll 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: we’re changing the proposed size for the subview, which won’t always result in a different actual size. More on this below. -
We’re wrapping the subview (
self
in the code above) in aVStack
. This might seem redundant, but it’s necessary to make sure the layout only receives a single element in its subviews collection. See Chris Eidhof’s 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 who’s 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 % won’t always result in 80 %
If you watch the debugging guides I’m drawing in the video above, you’ll notice that the relative sizing modifier never reports a width greater than 400, even if the window is wide enough:
This is because our layout only adjusts the proposed size for its subview but then accepts the subview’s actual size as its own. Since SwiftUI views always choose their own size (which the parent can’t override), the subview is free to ignore our proposal. In this example, the layout’s subview is the frame(maxWidth: 400)
view, which sets its own width to the proposed width or 400, whichever is smaller.
Understanding the modifier’s behavior
Proposed size ≠ actual size
It’s 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 don’t find this particularly problematic because SwiftUI’s entire layout system works like this. Ultimately, SwiftUI views always determine their own size, so you can’t 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 don’t think this is particularly bad, but it’s something to be aware of.
Proposing nil
is SwiftUI’s 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? I’m not sure. Maybe it’d 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 SwiftUI’s “header file” from that time that shows their API, including quite lengthy documentation.
I don’t know why these modifiers didn’t survive the beta phase. The release notes from 2019 don’t give a reason:
The
relativeWidth(_:)
,relativeHeight(_:)
, andrelativeSize(width:height:)
modifiers are deprecated. Use other modifiers likeframe(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
instead. (51494692)
I also don’t remember how these modifiers worked. They probably had somewhat similar semantics to my solution, but I can’t be sure. The doc comments linked above sound straightforward (“Sets the width of this view to the specified proportion of its parent’s width.”), but they don’t mention the intricacies of the layout algorithm (proposals and responses) at all.
containerRelativeFrame
Update May 1, 2024: Apple introduced the containerRelativeFrame
modifier for its 2023 OSes (iOS 17/macOS 14). If your deployment target permits it, this can be a good, built-in alternative.
Note that containerRelativeFrame
behaves differently than my relativeProposed
modifier as it computes the size relative to the nearest container view, whereas my modifier uses its proposed size as the reference. The SwiftUI documentation somewhat vaguely lists the views that count as a container for containerRelativeFrame
. Notably, stack views don’t count!
Check out Jordan Morgan’s article Modifier Monday: .containerRelativeFrame(_ axes:) (2022-06-26) to learn more about containerRelativeFrame
.