SwiftUI layout in VStack where one child is offered a maxHeight but can use less than that height - swift

I'm trying to build a layout inside a VStack that contains two children. The first child should take up all available space unused by the second child. The second child has a preferred size based on its own contents. I'd like to limit the height of the second child to a maximum height, but it should be able to take less than the maximum (when its own contents cannot make use of all the height). This should all be responsive to the root view size, which is the parent of the VStack (because the device can rotate).
My attempt uses the .frame(maxHeight: n) modifier, which seems to unconditionally takes up the entire n points of height, even when the view being modified doesn't use it. This results in whitespace rendered above and below the VStack's second child. This problem is shown in the Portrait preview below - the hasIdealSizeView only has a height of 57.6pts, but the frame that wraps that view has a height of 75pts.
import SwiftUI
struct StackWithOneLimitedHeightChild: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
hasIdealSizeView
.frame(maxHeight: geometry.size.height / 4)
}
}
}
var fullyExpandingView: some View {
Rectangle()
.fill(Color.blue)
}
var hasIdealSizeView: some View {
HStack {
Rectangle()
.aspectRatio(5/3, contentMode: .fit)
Rectangle()
.aspectRatio(5/3, contentMode: .fit)
}
// the following modifier just prints out the resulting height of this view in the layout
.overlay(alignment: .center) {
GeometryReader { geometry in
Text("Height: \(geometry.size.height)")
.font(.system(size: 12.0))
.foregroundColor(.red)
}
}
}
}
struct StackWithOneLimitedHeightChild_Previews: PreviewProvider {
static var previews: some View {
Group {
StackWithOneLimitedHeightChild()
.previewDisplayName("Portrait")
.previewLayout(PreviewLayout.fixed(width: 200, height: 300))
StackWithOneLimitedHeightChild()
.previewDisplayName("Landscape")
.previewLayout(PreviewLayout.fixed(width: 300, height: 180))
}
}
}
This observed result is consistent with how the .frame(maxHeight: n) modifier is described in the docs and online blog posts (the flow chart here is extremely helpful). Nonetheless, I can't seem to find another way to build this type of layout.
Related question: what are the expected use cases for .frame(maxHeight: n)? It seems to do the opposite of what I'd expect by unconditionally wrapping the view in a frame that is at least n points in height. It seems no different than .frame(height: n), using an explicit value for the offered height.

The behavior of .minHeight in this example is strange and far from intuitive. But I found a solution using a slightly different route:
This defines the minHeight for the expanding view (to get the desired layout in portrait mode), but adds a .layoutPriority to the second, making it define itself first and then give the remaining space to the upper view.
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
.frame(minHeight: geometry.size.height / 4 * 3)
hasIdealSizeView
.layoutPriority(1)
}
}
}

There's probably a really short way to go about this but in the meantime here is what I did.
Firstly I created a struct for your hasIdealSizeView and I made it return a GeometryProxy, and with that i could return the height of the HStack, in this case, the same height you were printing on to the Text View. then with that I used the return proxy to check if the height is greater than the maximum, and if it is, i set it to the maximum, otherwise, set the height to nil, which basically allows the native SwiftUI flexible height:
//
// ContentView.swift
// Test
//
// Created by Denzel Anderson on 3/16/22.
//
import SwiftUI
struct StackWithOneLimitedHeightChild: View {
#State var viewHeight: CGFloat = 0
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
.overlay(Text("\(viewHeight)"))
// GeometryReader { geo in
hasIdealSizeView { proxy in
viewHeight = proxy.size.height
}
.frame(height: viewHeight > geometry.size.height / 4 ? geometry.size.height / 4:nil)
}
.background(Color.green)
}
}
var fullyExpandingView: some View {
Rectangle()
.fill(Color.blue)
}
}
struct hasIdealSizeView: View {
var height: (GeometryProxy)->()
var body: some View {
HStack {
Rectangle()
.fill(.white)
.aspectRatio(5/3, contentMode: .fit)
Rectangle()
.fill(.white)
.aspectRatio(5/3, contentMode: .fit)
}
// the following modifier just prints out the resulting height of this view in the layout
.overlay(alignment: .center) {
GeometryReader { geometry in
Text("Height: \(geometry.size.height)")
.font(.system(size: 12.0))
.foregroundColor(.red)
.onAppear {
height(geometry)
}
}
}
}
}

Related

Responsive text box with GeometryReader

I'm trying to create responsive changes to some view parameters (e.g. font size, container box size) based on how much of a given space a text element is occupying at a given moment. The text must also be pushed to the right side of the space, which I'm accomplishing by grouping it with a Spacer() inside an HStack. Because of the space-greediness (and a related behavior I don't understand) of GeometryReader, I'm having a hard time getting the sizing variables I need without breaking the intended structure and behavior of the view.
Here's a version of the view without the responsive changes. The text appears where it should within the rounded rectangle:
struct responsiveTextBox: View {
#State var text = "Hi. "
var body: some View {
VStack {
ZStack{
RoundedRectangle(cornerRadius: 5)
.stroke()
HStack {
Spacer()
Text(text)
}
}
.frame(width: 300, height: 100)
Button("Add text") {
text += "Hi. "
}
Button("Erase text") {
text = ""
}
}
}
}
In order to implement functionality that responds to the relative amount of space occupied by the text, I need to know the size of either the Spacer() element or the Text() element itself. Wrapping the Spacer() in a GeometryReader doesn't work, because unlike the Spacer alone, the GeometryReader won't shrink to less than 50% of the HStack's width:
[...]
HStack {
GeometryReader() {freeSpace in
Spacer()
}
Text(text)
}
[...]
I don't really understand this behavior.
The alternative - wrapping the Text() element in a GeometryReader - also doesn't work, because the GeometryReader's space-greediness overrides the Spacer's, and the text appears on the left side of the rounded rectangle.
HStack {
Spacer()
GeometryReader() {textSpace in
Text(text)
}
}
What's the right way to do this?
In cases like these you can use GeometryReader in an overlay or background of the element you are interested in – so it does not affect the layout.
IMHO you also don't need the Spacer or HStack, just set the alignment on ZStack.
And ultimately you might want to look into the new Layout protocol. It seems to be the way to go for what you want to achieve. Example here
struct ContentView: View {
#State var text = "Hi. "
#State var textSize = CGSize.zero
var body: some View {
VStack {
ZStack(alignment: .trailing) {
RoundedRectangle(cornerRadius: 5)
.stroke()
Text(text)
.id(text)
.background( GeometryReader { geo in Color.clear.onAppear { textSize = geo.size }})
}
.frame(width: 300, height: 100)
Button("Add text") {
text += "Hi. "
}
Button("Erase text") {
text = ""
}
Text("Width: \(textSize.width), Height: \(textSize.height)")
}
}
}

Prevent view from overflowing in both directions

I'm trying to build a user-expandable view that will show more of its content by increasing its height. To accomplish this I will add .clipped() so that the content shown out of its bounds will be hidden, just like you would add overflow: hidden; in CSS.
However, it seems like by default VStack is centering its children, so when the height is smaller than the sum of the children's height, they overflow in both the top and the bottom.
Here is an example of what I'm talking about:
struct ExandableView: View {
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.red)
.frame(height: 50)
.padding(.horizontal)
Rectangle()
.fill(Color.green)
.frame(height: 50)
.padding(.horizontal)
}
.frame(height: 90)
.background(Color.blue)
}
}
Is there any way to make it behave so that the red item (the first) is always inside the blue container and the children can only overflow from the bottom?
Change frame alignment according to ExandableView. like this
struct ExandableView: View {
var body: some View {
// Other VStack code
}
.frame(height: 90, alignment: isExpand ? .top : .center) //<--Here
.background(Color.blue)
}
}

ScrollView + NavigationView animation glitch SwiftUI

I've got a simple view:
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(0..<2) { _ in
CardVew(for: cardData)
}
}
}
.navigationBarTitle("Testing", displayMode: .automatic)
}
}
But you can replace the CardView with anything - the glitch presists. Glitch video
Is there a way to fix it?
Xcode 12.0.1, Swift 5
Setting the top padding to 1 is breaking at least 2 major things:
The scroll view does not extend under NavigationView and TabView - this making it loose the beautiful blur effect of the content that scrolls under the bars.
Setting background on the scroll view will cause Large Title NavigationView to stop collapsing.
I've encountered these issues when i had to change the background color on all screens of the app i was working on.
So i did a little bit more digging and experimenting and managed to figure out a pretty nice solution to the problem.
Here is the raw solution:
We wrap the ScrollView into 2 geometry readers.
The top one is respecting the safe area - we need this one in order to read the safe area insets
The second is going full screen.
We put the scroll view into the second geometry reader - making it size to full screen.
Then we add the content using VStack, by applying safe area paddings.
At the end - we have scroll view that does not flicker and accepts background without breaking the large title of the navigation bar.
struct ContentView: View {
var body: some View {
NavigationView {
GeometryReader { geometryWithSafeArea in
GeometryReader { geometry in
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
.padding(.top, geometryWithSafeArea.safeAreaInsets.top)
.padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
.padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
.padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
}
.background(Color.yellow)
}
.edgesIgnoringSafeArea(.all)
}
.navigationBarTitle(Text("Example"))
}
}
}
The elegant solution
Since the solution is clear now - lets create an elegant solution that can be reused and applied to any existing ScrollView by just replacing the padding fix.
We create an extension of ScrollView that declares the fixFlickering function.
The logic is basically we wrap the receiver into the geometry readers and wrap its content into the VStack with the safe area paddings - that's it.
The ScrollView is used, because the compiler incorrectly infers the Content of the nested scroll view as should being the same as the receiver. Declaring AnyView explicitly will make it accept the wrapped content.
There are 2 overloads:
the first one does not accept any arguments and you can just call it on any of your existing scroll views, eg. you can replace the .padding(.top, 1) with .fixFlickering() - thats it.
the second one accept a configurator closure, which is used to give you the chance to setup the nested scroll view. Thats needed because we don't use the receiver and just wrap it, but we create a new instance of ScrollView and use only the receiver's configuration and content. In this closure you can modify the provided ScrollView in any way you would like, eg. setting a background color.
extension ScrollView {
public func fixFlickering() -> some View {
return self.fixFlickering { (scrollView) in
return scrollView
}
}
public func fixFlickering<T: View>(#ViewBuilder configurator: #escaping (ScrollView<AnyView>) -> T) -> some View {
GeometryReader { geometryWithSafeArea in
GeometryReader { geometry in
configurator(
ScrollView<AnyView>(self.axes, showsIndicators: self.showsIndicators) {
AnyView(
VStack {
self.content
}
.padding(.top, geometryWithSafeArea.safeAreaInsets.top)
.padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
.padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
.padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
)
}
)
}
.edgesIgnoringSafeArea(.all)
}
}
}
Example 1
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
}
.fixFlickering { scrollView in
scrollView
.background(Color.yellow)
}
.navigationBarTitle(Text("Example"))
}
}
}
Example 2
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
}
.fixFlickering()
.navigationBarTitle(Text("Example"))
}
}
}
Here's a workaround. Add .padding(.top, 1) to the ScrollView:
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(0..<2) { _ in
Color.blue.frame(width: 350, height: 200)
}
}
}
.padding(.top, 1)
.navigationBarTitle("Testing", displayMode: .automatic)
}
}
}
I simplified #KoCMoHaBTa's answer. Here it is:
extension ScrollView {
private typealias PaddedContent = ModifiedContent<Content, _PaddingLayout>
func fixFlickering() -> some View {
GeometryReader { geo in
ScrollView<PaddedContent>(axes, showsIndicators: showsIndicators) {
content.padding(geo.safeAreaInsets) as! PaddedContent
}
.edgesIgnoringSafeArea(.all)
}
}
}
Use like so:
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
/* ... */
}
.fixFlickering()
}
}
}
while facing the same problem I did a bit of investigation.
The glitch seems to come from a combination of scrollview bouncing, and the speed of deceleration of the scrolling context.
For now I have managed to make the glitch disappear by settings the deceleration rate to fast. It seem to let swiftui better compute the layout while keeping the bounce animation active.
My work around is as simple as to set the following in the init of your view. The drawback its that this affects the speed of your scrolling deceleration.
init() {
UIScrollView.appearance().decelerationRate = .fast
}
A possible improvement would be to compute the size of the content being displayed and then switch on the fly the deceleration rate depending what would be needed.

Dynamic row height in a SwiftUI form

I'm adding controls to a SwiftUI Form to assist the user enter data (and constrain the entries!). Although there is a lot to like about Forms, I've discovered that things that work nicely outside this container do very unexpected things inside it and it's not always obvious how to compensate for this.
The plan is to have the data field displayed as a single row. When the row is tapped, the control slides out from behind the data field - the row will need to expand (height) to accommodate the control.
I'm using Swift Playgrounds to develop the proof of concept (or failure in my case). The idea is to use a ZStack which will allow a nice sliding animation by overlaying the views and giving them a different zIndex and applying the offset when the data field view is tapped. Sounds simple but of course the Form row does not expand when the ZStack is expanded.
Adjusting the frame of the ZStack while expanding causes all sorts of weird changes in padding (or at least it looks like it) which can be compensated for by counter-offsetting the "top" view but this causes other unpredictable behaviour. Pointers and ideas gratefully accepted.
import SwiftUI
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { self.isDisclosed.toggle() }
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(y: isDisclosed ? 50 : 0)
.animation(.easeOut)
}
}
Spacer()
}
}
}
Collapsed stack
Expanded stack - view overlaps adjacent row
Result when adjusting ZStack vertical frame when expanded - top padding increases
Here is possible solution with fluent row height change (using AnimatingCellHeight modifier taken from my solution in SwiftUI - Animations triggered inside a View that's in a list doesn't animate the list as well ).
Tested with Xcode 11.4 / iOS 13.4
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { withAnimation { self.isDisclosed.toggle() } }
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 150 : 100))
Spacer()
}
}
}
Use alignmentGuide instead of offset.
...
//.offset(y: isDisclosed ? 50 : 0)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 50 : 0) })
...
offset doesn't affect its view's frame. that's why Form doesn't react as expected. On the contrary, alignmentGuide does.
I now have a working implementation using alignment guides as suggested by Kyokook. I have softened the somewhat jarring row height change by adding an opacity animation to the Stepper as it slides out. This also helps to prevent a slightly glitchy overlap of the row title when the control is closed.
struct ContentView: View {
// MARK: Logic state
#State private var years = 0
#State private var months = 0
#State private var weeks = 0
// MARK: UI state
#State var isStepperVisible = false
var body: some View {
Form {
Text("Row 1")
VStack {
// alignment guide must be explicit for the ZStack & all child ZStacks
// must use the same alignment guide - weird stuff happens otherwise
ZStack(alignment: .top) {
HStack {
Text("AGE")
.bold()
.font(.footnote)
Spacer()
Text("\(years) years \(months) months \(weeks) weeks")
.foregroundColor(self.isStepperVisible ? Color.blue : Color.gray)
}
.frame(height: 35) // TODO: Without this, text in HStack vertically offset. Investigate. (HStack align doesn't help)
.background(Color.white) // Prevents overlap of text during transition
.zIndex(3)
.contentShape(Rectangle())
.onTapGesture {
self.isStepperVisible.toggle()
}
HStack(alignment: .center) {
StepperComponent(value: $years, label: "Years", bounds: 0...30, isVisible: $isStepperVisible)
StepperComponent(value: $months, label: "Months", bounds: 0...12, isVisible: $isStepperVisible)
StepperComponent(value: $weeks, label: "Weeks", bounds: 0...4, isVisible: $isStepperVisible)
}
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isStepperVisible ? 40 : 0) })
}
}
Text("Row 3")
}
}
}
struct StepperComponent<V: Strideable>: View {
// MARK: Logic state
#Binding var value: V
var label: String
var bounds: ClosedRange<V>
//MARK: UI state
#Binding var isVisible: Bool
var body: some View {
ZStack(alignment: .top) {
Text(label.uppercased()).font(.caption).bold()
.frame(alignment: .center)
.zIndex(1)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
Stepper(label, value: self.$value, in: bounds)
.labelsHidden()
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isVisible ? 25 : 0) })
.frame(alignment: .center)
.zIndex(2)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
}
}
}
There is still some room for improvement here but on the whole I'm pleased with the result :-)
Thanks to both Kyokook (for putting me straight on offset()) and Asperi.
I think the Kyokook's solution (using AlignmentGuides) is simpler and would be my preference in that it's leveraging Apple's existing API and seems to cause less unpredictable movement of the views in their container. However, the row height changes abruptly and isn't synchronised. The animation in the Asperi's example is smoother but there is some bouncing of the views within the row (it's almost as if the padding or insets are changing and then being reset at the end of the animation). My approach to animation is a bit hit-and-miss so any further comments would be welcome.
Solution 1 (frame consistent, animation choppy):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
self.isDisclosed.toggle()
}
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 100 : 0) })
.animation(.easeOut)
Text("Row 3")
}
}
Text("Row 3")
}
}
}
Solution 2 (smoother animation but frame variance):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
withAnimation { self.isDisclosed.toggle() }
}
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 200 : 100))
}
Text("Row 3")
}
}
}
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height)
}
}

SwiftUI - Using GeometryReader Without Modifying The View Size

I have a header view which extends its background to be under the status bar using edgesIgnoringSafeArea. To align the content/subviews of the header view correctly, I need the safeAreaInsets from GeometryReader. However, when using GeometryReader, my view doesn't have a fitted size anymore.
Code without using GeometryReader
struct MyView : View {
var body: some View {
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.bottom, 64)
.background(Color.blue)
}
}
Preview
Code using GeometryReader
struct MyView : View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.top, geometry.safeAreaInsets.top)
.padding(.bottom, 64)
.background(Color.blue)
.fixedSize()
}
}
}
Preview
Is there a way to use GeometryReader without modifying the underlying view size?
Answer to the question in the title:
It is possible to wrap the GeometryReader in an .overlay() or .background(). Doing so will mitigate the layout changing effect of GeometryReader. The view will be laid out as normal, the GeometryReader will expand to the full size of the view and emit the geometry into its content builder closure.
It's also possible to set the frame of the GeometryReader to stop its eagerness in expanding.
For example, this example renders a blue rectangle, and a "Hello world" text inside at 3/4th the height of the rectangle (instead of the rectangle filling up all available space) by wrapping the GeometryReader in an overlay:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
})
Spacer()
}
}
Another example to achieve the same effect by setting the frame on the GeometryReader:
struct MyView : View {
var body: some View {
GeometryReader { geo in
Rectangle().fill(Color.blue)
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
}
.frame(height: 150)
Spacer()
}
}
However, there are caveats / not very obvious behaviors
1
View modifiers apply to anything up to the point that they are applied, and not to anything after. An overlay / background that is added after .edgesIgnoringSafeArea(.all) will respect the safe area (not participate in ignoring the safe area).
This code renders "Hello world" inside the safe area, while the blue rectangle ignores the safe area:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.edgesIgnoringSafeArea(.all)
.overlay(VStack {
Text("Hello world")
Spacer()
})
Spacer()
}
}
2
Applying .edgesIgnoringSafeArea(.all) to the background makes GeometryReader ignore the SafeArea:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
VStack {
Text("Hello world")
// No effect, safe area is set to be ignored.
.padding(.top, geo.safeAreaInsets.top)
Spacer()
}
})
.edgesIgnoringSafeArea(.all)
Spacer()
}
}
It is possible to compose many layouts by adding multiple overlays / backgrounds.
3
A measured geometry will be available to the content of the GeometryReader. Not to parent or sibling views; even if the values are extracted into a State or ObservableObject. SwiftUI will emit a runtime warning if that happens:
struct MyView : View {
#State private var safeAreaInsets = EdgeInsets()
var body: some View {
Text("Hello world")
.edgesIgnoringSafeArea(.all)
.background(GeometryReader(content: set(geometry:)))
.padding(.top, safeAreaInsets.top)
Spacer()
}
private func set(geometry: GeometryProxy) -> some View {
self.safeAreaInsets = geometry.safeAreaInsets
return Color.blue
}
}
I tried with the previewLayout and I see what you mean. However, I think the behavior is as expected. The definition of .sizeThatFits is:
Fit the container (A) to the size of the preview (B) when offered the
size of the device (C) on which the preview is running.
I intercalated some letters to define each part and make it more clear:
A = the final size of the preview.
B = The size of what you are modifying with .previewLayout(). In the first case, it's the VStack. But in the second case, it's the GeometryReader.
C = The size of the screen of the device.
Both views act differently, because VStack is not greedy, and only takes what it needs. GeometryReader, on the other side, tries to have it all, because it does not know what its child will want to use. If the child wants to use less, it can do it, but it has to start by being offered everything.
Perhaps if you edit your question to explain exactly what you would like to accomplish, I can refine my answer a little.
If you would like GeometryReader to report the size of the VStack. you may do so by putting it inside a .background modifier. But again, I am not sure what's the goal, so maybe that's a no go.
I have written an article about the different uses of GeometryReader. Here's the link, in case it helps: https://swiftui-lab.com/geometryreader-to-the-rescue/
UPDATE
Ok, with your additional explanation, here you have a working solution. Note that the Preview will not work, because safeInsets are reported as zero. On the simulator, however, it works fine:
As you will see, I use view preferences. They are not explained anywhere, but I am currently writing an article about them that I will post soon.
It may all look too verbose, but if you find yourself using it too often, you can encapsulate it inside a custom modifier.
import SwiftUI
struct InsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct InsetGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().preference(key: InsetPreferenceKey.self, value: geometry.safeAreaInsets.top)
}
}
}
struct ContentView : View {
var body: some View {
MyView()
}
}
struct MyView : View {
#State private var topInset: CGFloat = 0
var body: some View {
VStack {
CustomView(inset: topInset)
.padding(.horizontal)
.padding(.bottom, 64)
.padding(.top, topInset)
.background(Color.blue)
.background(InsetGetter())
.edgesIgnoringSafeArea(.all)
.onPreferenceChange(InsetPreferenceKey.self) { self.topInset = $0 }
Spacer()
}
}
}
struct CustomView: View {
let inset: CGFloat
var body: some View {
VStack {
HStack {
Text("C \(inset)").color(.white).fontWeight(.bold).font(.title)
Spacer()
}
HStack {
Text("A").color(.white)
Text("B").color(.white)
Spacer()
}
}
}
}
I managed to solve this by wrapping the page main view inside a GeometryReader and pass down the safeAreaInsets to MyView. Since it is the main page view where we want the entire screen thus it is ok to be as greedy as possible.