SwiftUI’s format primitives typically don’t present relative sizing choices, e.g. “make this view 50 % of the width of its container”. Let’s construct our personal!
Use case: chat bubbles
Take into account this chat dialog view for instance of what I wish to construct. The chat bubbles all the time stay 80 % as large as their container because the view is resized:
Constructing a proportional sizing modifier
1. The Structure
We will construct our personal relative sizing modifier on prime of the Structure
protocol. The format multiplies its personal proposed dimension (which it receives from its mother or father view) with the given elements for width and peak. It then proposes this modified dimension to its solely subview. Right here’s the implementation (the total code, together with the demo app, is on GitHub):
/// A customized format that proposes a share of its
/// acquired proposed dimension to its subview.
///
/// - Precondition: should comprise precisely one subview.
fileprivate struct RelativeSizeLayout: Structure {
var relativeWidth: Double
var relativeHeight: Double
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
assert(subviews.rely == 1, "expects a single subview")
let resizedProposal = ProposedViewSize(
width: proposal.width.map { $0 * relativeWidth },
peak: proposal.peak.map { $0 * relativeHeight }
)
return subviews[0].sizeThatFits(resizedProposal)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
assert(subviews.rely == 1, "expects a single subview")
let resizedProposal = ProposedViewSize(
width: proposal.width.map { $0 * relativeWidth },
peak: proposal.peak.map { $0 * relativeHeight }
)
subviews[0].place(
at: CGPoint(x: bounds.midX, y: bounds.midY),
anchor: .middle,
proposal: resizedProposal
)
}
}
Notes:
-
I made the sort personal as a result of I wish to management how it may be used. That is necessary for sustaining the belief that the format solely ever has a single subview (which makes the maths a lot less complicated).
-
Proposed sizes in SwiftUI could be
nil
or infinity in both dimension. Our format passes these particular values by means of unchanged (infinity occasions a share continues to be infinity). I’ll focus on beneath what implications this has for customers of the format.
2. The View extension
Subsequent, we’ll add an extension on View
that makes use of the format we simply wrote. This turns into our public API:
extension View {
/// Proposes a share of its acquired proposed dimension to `self`.
public func relativeProposed(width: Double = 1, peak: Double = 1) -> some View {
RelativeSizeLayout(relativeWidth: width, relativeHeight: peak) {
// Wrap content material view in a container to verify the format solely
// receives a single subview. As a result of views are lists!
VStack { // alternatively: `_UnaryViewAdaptor(self)`
self
}
}
}
}
Notes:
-
I made a decision to go together with a verbose identify,
relativeProposed(width:peak:)
, to make the semantics clear: we’re altering the proposed dimension for the subview, which gained’t all the time end in a unique precise dimension. Extra on this beneath. -
We’re wrapping the subview (
self
within the code above) in aVStack
. This may appear redundant, however it’s vital to verify the format solely receives a single component in its subviews assortment. See Chris Eidhof’s SwiftUI Views are Lists for an evidence.
Utilization
The format code for a single chat bubble within the demo video above appears to be like like this:
let alignment: Alignment = message.sender == .me ? .trailing : .main
chatBubble
.relativeProposed(width: 0.8)
.body(maxWidth: .infinity, alignment: alignment)
The outermost versatile body with maxWidth: .infinity
is chargeable for positioning the chat bubble with main or trailing alignment, relying on who’s talking.
You’ll be able to even add one other body that limits the width to a most, say 400 factors:
let alignment: Alignment = message.sender == .me ? .trailing : .main
chatBubble
.body(maxWidth: 400)
.relativeProposed(width: 0.8)
.body(maxWidth: .infinity, alignment: alignment)
Right here, our relative sizing modifier solely has an impact because the bubbles turn out to be narrower than 400 factors. In a wider window the width-limiting body takes priority. I like how composable that is!
80 % gained’t all the time end in 80 %
If you happen to watch the debugging guides I’m drawing within the video above, you’ll discover that the relative sizing modifier by no means reviews a width better than 400, even when the window is large sufficient:
It’s because our format solely adjusts the proposed dimension for its subview however then accepts the subview’s precise dimension as its personal. Since SwiftUI views all the time select their very own dimension (which the mother or father can’t override), the subview is free to disregard our proposal. On this instance, the format’s subview is the body(maxWidth: 400)
view, which units its personal width to the proposed width or 400, whichever is smaller.
Understanding the modifier’s conduct
Proposed dimension ≠ precise dimension
It’s necessary to internalize that the modifier works on the idea of proposed sizes. This implies it is dependent upon the cooperation of its subview to realize its purpose: views that ignore their proposed dimension can be unaffected by our modifier. I don’t discover this significantly problematic as a result of SwiftUI’s total format system works like this. In the end, SwiftUI views all the time decide their very own dimension, so you may’t write a modifier that “does the proper factor” (no matter that’s) for an arbitrary subview hierarchy.
nil
and infinity
I already talked about one other factor to pay attention to: if the mother or father of the relative sizing modifier proposes nil
or .infinity
, the modifier will go the proposal by means of unchanged. Once more, I don’t suppose that is significantly dangerous, however it’s one thing to pay attention to.
Proposing nil
is SwiftUI’s approach of telling a view to turn out to be its best dimension (fixedSize
does this). Would you ever wish to inform a view to turn out to be, say, 50 % of its best width? I’m unsure. Possibly it’d make sense for resizable pictures and related views.
By the best way, you possibly can modify the format to do one thing like this:
- If the proposal is
nil
or infinity, ahead it to the subview unchanged. - Take the reported dimension of the subview as the brand new foundation and apply the scaling elements to that dimension (this nonetheless breaks down if the kid returns infinity).
- Now suggest the scaled dimension to the subview. The subview would possibly reply with a unique precise dimension.
- Return this newest reported dimension as your personal dimension.
This strategy of sending a number of proposals to youngster views is named probing. Plenty of built-in containers views do that too, e.g. VStack
and HStack
.
The code
The whole code is obtainable in a Gist on GitHub.
Digression: Proportional sizing in early SwiftUI betas
The very first SwiftUI betas in 2019 did embrace proportional sizing modifiers, however they have been taken out earlier than the ultimate launch. Chris Eidhof preserved a replica of SwiftUI’s “header file” from that point that reveals their API, together with fairly prolonged documentation.
I don’t know why these modifiers didn’t survive the beta section. The discharge notes from 2019 don’t give a motive:
The
relativeWidth(_:)
,relativeHeight(_:)
, andrelativeSize(width:peak:)
modifiers are deprecated. Use different modifiers likebody(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
as an alternative. (51494692)
I additionally don’t keep in mind how these modifiers labored. They in all probability had considerably related semantics to my answer, however I can’t be certain. The doc feedback linked above sound simple (“Units the width of this view to the required proportion of its mother or father’s width.”), however they don’t point out the intricacies of the format algorithm (proposals and responses) in any respect.