How to make every item the same height in a LazyVGrid? - swiftui

Edit This is a regression in iOS 15 beta. The code works as expected on iOS 14.5:
I have submitted a bug to Apple.
I have a dashboard-style screen in my SwiftUI app, where I am using a LazyVGrid with a single .adaptative column to layout my dashboard widgets, where widgets are laid out in wrapping rows.
It works as I want it to.
However, if a widget happens to be taller than others, I would like other widgets in the same row to grow vertically, so they end up having the same height as the tallest of the row.
This small bit of code illustrates my problem:
struct ContentView: View {
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
VStack {
Spacer()
Text("Hello")
}
.border(.red)
Text("Lorem ipsum")
.border(.blue)
}
.border(.green)
.padding(.horizontal, 100)
}
}
The result is:
I would like the red box (VStack containing Spacer + Hello) to be as tall as the blue box (lorem ipsum).
How could I accomplish that?
Please don't suggest using an HStack, as the above example is only to illustrate my problem with LazyVGrid. I do need to use the grid because I have quite a few children to layout, and the grid works great between phone and iPad form factors (adjusting the number of columns dynamically, exactly as I want it).

It looks like Apple begins (for unknown reason) to apply fixedSize for views in grid to make layout based on known intrinsic content sizes. The Spacer, Shape, Color, etc. do not have intrinsic size so we observe... that what's observed.
A possible approach to resolve this is perform calculations by ourselves (to find dynamically max height and apply it to all cells/views).
Here is a demo (with simple helper wrapper for cell). Tested with Xcode 13.2 / iOS 15.2
struct ContentView: View {
#State private var viewHeight = CGFloat.zero
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
GridCell(height: $viewHeight) {
VStack {
Spacer()
Text("Hello")
}
}.border(.red)
GridCell(height: $viewHeight) {
Text("Lorem ipsum asdfd")
}.border(.blue)
}
.onPreferenceChange(ViewHeightKey.self) {
self.viewHeight = max($0, viewHeight)
}
.border(.green)
.padding(.horizontal, 100)
}
}
struct GridCell<Content: View>: View {
#Binding var height: CGFloat
#ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(minHeight: height)
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

I had exactly same problem. My LazyVGrid looked great on iOS 14, but now its items have different heights.
I found a dirty workaround to force the items have same height:
In my case I have only several items in each LazyVGrid (So it won't cause too much performance drop), and it is easy for me to know which item has the largest height. So I made a ZStack and put a transparent highest item behind the actual item.
struct ContentView: View {
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
ZStack {
Text("Lorem ipsum") // This is the highest item.
.opacity(0) // Make it transparent.
Text("Hello")
}
.border(.red)
Text("Lorem ipsum")
.border(.blue)
}
.border(.green)
.padding(.horizontal, 100)
}
}
This workaround works in my case, but I don't recommend using it widely in your app especially when you have a lot of items in the grid.

Related

How to make the space between views grow when given a large frame, but be as small as possible otherwise?

This question is essentially about how to define layout behaviour for a SwiftUI View such that it grows/shrinks in a particular way when given different frames externally. IE imagine you are creating a View which will be packaged up in a library and given to somebody else, without you knowing how much space they will give to your view.
The layout I would like to create will contain two horizontal views, indicated by A & B in my diagrams. I would like to control how this view expands if you specify a frame like follows:
When no frame is specified, I'd like my container View to be as small as the inner views and no bigger. See diagram 1.
When the container View is given a frame that's larger than the inner views, I'd like the space between the inner views to grow. See diagram 2.
Diagram 1: How I'd like my View to look without a frame specified.
// MyView()
| [A B] |
Diagram 2: How I'd like my View to look with a large frame.
// MyView().frame(maxWidth: .infinity)
|[A B]|
Diagram Key:
| represents my Window
[] represents my container View
A and B are my child Views.
My naive attempts:
Unmodified HStack
The behaviour of an unmodified HStack matches Diagram 1 with an unspecified frame successfully, however when given a large frame it's default behaviour is to grow as follows:
// HStack{A B}.frame(maxWidth: .infinity)
|[ AB ]|
HStack with a Spacer between the views
If I use a Stack with but add a spacer in between the views, the spacer grows to take up the most space possible, regardless of what frame is given. IE I end up with a view that looks like Diagram 2 even when no frame is specified.
// HStack{A Spacer B}
|[A B]|
I've been trying to figure out a way to tell a Spacer to prefer to be as small as possible, but to no avail. What other options do we have to achieve this layout?
Edit: To help out, here's some code as a starting point:
struct ContentView: View {
#State var largeFrame: Bool = false
var body: some View {
VStack{
Toggle("Large Frame", isOn: $largeFrame)
HStack {
Text("A")
.border(Color.red, width: 1)
Text("B")
.border(Color.red, width: 1)
}
.padding()
.frame(maxWidth: largeFrame ? .infinity : nil)
.border(Color.blue, width: 1)
}
}
}
I'm a little confused to what you are saying. Are you asking how to generate space between A and B without forcing the HStack to be window width? If so, if you place a frame on the HStack, then the spacer shoulder only separate the contents to as far as the user desires?
struct ContentView: View {
var body: some View {
HStack() {
Text("A")
Spacer()
Text("B")
}
.frame(width: 100)
}
}
EDIT:
Does the following code work? The HStack(spacing: 0) ensures that the contents the HStack have no spacing between the items and so the "smallest" possible.
struct ContentView: View {
#State private var customSpacing = true
#State private var customFrame = CGFloat(100)
var body: some View {
VStack {
Button {
customSpacing.toggle()
} label: {
Text("Custom or Not")
}
if !customSpacing {
HStack(spacing: 0) {
Text("A")
Text("B")
}
} else {
HStack(spacing: 0) {
Text("A")
Spacer()
Text("B")
}
.frame(width: customFrame)
}
}
}
}
If MyView is your component and you have control over its content, then a possible approach is to "override" .frame modifiers (all of them, below is one for demo) and compare explicitly outer width provided by frame and inner width of content subviews.
Tested with Xcode 13.4 / iOS 15.5
Main parts:
struct MyView: View { // << your component
var outerWidth: CGFloat? // << injected width !!
#State private var myWidth = CGFloat.zero // << own calculated !!
// ...
"overridden" frame modifier to store externally provided parameter
#inlinable public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View {
var newview = self
newview.outerWidth = maxWidth // << inject frame width !!
return VStack { newview } // << container to avoid cycling !!
.frame(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment)
}
and conditionally activated space depending on width diffs
SubViewA()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
if let width = outerWidth, width > myWidth { // << here !!
Spacer()
}
SubViewB()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
Test module is here

SwiftUI ScrollView VStack GeometryReader height ignored

I want to use a ScrollView outside of a VStack, so that my content is scrollable if the VStack expands beyond screen size.
Now I want to use GeometryReader within the VStack and it causes problems, which I can only solve by setting the GeometryReader frame, which does not really help me given that I use the reader to define the view size.
Here is the code without a ScrollView and it works nicely:
struct MyExampleView: View {
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
GeometryReader { reader in
Text("Custom Sized Label")
.frame(width: reader.size.width, height: reader.size.width * 0.5)
.background(Color.green)
}
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
This results in the following image:
The custom sized label should be full width, but half the width for height.
Now if I wrap the same code in a ScrollView, this happens:
Not just did everything get smaller, but the height of the Custom Sized Label is somehow ignored.
If I set the height of the GeometryReader, I can adjust that behaviour, but I want to GeometryReader to grow as large as its content. How can I achieve this?
Thanks
It should be understood that GeometryReader is not a magic tool, it just reads available space in current context parent, but... ScrollView does not have own available space, it is zero, because it determines needed space from internal content... so using GeometryReader here you have got cycle - child asks parent for size, but parent expects size from child... SwiftUI renderer somehow resolves this (finding minimal known sizes), just to not crash.
Here is possible solution for your scenario - the appropriate instrument here is view preferences. Prepared & tested with Xcode 12 / iOS 14.
struct DemoLayout_Previews: PreviewProvider {
static var previews: some View {
Group {
MyExampleView()
ScrollView { MyExampleView() }
}
}
}
struct MyExampleView: View {
#State private var height = CGFloat.zero
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
Text("Custom Sized Label")
.frame(maxWidth: .infinity)
.background(GeometryReader {
// store half of current width (which is screen-wide)
// in preference
Color.clear
.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.width / 2.0)
})
.onPreferenceChange(ViewHeightKey.self) {
// read value from preference in state
self.height = $0
}
.frame(height: height) // apply from stored state
.background(Color.green)
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Note: ... and don't use GeometryReader if you are not sure about context in which your view is.

SwiftUI grid with column that fits content?

Is this layout possible with SwiftUI?
I want the first column to wrap the size of the labels, so in this case it will be just big enough to show "Bigger Label:". Then give the rest of the space to the second column.
This layout is pretty simple with auto layout.
SwiftUI 2020 has LazyVGrid but the only ways I see to set the column sizes use hardcoded numbers. Do they not understand what a problem that causes with multiple languages and user-adjustable font sizes?
It is not so complex if to compare number of code lines to make this programmatically in both worlds...
Anyway, sure it is possible. Here is a solution based on some help modifier using view preferences feature. No hard. No grid.
Demo prepared & tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var width = CGFloat.zero
var body: some View {
VStack {
HStack {
Text("Label1")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
HStack {
Text("Bigger Label")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
}
}
}
and helpers
extension View {
func alignedView(width: Binding<CGFloat>) -> some View {
self.modifier(AlignedWidthView(width: width))
}
}
struct AlignedWidthView: ViewModifier {
#Binding var width: CGFloat
func body(content: Content) -> some View {
content
.background(GeometryReader {
Color.clear
.preference(key: ViewWidthKey.self, value: $0.frame(in: .local).size.width)
})
.onPreferenceChange(ViewWidthKey.self) {
if $0 > self.width {
self.width = $0
}
}
.frame(minWidth: width, alignment: .trailing)
}
}

How can I split VStack to two sub view with double height of second sub view than first sub view?

I just started with SwiftUI, and seems VStack and HStack is very similar as flex box in web. On the web, it's easy to split two sub views as height weight with flex
<div id="parent" style="display: flex; flex-direction: column; height: 300px">
<div id="subA" style="flex: 1; background-color: red">Subview A</div>
<div id="subB" style="flex: 2; background-color: yellow">Subview B</div>
</div>
I wonder if it's possible on swiftUI too.
VStack {
VStack {
Text("Subview A")
} // Subview A with height 100
.background(Color.red)
VStack {
Text("Subview B")
} // Subview B with height 200
.background(Color.yellow)
}
.frame(height: 300, alignment: .center)
How can I implement that?
UPDATE #2:
Thanks to this answer and code from #kontiki, here's what easily works instead of using this deprecated method:
Declare this:
#State private var rect: CGRect = CGRect()
Then create this:
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> ShapeView<Rectangle, Color> in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return Rectangle().fill(Color.clear)
}
}
}
}
(For those familiar with UIKit, you are basically creating an invisible CALayer or UIView in the parent and passing it's frame to the subview - apologies for not being 100% technically accurate, but remember, this is not a UIKit stack in any way.)
Now that you have the parent frame, you can use it as a base for a percentage - or "relative" - of it. In this question there's a nested VStack inside another and you want the lower Text to be twice the vertical size of the top one. In the case of this answer, adjust your `ContentView to this:
struct ContentView : View {
#State private var rect: CGRect = CGRect()
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red)
.frame(height: rect.height * 0.25)
YellowView()
}
.background(GeometryGetter(rect: $rect))
}
}
UPDATE #1:
As of beta 4, this method is deprecated. relativeHeight, relativeWidth, relativeSizehave all been deprecated. Useframeinstead. If you want *relatve* sizing based on aView's parent, use GeometryReader` instead. (See this question.)
ORIGINAL POST:
Here's what you want. Keep in mind that without modifiers, everything is centered. Also, relativeHeight seems (at least to some) not very intuitive. The key is to remember that in a VSTack the parent is 50% of the screen, so 50% of 50% is actually 25%.
Alternatively, you can dictate frame heights (letting the width take up the whole screen). but your example suggests you want the red view to be 25% of the entire screen no matter what the actual screen size is.
struct RedView: View {
var body: some View {
ZStack {
Color.red
Text("Subview A")
}
}
}
struct YellowView: View {
var body: some View {
ZStack {
Color.yellow
Text("Subview B")
}
}
}
struct ContentView : View {
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red).relativeHeight(0.50)
YellowView()
}
}
}

Square Text using aspectRatio in SwiftUI

I'm trying to achieve a following layout using Swift UI…
struct ContentView : View {
var body: some View {
List(1...5) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.padding()
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
}.background(Color.yellow)
}
}
}
I'd like the Text("i") to be square, but setting the .aspectRatio(1, contentMode: .fill) doesn't seem to do anything…
I could set the frame width and height of the text so it's square, but it seems that setting the aspect ratio should achieve what I want in a more dynamic way.
What am I missing?
I think this is what you're looking for:
List(1..<6) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
}
.padding(6)
.background(Color.yellow)
}
The answer being said, i don't recommend giving SwiftUI too much freedom to decide the sizings. one of the biggest SwiftUI problems right now is the way it decides how to fit the views into each other. if something goes not-so-good on SwiftUI's side, it can result in too many calls to the UIKit's sizeToFit method which can slowdown the app, or even crash it.
but, if you tried this solution in a few different situations and it worked, you can assume that in your case, giving SwiftUI the choice of deciding the sizings is not problematic.
The issue is due to used different fonts for left/right sides, so paddings generate different resulting area.
Here is possible solution. The idea is to give right side rect based on default view size of left side text (this gives ability to track dynamic fonts sizes as well, automatically).
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State private var height = CGFloat.zero
var body: some View {
List(1...5, id: \.self) { index in
HStack(spacing: 8) {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(GeometryReader {
Color.blue.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
Text("i")
.italic()
.font(.title)
.frame(width: height, height: height)
.background(Color.pink)
}
.padding(8)
.background(Color.yellow)
.onPreferenceChange(ViewHeightKey.self) {
self.height = $0
}
}
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I managed to recreate the view in your first screenshot in SwiftUI. I wasn't sure on how much padding you wanted so I defined a private immutable variable for this value
The blue view is the one that will have the text content and could change in size so by using a GeometryReader you can get the size of the blue view and then use the height value from the size to set the width and height of the pink view. This means that whatever the height of the blue view is, the pink view will follow keeping an equal aspect ratio
The SizeGetter view below is used to get any views size using a GeometryReader and then binds that value back to a #State variable in the ContentView. Because the #State and #Binding property wrappers are being used, whenever the blueViewSize is updated SwiftUI will automatically refresh the view.
The SizeGetter view can be used for any view and is implemented using the .background() modifier as shown below
struct SizeGetter: View {
#Binding var size: CGSize;
var body: some View {
// Get the size of the view using a GeometryReader
GeometryReader { geometry in
Group { () -> AnyView in
// Get the size from the geometry
let size = geometry.frame(in: .global).size;
// If the size has changed, update the size on the main thread
// Checking if the size has changed stops an infinite layout loop
if (size != self.size) {
DispatchQueue.main.async {
self.size = size;
}
}
// Return an empty view
return AnyView(EmptyView());
}
}
}
}
struct ContentView: View {
private let padding: Length = 10;
#State private var blueViewSize: CGSize = .zero;
var body: some View {
List(1...5) { index in
// The yellow view
HStack(spacing: self.padding) {
// The blue view
HStack(spacing: 0) {
VStack(spacing: 0) {
Text("Item number \(index)")
.padding(self.padding);
}
Spacer();
}
.background(SizeGetter(size: self.$blueViewSize))
.background(Color.blue);
// The pink view
VStack(spacing: 0) {
Text("i")
.font(.title)
.italic();
}
.frame(
width: self.blueViewSize.height,
height: self.blueViewSize.height
)
.background(Color.pink);
}
.padding(self.padding)
.background(Color.yellow);
}
}
}
In my opinion it is better to set the background colour of a VStack or HStack instead of the Text view directly because you can then add more text and other views to the stack and not have to set the background colour for each one
I was searching very similar topic "Square Text in SwiftUI", came across your question and I think I've found quite simple approach to achieve your desired layout, using GeometryProxy to set width and heigh of the square view from offered geometry.size.
Checkout the code below, an example of TableCellView which can be used within List View context:
import SwiftUI
struct TableCellView: View {
var index: Int
var body: some View {
HStack {
HStack {
Text("Item number \(index)")
.padding([.top, .leading, .bottom])
Spacer()
}
.background(Color(.systemBlue))
.layoutPriority(1)
GeometryReader { geometry in
self.squareView(geometry: geometry)
}
.padding(.trailing)
}
.background(Color(.systemYellow))
.padding(.trailing)
}
func squareView(geometry: GeometryProxy) -> some View {
Text("i")
.frame(width: geometry.size.height, height: geometry.size.height)
.background(Color(.systemPink))
}
}