1

I'm new to SwiftUI - not new to iOS development. I have a lot of custom design/drawing to do and that seems particularly difficult in SwiftUI.
Please bear with me and prepare for a long one.

What's great about SwiftUI is that it's based on composition and you automagically get auto sizing and fitting to different devices.

But what is troubling me it that as soon as you have to size things relative to each other, you quickly have to turn to GeometryReader. And GeometryReader seems to be very contradictionary to the sizing concepts in SwiftUI.

Opposite to Autolayout constrains, where you easily can add relative constraints of dependency on other views or superviews, SwiftUI doesn't seem to do this so easily.

Lets say I want to add a view as overlay of an image, making sure the overlay always will have a relative padding of the superview - like margins in percentages. Concept drawing The topY, bottomY, leadingX and trailingX are relative to the way the super view resizes, so that the content view will always fit in the "frame". (This is a simplified version - consider the "frame" to be more that just boxes.)

In Autolayout I would just add the overview and then constraint the overlay vidth to the superview width, with a factor. In that way the padding of the overlay will follow along as the superview resizes.

Doing this in SwiftUI will have you to wrap the base view in a GeometryReader (which by it selv causes the entire view to fill the whole screen and change the coordinate system). The issue at hand is that when adding an overlay in SwiftUI, this works the same way as adding a view on top in UIKit. Now there is two different ways to make the overlay relative. Both involves finite pixel coordinates - which is my main complaint here. As soon as you enter the world of GeometryReader, you enter the world of fixed pixel based positioning and loos all the good parts of the automatic fitting in SwiftUI.

So, to make the overlay padding relative to the superview I can either:
A) Add .padding with EdgeInsets(), where edge insets is calculated by a factor of the super view size.
B) Use .offset() and .frame() to offset the position and size of the overlay by a calculated factor relative to the superview size.

This is pretty simple when just using rectangles:

struct RelativeView: View {
    var body: some View {
        GeometryReader { geo in
        Rectangle() // Superview
            .foregroundColor(Color.blue)
            .overlay(Rectangle() // Relative overview (content view)
                        .foregroundColor(Color.yellow)
                        .padding(EdgeInsets(top: geo.size.height/4, leading: (geo.size.width*0.25)/2, bottom: geo.size.height/4, trailing: (geo.size.width*0.25)/2))
                        .overlay(Text("Yellow overlay must be 1/2 height and 3/4 width of the blue"))
            )
        }
    }
}

Result

The real problem starts when the super view is a Image that scales to .fit:

struct RelativeView: View {
    var body: some View {
        GeometryReader { geo in
            
            let realImageWidth = CGFloat(4032)
            let viewWidth = geo.size.width
            let relativeWidthFactor = viewWidth/realImageWidth
            let leadingX = CGFloat(1110)
            let trailingX = CGFloat(750)
            let topY = CGFloat(770)
            let bottomY = CGFloat(936)
            
            Image("TestBackground")
                .resizable()
                .aspectRatio(contentMode: .fit) // Superview
                .overlay(Rectangle() // Relative overview (content view)
                        .foregroundColor(Color.yellow)
                        .padding(EdgeInsets(top: topY * relativeWidthFactor, leading: leadingX * relativeWidthFactor, bottom: bottomY * relativeWidthFactor, trailing: trailingX * relativeWidthFactor))
                        .overlay(Text("Yellow overlay must be 1/2 height and 3/4 width of the blue"))
                )
        }
    }
}

Result 2

Please note that the center text that is not properly centered is caused by padding not being respected by Rectangles. If I was wrapping the yellow Rectangle in a separate View, it would be centered in the yellow box instead of the super. (Don't get me started on that...)

The problems start to surface when I then use this View in another view. Then the GeometryReader starts to mess with the dynamic SwiftUI auto-world.

import SwiftUI

struct GrainCart3dView: View {
    var body: some View {
        GeometryReader { geo in
            VStack {
                Rectangle().foregroundColor(.green)
                RelativeContainerView()
            }
        }
    }
}


struct RelativeContainerView: View {
    var body: some View {
        GeometryReader { geo in
            
            let realImageWidth = CGFloat(4032)
            let viewWidth = geo.size.width
            let relativeWidthFactor = viewWidth/realImageWidth
            let leadingX = CGFloat(1110)
            let trailingX = CGFloat(750)
            let topY = CGFloat(770)
            let bottomY = CGFloat(936)
            
            Image("TestBackground")
                .resizable()
                .aspectRatio(contentMode: .fit) // Superview
                .overlay(RelativeContentView() // Relative overview (content view)
                        .padding(EdgeInsets(top: topY * relativeWidthFactor, leading: leadingX * relativeWidthFactor, bottom: bottomY * relativeWidthFactor, trailing: trailingX * relativeWidthFactor))
                )
        }
    }
}

struct RelativeContentView: View {
    var body: some View {
        Rectangle()
            .foregroundColor(.yellow)
            .overlay(Text("Yellow overlay must be 1/2 height and 3/4 width of the blue"))
    }
}

On iPad in landscape the even distribution results in the frame view not filling the entire width and since the frame padding is calculated based on the width, it's now wrong. result on iPad landscape result on iPad portrait result on iPhone

Where is the content view now on the iPad?

The VStack distributes the two view evenly, but let's say I want to fit the frame-view in width and maintain aspect and then fit the green view to to the remaining.

In Autolayout I could just set priority of the frame-view, but thats not how it works with SwiftUI. My only way with SwiftUI is to wrap it in yet another super view with yet another GeomotryReader mathematically setting a calculated relationship between the green- and the frame views. And even worse, I have to se sizes on both - there is no way to set the size of one and "fill the gap" automatically with the second.

My issue at hand here is that it seems like the sizing and positioning using GeometryReader seems very tied to pixels and very far from the relative and dynamic concept of SwiftUI.

So, to clarify what I'm looking for is making a frame where the padding scales relatively to the super view size while distributing it vertically unevenly. Expected result

How do I approach this in a better and more generic way?

esbenr
  • 1,356
  • 1
  • 11
  • 34
  • I'm a bit confused what you mean in the last part of the question under "Where is the content view now on the iPad?". How would you like the layout to look? – George Jan 03 '22 at 15:21
  • That's a good point. Let me update the already long question. Sorry. – esbenr Jan 03 '22 at 15:24
  • I tend to avoid Geometry reader as much as possible. Just like you implied, it doesn't really fit the mindset of constraints. I just wonder about this: _The real problem starte when the super view is a Image that scales to .fit:_ - why are you even using `.fit` instead of `.fill`? The latter causes much less problem, because it's generally more dynamic in it's uses. – Rickard Elimää Jan 03 '22 at 15:25
  • 1
    @esbenr: I looked to your code real quick, your approach with padding is not the best one! Try adapt using position or offset, because you already using GeometryReader. And you have access to all data of proxy. – ios coder Jan 03 '22 at 15:28
  • @George I updated the question with the expected result made in Photoshop (with the uneven distribution) Also added the full source so you can reproduce it. – esbenr Jan 03 '22 at 15:41
  • @esbenr If the screen is not tall enough so the image can't go full width, do you just want to fit it in the screen and have space on the sides? Or do you want to fill the screen so the top & bottom of the image is cut off? – George Jan 03 '22 at 15:43
  • @RickardElimää Thanks for your suggestion. If I use .fill then I'll have to scale the frame image manually to the screen width before adding the Image(...). That's also a solution. It's more manual but gives med more control. The issue with both (or the Image(...)) is that the view doesent resize according to the image on both height and width. It adds white space and it's impossible to know the size of that. – esbenr Jan 03 '22 at 15:44
  • @George If the screen is not tall enough I want the frame view to have space on the sides and the green to be minimum/gone. – esbenr Jan 03 '22 at 15:46
  • @esbenr Done, check my answer – George Jan 03 '22 at 15:46

1 Answers1

2

Firstly - you can simplify the first part with the yellow & blue views. You can just set the frame size of the Text, add the background, then make the GeometryReader fill the overlay. This is far easier than trying to work out the insets on each edge.

Code:

struct ContentView: View {
    var body: some View {
        Color.blue
            .overlay(
                GeometryReader { geo in
                    Text("Yellow overlay must be 1/2 height and 3/4 width of the blue")
                        .frame(width: geo.size.width * 0.75, height: geo.size.height * 0.5)
                        .background(Color.yellow)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            )
    }
}

Next, we do a similar thing to above, but now with the image:

struct RelativeView: View {
    var body: some View {
        Image("TestBackground")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .overlay(
                GeometryReader { geo in
                    Text("Yellow overlay must be 1/2 height and 3/4 width of the blue")
                        .frame(width: geo.size.width * 0.75, height: geo.size.height * 0.5)
                        .background(Color.yellow)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            )
    }
}

Result:

Result


For the next part of trying to add another view as well, you can just make these small adjustments. This just makes the image scale to fit the screen, and giving it priority over the green color.

You may wish to use VStack(spacing: 0) { ... } instead, to remove the gap between the green color and the image.

Code:

struct ContentView: View {
    var body: some View {
        VStack {
            Color.green

            RelativeView()
                .scaledToFit()
                .layoutPriority(1)
        }
    }
}

Result:

Result 2

George
  • 25,988
  • 10
  • 79
  • 133
  • Thanks. I stated that as the 2nd option and just went for the padding. But either way the principle is the same. – esbenr Jan 03 '22 at 15:40
  • @esbenr Relative frame sizes is much more intuitive, especially when you talk about it like the constraint factors in UIKit. The code clearly shows that the `Text`'s width is `0.75` times that of the image. It also ensures that it is evenly centered, as yours can appear off-center. – George Jan 03 '22 at 15:41
  • You are clearly on to something. Thanks. but adding the .scaledToFit() and .layoutPriority(1) to the last code example where I extracted the yellow rectangle (because that going to be a more complex view anyway) it hides the green view even though there is plenty of room for it. Can you try that? – esbenr Jan 03 '22 at 15:51
  • @esbenr Your `RelativeContainerView` and `RelativeContentView` just doesn't work how you've done it. If you want to extract the yellow part into a separate view, take the part from my example between the `Text("Yellow ...` and the `.background(Color.yellow)` and put _that_ in its own view. You should let SwiftUI do all the layout work for you, you just need to describe it. – George Jan 03 '22 at 15:56
  • Thanks. I made it work with your suggestions. The last problem I have is that the 1/2 and 3/4 sizes do not fit the image precisely. That'a why I used the odd calculations. (The hardcoded paddings was based on the real image dimensions). When I introduce the GeometryReader in the RelativeContainerView, the VStack is no longer distributing the view correctly. But I'll figure that out somehow. You pointer to the .scaledToFit() and the .layoutPriority(1) was the key to control the uneven distribution. – esbenr Jan 03 '22 at 16:14
  • @esbenr Why does it not fit the image precisely? If you mean the actual image drawing doesn't align up - either edit the real image and change the asset, or you could clip the image to the right size _first_. – George Jan 03 '22 at 16:15
  • It's because the real image is not actually 1/2 and 3/4. The paddings are arbitrary and not centered. I can't show the real image due to corporate policies. I might revert to the padding-strategy as that allows me to have unique paddings on each side. But the .layoutPriority still solved the uneven distribution. – esbenr Jan 03 '22 at 19:00
  • @esbenr Ah ok, that makes sense – George Jan 03 '22 at 19:02