Home » SwiftUI Layout — Cracking the Size Code |fatbobman

SwiftUI Layout — Cracking the Size Code |fatbobman

by Icecream
0 comment

Photo by Markus Winkler on Unsplash

In the “SwiftUI Layout — The Mystery of Size,” we defined quite a few sizing ideas concerned within the SwiftUI format course of. In this text, we are going to additional deepen our understanding of the SwiftUI format mechanism by imitating the view modifiers body and fastenedSize, and show some points to pay attention to throughout format via a number of examples.

In SwiftUI, we are able to make the most of totally different format containers to generate almost an identical rendering outcomes. For instance, ZStack, overlay, background, VStack, and HStack can all obtain comparable format results.

Here’s an instance utilizing ZStack, overlay, and background:

struct Coronary heartView: View {
var physique: some View {
Circle()
.fill(.yellow)
.body(width: 30, peak: 30)
.overlay(Image(systemName: "coronary heart").foregroundColor(.crimson))
}
}

struct ButtonView: View {
var physique: some View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.gradient)
.body(width: 150, peak: 50)
}
}

// ZStack
struct IconDemo1: View {
var physique: some View {
ZStack(alignment: .topTrailing) {
ButtonView()
Coronary heartView()
.alignmentGuide(.high, computeValue: { $0.peak / 2 })
.alignmentGuide(.trailing, computeValue: { $0.width / 2 })
}
}
}

// overlay
struct IconDemo2: View {
var physique: some View {
ButtonView()
.overlay(alignment: .topTrailing) {
Coronary heartView()
.alignmentGuide(.high, computeValue: { $0.peak / 2 })
.alignmentGuide(.trailing, computeValue: { $0.width / 2 })
}
}
}

// background
struct IconDemo3: View {
var physique: some View {
Coronary heartView()
.background(alignment:.heart){
ButtonView()
.alignmentGuide(HorizontalAlignment.heart, computeValue: {$0[.trailing]})
.alignmentGuide(VerticalAlignment.heart, computeValue: {$0[.top]})
}
}
}

Although IconDemo1, IconDemo2, and IconDemo3 look the identical within the remoted preview, inserting them inside different format containers reveals distinct variations of their format consequence contained in the container. The composition and measurement of the required measurement are totally different (see the required measurement of every marked by the crimson field within the determine beneath).

This is as a result of totally different format containers have totally different methods in planning their very own required measurement, which ends up in the above phenomenon.

Containers like ZStack, VStack, and HStack, their required measurement consists of the full measurement obtained after inserting all their subviews in keeping with the required format guidelines. While the required measurement of the overlay and background relies upon completely on their major view (on this instance, the required measurement of the overlay is set by ButtonView, and the required measurement of the background is set by Coronary heartView).

Suppose the present design requirement is to put out ButtonView and Coronary heartView as an entire, then ZStack is an effective alternative.

Each container has its relevant situations. For instance, within the following requirement, to create a subview much like the “like” operate in a video app (solely contemplate the place and measurement of gesture icon throughout format), the overlay container that relies upon solely on the required measurement of the principle view may be very appropriate:

struct FavoriteDemo: View {
var physique: some View {
ZStack(alignment: .bottomTrailing) {
Rectangle()
.fill(Color.cyan.gradient.opacity(0.5))
Favorite()
.alignmentGuide(.backside, computeValue: { $0[.bottom] + 200 })
.alignmentGuide(.trailing, computeValue: { $0[.trailing] + 100 })
}
.ignoresSafeArea()
}
}

struct Favorite: View {
@State var hearts = [(String, CGFloat, CGFloat)]()
var physique: some View {
Image(systemName: "hand.thumbsup")
.symbolVariant(.fill)
.foregroundColor(.blue)
.font(.title)
.overlay(alignment: .backside) {
ZStack {
Color.clear
ForEach(hearts, id: .0) { coronary heart in
Text("+1")
.font(.title)
.foregroundColor(.white)
.daring()
.transition(.uneven(insertion: .transfer(edge: .backside).mixed(with: .opacity), elimination: .transfer(edge: .high).mixed(with: .opacity)))
.offset(x: coronary heart.1, y: coronary heart.2)
.job {
attempt? await Task.sleep(nanoseconds: 500000000)
if let index = hearts.firstIndex(the place: { $0.0 == coronary heart.0 }) {
let _ = withAnimation(.easeIn) {
hearts.take away(at: index)
}
}
}
}
}
.body(width: 50, peak: 100)
.allowsHitTesting(false)
}
.onTapGesture {
withAnimation(.easeOut) {
hearts.append((UUID().uuidString, .random(in: -10...10), .random(in: -10...10)))
}
}
}
}

Views of the identical look could have totally different implications. When utilizing format containers to create mixed views, the influence on the mother or father container’s format of the composed view have to be thought of, and an appropriate container needs to be chosen for various necessities.

Similar to UIKit and AppKit, SwiftUI’s format operations are carried out on the view degree (essence), whereas all operations concentrating on the related backing layer are nonetheless accomplished via Core Animation. Therefore, changes made on to the CALayer (look) are undetectable by SwiftUI’s format system.

Such operations that alter content material after format however earlier than rendering is prevalent in SwiftUI, e.g., offset, scaleEffect, rotationEffect, shadow, background, cornerRadius, and so forth., are carried out at this stage.

Here’s an instance:

struct OffsetDemo1:View{
var physique: some View{
HStack{
Rectangle()
.fill(.orange.gradient)
.body(maxWidth:.infinity)
Rectangle()
.fill(.inexperienced.gradient)
.body(maxWidth:.infinity)
Rectangle()
.fill(.cyan.gradient)
.body(maxWidth:.infinity)
}
.border(.crimson)
}
}

We alter the place of the center rectangle with offset, which doesn’t have an effect on the scale of HStack. In this case, the looks and essence are decoupled:

Rectangle()
.fill(.inexperienced.gradient)
.body(width: 100, peak: 50)
.border(.blue)
.offset(x: 30, y: 30)
.border(.inexperienced)

In SwiftUI, the offset modifier corresponds to the CGAffineTransform operation in Core Animation. .offset(x: 30, y: 30) is equal to .transformEffect(.init(translationX: 30, y: 30)). Such modifications made immediately on the CALayer degree don’t have an effect on format.

The above could be the impact you need, however if you would like the displaced view to have an effect on the format of its mother or father view (container), you could want one other strategy — use format containers as an alternative of Core Animation operations:

// Using padding
Rectangle()
.fill(.inexperienced.gradient)
.body(width: 100, peak: 50)
.border(.blue)
.padding(EdgeInunits(high: 30, main: 30, backside: 0, trailing: 0))
.border(.inexperienced)

Or it could appear to be this:

// Using body
Rectangle()
.fill(.inexperienced.gradient)
.body(width: 100, peak: 50)
.border(.blue)
.body(width: 130, peak: 80, alignment: .bottomTrailing)
.border(.inexperienced)

// Using place
Rectangle()
.fill(.inexperienced.gradient)
.body(width: 100, peak: 50)
.border(.blue)
.place(x: 80, y: 55)
.body(width: 130, peak: 80)
.border(.inexperienced)

Compared to the offset view modifier, since there is no such thing as a prepared alternative, it’s a bit tedious to make the outcomes of rotationEffect, in flip, have an effect on the format:

struct RotationDemo: View {
var physique: some View {
HStack(alignment: .heart) {
Text("HI")
.border(.crimson)
Text("Hello world")
.fastenedSize()
.border(.yellow)
.rotationEffect(.levels(-40))
.border(.crimson)
}
.border(.blue)
}
}
extension View {
func rotationEffectWithFrame(_ angle: Angle) -> some View {
modifier(RotationEffectWithFrameModifier(angle: angle))
}
}

struct RotationEffectWithFrameModifier: ViewModifier {
let angle: Angle
@State personal var measurement: CGSize = .zero
var bounds: CGRect {
CGRect(origin: .zero, measurement: measurement)
.offsetBy(dx: -size.width / 2, dy: -size.peak / 2)
.making use of(.init(rotationAngle: CGFloat(angle.radians)))
}
func physique(content material: Content) -> some View {
content material
.rotationEffect(angle)
.background(
GeometryReader { proxy in
Color.clear
.job(id: proxy.body(in: .native)) {
measurement = proxy.measurement
}
}
)
.body(width: bounds.width, peak: bounds.peak)
}
}

struct RotationDemo: View {
var physique: some View {
HStack(alignment: .heart) {
Text("HI")
.border(.crimson)
Text("Hello world")
.fastenedSize()
.border(.yellow)
.rotationEffectWithFrame(.levels(-40))
.border(.crimson)
}
.border(.blue)
}
}

scaleEffect may also be carried out in an analogous technique to have an effect on the unique format.

In SwiftUI, builders have to be clear whether or not an operation targets the essence (based mostly on format mechanism) or look (at CALayer degree). They may see if it desires to have an effect on the essence by modifying the looks. This method, the ultimate rendered impact will be per the anticipated format.

Please learn “Layout in SwiftUI Way” to learn to use totally different format logics in SwiftUI to realize the identical visible design necessities.

In this chapter, we are going to deepen the understanding of various measurement ideas within the format course of by imitating body and fastenedSize utilizing the Layout protocol.

The format logic of body and fastenedSize has been described within the earlier part; this part solely explains the important thing code. The imitation code will be obtained right here.

There are two variations of body in SwiftUI. This part imitates body(width: CGFloat? = nil, peak: CGFloat? = nil, alignment: Alignment = .heart).

Essentially the body view modifier is a wrapper across the _FrameLayout format container. In this instance, we title the customized format container MyFrameLayout and the view modifier myFrame.

In SwiftUI, format containers often have to be wrapped earlier than utilizing them. For instance, _VStackLayout is wrapped as VStack, _FrameLayout is wrapped because the body view modifier.

The impact of this wrapping conduct is (taking MyFrameLayout for example):

Improve the a number of parentheses challenge brought on by Layout protocol’s callAsFunction

In “Alignment in SwiftUI: Everything You Need To Know, I’ve launched that “alignment” occurs between subviews inside a container. Therefore, for _FrameLayout, which solely takes one subview from the developer however nonetheless wants alignment. We should add a Color.clear view within the modifier to resolve the dearth of alignment objects.

personal struct MyFrameLayout: Layout, ViewModifier {
let width: CGFloat?
let peak: CGFloat?
let alignment: Alignment

func physique(content material: Content) -> some View {
MyFrameLayout(width: width, peak: peak, alignment: alignment)() { // Due to the a number of parentheses brought on by callAsFunction
Color.clear // Add views for alignment help.
content material
}
}
}

public extension View {
func myFrame(width: CGFloat? = nil, peak: CGFloat? = nil, alignment: Alignment = .heart) -> some View {
self
.modifier(MyFrameLayout(width: width, peak: peak, alignment: alignment))
}
@out there(*, deprecated, message: "Please go a number of parameters.")
func myFrame() -> some View {
modifier(MyFrameLayout(width: nil, peak: nil, alignment: .heart))
}
}

This model of the body has the next capabilities:

  • When each dimensions have particular values set, use these two values because the required measurement of the _FrameLayout container and the format measurement of the subview.
  • When just one dimension has a selected worth A set, use this worth A because the required measurement of the _FrameLayout container in that dimension. For the opposite dimension, use the required measurement of the subview because the required measurement (use A and the proposed measurement obtained by _FrameLayout because the proposed measurement of the subview).
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.depend == 2, let content material = subviews.final else { fatalError("Can't use MyFrameLayout immediately") }
var consequence: CGSize = .zero

if let width, let peak { // Both dimensions are set.
consequence = .init(width: width, peak: peak)
}
if let width, peak == nil { // Only width is ready
let contentHeight = content material.sizeThatFits(.init(width: width, peak: proposal.peak)).peak // Required measurement of the subview on this dimension
consequence = .init(width: width, peak: contentHeight)
}
if let peak, width == nil {
let contentWidth = content material.sizeThatFits(.init(width: proposal.width, peak: peak)).width
consequence = .init(width: contentWidth, peak: peak)
}
if peak == nil, width == nil {
consequence = content material.sizeThatFits(proposal)
}
return consequence
}

In placeSubviews, we are going to make the most of the auxiliary view added within the modifier to align and place the subview.

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.depend == 2, let background = subviews.first, let content material = subviews.final else {
fatalError("Can't use MyFrameLayout immediately")
}
background.place(at: .zero, anchor: .topLeading, proposal: .init(width: bounds.width, peak: bounds.peak))
// Get the place of the Color.clear's alignment information
let backgroundDimensions = background.dimensions(in: .init(width: bounds.width, peak: bounds.peak))
let offsetX = backgroundDimensions[alignment.horizontal]
let offsetY = backgroundDimensions[alignment.vertical]
// Get the place of the subview's alignment information
let contentDimensions = content material.dimensions(in: .init(width: bounds.width, peak: bounds.peak))
// Calculate the topLeading offset of content material
let main = offsetX - contentDimensions[alignment.horizontal] + bounds.minX
let high = offsetY - contentDimensions[alignment.vertical] + bounds.minY
content material.place(at: .init(x: main, y: high), anchor: .topLeading, proposal: .init(width: bounds.width, peak: bounds.peak))
}

Now, we are able to use myFrame to exchange the body in views and obtain the identical impact.

fastenedSize supplies an unspecified mode (nil) proposed measurement for a selected dimension of the subview. It does this to return the perfect measurement as its required measurement in that dimension and use that measurement as its personal required measurement returned to the mother or father view.

personal struct MyFixedSizeLayout: Layout, ViewModifier {
let horizontal: Bool
let vertical: Bool

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.depend == 1, let content material = subviews.first else {
fatalError("Can't use MyFixedSizeLayout immediately")
}
// Prepare the proposed measurement for submission to the subview
let width = horizontal ? nil : proposal.width // If horizontal is true then submit the proposal dimensions for the unspecified mode, in any other case present the advised dimensions for the mother or father view on this dimension
let peak = vertical ? nil : proposal.peak // If vertical is true then submit the proposal dimensions for the unspecified mode, in any other case present the advised dimensions for the mother or father view on this dimension
let measurement = content material.sizeThatFits(.init(width: width, peak: peak)) // Submits the proposal dimensions decided above to the subview and will get the subview's required dimensions
return measurement // Take the required measurement of the kid view because the required measurement of the MyFixedSizeLayout container
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.depend == 1, let content material = subviews.first else {
fatalError("Can't use MyFixedSizeLayout immediately")
}
content material.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, peak: bounds.peak))
}

func physique(content material: Content) -> some View {
MyFixedSizeLayout(horizontal: horizontal, vertical: vertical)() {
content material
}
}
}

public extension View {
func myFixedSize(horizontal: Bool, vertical: Bool) -> some View {
modifier(MyFixedSizeLayout(horizontal: horizontal, vertical: vertical))
}
func myFixedSize() -> some View {
myFixedSize(horizontal: true, vertical: true)
}
}

Given the large variations between the 2 body variations, each functionally and implementation-wise, they correspond to totally different format containers in SwiftUI. body(minWidth:, idealWidth: , maxWidth: , minHeight: , idealHeight:, maxHeight: , alignment:) is a wrapper across the _FlexFrameLayout format container.

_FlexFrameLayout is basically a mix of two functionalities:

  • When the perfect worth is ready, and the mother or father view supplies an unspecified mode proposed measurement in that dimension, return the perfect worth because the required measurement and use it because the format measurement of the subview.
  • When min or (and) max has a price, return the required measurement in that dimension in keeping with the next guidelines (diagram from SwiftUI-Lab):
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.depend == 2, let content material = subviews.final else { fatalError("Can't use MyFlexFrameLayout immediately") }

var resultWidth: CGFloat = 0
var resultHeight: CGFloat = 0
let contentWidth = content material.sizeThatFits(proposal).width // Get the required measurement of the kid view by way of width utilizing the proposal measurement of the mother or father view because the proposal measurement
// idealWidth has a price and the mother or father view has an unspecified mode for the proposal measurement by way of width, the required width is idealWidth
if let idealWidth, proposal.width == nil {
resultWidth = idealWidth
} else if minWidth == nil, maxWidth == nil { // min and max are each unspecified, returning the required dimensions of the kid view by way of width.
resultWidth = contentWidth
} else if let minWidth, let maxWidth { // If each min and max have values
resultWidth = clamp(min: minWidth, max: maxWidth, supply: proposal.width ?? contentWidth)
} else if let minWidth { // min If there's a worth, be sure that the requirement measurement shouldn't be smaller than the minimal worth.
resultWidth = clamp(min: minWidth, max: maxWidth, supply: contentWidth)
} else if let maxWidth { // When max has a price, be sure that the required measurement shouldn't be bigger than the utmost worth.
resultWidth = clamp(min: minWidth, max: maxWidth, supply: proposal.width ?? contentWidth)
}
// Use the required width decided above because the proposal width to get the required peak of the kid view
let contentHeight = content material.sizeThatFits(.init(width: proposal.width == nil ? nil : resultWidth, peak: proposal.peak)).peak
if let idealHeight, proposal.peak == nil {
resultHeight = idealHeight
} else if minHeight == nil, maxHeight == nil {
resultHeight = contentHeight
} else if let minHeight, let maxHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, supply: proposal.peak ?? contentHeight)
} else if let minHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, supply: contentHeight)
} else if let maxHeight {
resultHeight = clamp(min: minHeight, max: maxHeight, supply: proposal.peak ?? contentHeight)
}
let measurement = CGSize(width: resultWidth, peak: resultHeight)
return measurement
}

// Limit values to between minimal and most
func clamp(min: CGFloat?, max: CGFloat?, supply: CGFloat) -> CGFloat {
var consequence: CGFloat = supply
if let min {
consequence = Swift.max(supply, min)
}
if let max {
consequence = Swift.min(supply, max)
}
return consequence
}

In the View extension, you may test if min, preferrred, and max values are in ascending order:

public extension View {
func myFrame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .heart) -> some View {
// min < preferrred < max
func areInNondecreasingOrder(
_ min: CGFloat?, _ preferrred: CGFloat?, _ max: CGFloat?
) -> Bool {
let min = min ?? -.infinity
let preferrred = preferrred ?? min
let max = max ?? preferrred
return min <= preferrred && preferrred <= max
}

// The official SwiftUI implementation will nonetheless execute in case of a numerical error, however will show an error message within the console.
if !areInNondecreasingOrder(minWidth, idealWidth, maxWidth)
|| !areInNondecreasingOrder(minHeight, idealHeight, maxHeight) {
fatalError("Contradictory body constraints specified.")
}
return modifier(MyFlexFrameLayout(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment))
}
}

The Layout protocol supplies a wonderful window to achieve a deep understanding of the SwiftUI format mechanism. Whether it’s good to create customized format containers utilizing the Layout protocol in your future work or not, mastering it’ll convey nice advantages.

I hope this text will assist you to.

You may also like

Leave a Comment