Friday, May 24, 2024
HomeIOS DevelopmentWorking with percentages in SwiftUI structure – Ole Begemann

Working with percentages in SwiftUI structure – Ole Begemann


SwiftUI’s structure primitives usually 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 example of what I need to construct. The chat bubbles at all times stay 80 % as broad as their container because the view is resized:

The chat bubbles ought to turn into 80 % as broad as their container. Obtain video

Constructing a proportional sizing modifier

1. The Structure

We will construct our personal relative sizing modifier on prime of the Structure protocol. The structure multiplies its personal proposed measurement (which it receives from its mum or dad view) with the given components for width and top. It then proposes this modified measurement to its solely subview. Right here’s the implementation (the total code, together with the demo app, is on GitHub):

/// A customized structure that proposes a share of its
/// obtained proposed measurement 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 },
            top: proposal.top.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 },
            top: proposal.top.map { $0 * relativeHeight }
        )
        subviews[0].place(
            at: CGPoint(x: bounds.midX, y: bounds.midY), 
            anchor: .middle, 
            proposal: resizedProposal
        )
    }
}

Notes:

  • I made the kind non-public as a result of I need to management how it may be used. That is essential for sustaining the belief that the structure solely ever has a single subview (which makes the mathematics a lot easier).

  • Proposed sizes in SwiftUI may be nil or infinity in both dimension. Our structure passes these particular values by means of unchanged (infinity instances a share continues to be infinity). I’ll focus on beneath what implications this has for customers of the structure.

2. The View extension

Subsequent, we’ll add an extension on View that makes use of the structure we simply wrote. This turns into our public API:

extension View {
    /// Proposes a share of its obtained proposed measurement to `self`.
    public func relativeProposed(width: Double = 1, top: Double = 1) -> some View {
        RelativeSizeLayout(relativeWidth: width, relativeHeight: top) {
            // Wrap content material view in a container to ensure the structure 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:top:), to make the semantics clear: we’re altering the proposed measurement for the subview, which received’t at all times end in a unique precise measurement. Extra on this beneath.

  • We’re wrapping the subview (self within the code above) in a VStack. This might sound redundant, but it surely’s vital to ensure the structure solely receives a single ingredient in its subviews assortment. See Chris Eidhof’s SwiftUI Views are Lists for an evidence.

Utilization

The structure 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 liable for positioning the chat bubble with main or trailing alignment, relying on who’s talking.

You may 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 into narrower than 400 factors. In a wider window the width-limiting body takes priority. I like how composable that is!

80 % received’t at all times end in 80 %

For those who watch the debugging guides I’m drawing within the video above, you’ll discover that the relative sizing modifier by no means experiences a width larger than 400, even when the window is broad sufficient:


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 precise measurement of its subview as its personal measurement.

It is because our structure solely adjusts the proposed measurement for its subview however then accepts the subview’s precise measurement as its personal. Since SwiftUI views at all times select their very own measurement (which the mum or dad can’t override), the subview is free to disregard our proposal. On this instance, the structure’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 measurement ≠ precise measurement

It’s essential to internalize that the modifier works on the premise of proposed sizes. This implies it is dependent upon the cooperation of its subview to realize its objective: views that ignore their proposed measurement can be unaffected by our modifier. I don’t discover this notably problematic as a result of SwiftUI’s total structure system works like this. Finally, SwiftUI views at all times decide their very own measurement, so you’ll be able to’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 mum or dad of the relative sizing modifier proposes nil or .infinity, the modifier will go the proposal by means of unchanged. Once more, I don’t assume that is notably dangerous, but it surely’s one thing to pay attention to.

Proposing nil is SwiftUI’s manner of telling a view to turn into its ultimate measurement (fixedSize does this). Would you ever need to inform a view to turn into, say, 50 % of its ultimate width? I’m unsure. Perhaps it’d make sense for resizable photos and comparable views.

By the way in which, you can modify the structure to do one thing like this:

  1. If the proposal is nil or infinity, ahead it to the subview unchanged.
  2. Take the reported measurement of the subview as the brand new foundation and apply the scaling components to that measurement (this nonetheless breaks down if the kid returns infinity).
  3. Now suggest the scaled measurement to the subview. The subview may reply with a unique precise measurement.
  4. Return this newest reported measurement as your personal measurement.

This strategy of sending a number of proposals to baby views known as probing. A lot of built-in containers views do that too, e.g. VStack and HStack.

Nesting in different container views

The relative sizing modifier interacts in an fascinating manner with stack views and different containers that distribute the accessible house amongst their kids. I believed this was such an fascinating matter that I wrote a separate article about it: How the relative measurement modifier interacts with stack views.

The code

The whole code is offered in a Gist on GitHub.

Digression: Proportional sizing in early SwiftUI betas

The very first SwiftUI betas in 2019 did embody proportional sizing modifiers, however they have been taken out earlier than the ultimate launch. Chris Eidhof preserved a duplicate 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 purpose:

The relativeWidth(_:), relativeHeight(_:), and relativeSize(width:top:) modifiers are deprecated. Use different modifiers like body(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) as a substitute. (51494692)

I additionally don’t bear in mind how these modifiers labored. They most likely had considerably comparable semantics to my answer, however I can’t be certain. The doc feedback linked above sound easy (“Units the width of this view to the required proportion of its mum or dad’s width.”), however they don’t point out the intricacies of the structure algorithm (proposals and responses) in any respect.

containerRelativeFrame

Replace Could 1, 2024: Apple launched the containerRelativeFrame modifier for its 2023 OSes (iOS 17/macOS 14). In case your deployment goal permits it, this is usually a good, built-in different.

Notice that containerRelativeFrame behaves otherwise than my relativeProposed modifier because it computes the dimensions relative to the closest container view, whereas my modifier makes use of its proposed measurement because the reference. The SwiftUI documentation considerably vaguely lists the views that rely as a container for containerRelativeFrame. Notably, stack views don’t rely!

Take a look at Jordan Morgan’s article Modifier Monday: .containerRelativeFrame(_ axes:) (2022-06-26) to be taught extra about containerRelativeFrame.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments