SwiftUI: Expand LazyVGrid cells to max height of each row - ios

I'm using a LazyVGrid in a ScrollView to display cells in either a 1 (portrait) or 2 (landscape) column layout. However, the height of shorter cells in a row does not expand to match the taller cell in the same row and looks pretty terrible.
How can I ensure the height is always the same for every cell in a row? Obviously I don't want a fixed height for every cell. (To be clear, I want "Church - Eastbound" to be as tall as "Church & Market" and "West Portal" to be as tall as "Forest Hill".
ScrollView(.vertical) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 400))],
alignment: .leading,
spacing: 16
) {
ForEach(sharedFavorites.favoriteStops.indices, id: \.self) { index in
let favorite = sharedFavorites.favoriteStops[index]
NavigationLink(
destination: SingleStationView(
station: favorite.station,
direction: favorite.direction
)
) {
BoardRow(favorite: favorite, stop: favorite.observableStop)
.padding()
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(10)
.frame(maxHeight: .infinity)
}
}
}
}
Screenshot:
I tried .frame(maxHeight: .infinity) on both the BoardRow view and the inner contents of BoardView (which is just a normal VStack). It didn't work.

You were really close — just needed to put the .frame(maxHeight:) before .background.
struct ContentView: View {
var body: some View {
ScrollView(.vertical) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 160))],
alignment: .leading,
spacing: 16
) {
ForEach([0, 1, 2, 5, 6, 3], id: \.self) { index in
BoardCell(index: index)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(.secondarySystemGroupedBackground)) /// `background` goes after the frame!
.cornerRadius(10)
}
}
.padding(20)
}
.background(Color(.secondaryLabel))
}
}
/// I recreated your view for testing purposes
struct BoardCell: View {
var index: Int
var body: some View {
VStack(alignment: .leading) {
Text("Powell (Westbound)")
ForEach(0 ..< index) { _ in
HStack {
Circle()
.fill(.green)
.frame(width: 30, height: 30)
Text("4")
Text("19")
Text("17")
}
}
}
}
}
Result:

Related

SwiftUI - reduce extra space within the list

I wanted some extra space on the top of the list so I tried using Spacer within the list and added modifiers to it. However I am not seeing the height getting reduced further. Below is the code for my view.
CustomView:
import SwiftUI
struct CustomView: View {
var body: some View {
VStack {
List {
Spacer()
.frame(minHeight: 1, idealHeight: 1, maxHeight: 2)
.fixedSize().listRowBackground(Color.clear)
UserLoginDetailsRowView().padding(.init(top: 0, leading: 5, bottom: 5, trailing: 5))
ForEach(1..<2) { _ in
VStack(alignment: .leading) {
Text("App version").fixedSize(horizontal: false, vertical: true).font(.headline).foregroundColor(.white)
Text("1.1.0").fixedSize(horizontal: false, vertical: true).font(.subheadline).foregroundColor(.white)
Spacer()
}.padding(.bottom, 15)
}.listRowBackground(Color.clear)
}
}.navigationBarTitle("Main Menu")
}
}
UserLoginDetailsRowView code:
import SwiftUI
struct UserLoginDetailsRowView: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .center) {
Spacer()
Spacer()
Text("User's full name").lineLimit(2).font(.headline)
Text("Username").lineLimit(2).font(.subheadline)
Spacer()
}
ZStack {
Image("user-gray")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30 , height: 30)
.offset(x: geometry.size.width / 2.8, y: -geometry.size.height/4)
}
}.frame(minHeight: 60.0)
}
}
This is how it looks with this code:
Regardless of the changes I make to minHeight, idealHeight and maxHeight in Spacer() within CustomView the result remains the same. However I want half of the space of what it's currently showing. I even tried replacing Spacer() with VStack and setting a frame height modifier to it, but at minimum, I do always see this much of space. I want the space reduced to half.
If I remove the Spacer() from CustomView then the image on my custom row gets chopped off and looks something like this. How do I reduce the space to half of what it is now?
Adding playground source code:
import SwiftUI
import PlaygroundSupport
struct CustomView: View {
var body: some View {
VStack {
Spacer().frame(minHeight: 25, idealHeight: 25, maxHeight: 30).fixedSize().listRowBackground(Color.clear)
List {
// Extra space for the top half of user icon within UserLoginDetailsRowView.
// Spacer().frame(minHeight: 25, idealHeight: 25, maxHeight: 30).fixedSize().listRowBackground(Color.clear)
UserLoginDetailsRowView().padding(.init(top: 0, leading: 5, bottom: 5, trailing: 5))
ForEach(1..<2) { _ in
VStack(alignment: .leading) {
Text("App Version").fixedSize(horizontal: false, vertical: true).font(.headline).foregroundColor(.white)
Text("1.1.0").fixedSize(horizontal: false, vertical: true).font(.subheadline).foregroundColor(.white)
Spacer()
}.padding(.bottom, 15)
}.listRowBackground(Color.clear)
}
}.navigationBarTitle("Back")
}
}
struct UserLoginDetailsRowView: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .center) {
Spacer()
Spacer()
Text("User's full name").lineLimit(2).font(.headline)
Text("Username").lineLimit(2).font(.subheadline)
Spacer()
}
ZStack {
Image(systemName: "person.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 22 , height: 22)
.offset(x: geometry.size.width / 2.8, y: -geometry.size.height/4)
}
}.frame(minHeight: 60.0)
}
}
PlaygroundPage.current.setLiveView(CustomView())
Solution
The primary gap comes from the list style itself. if you apply .listStyle(PlainListStyle()) to the List it will reduce it to what you are looking for.
List { ... }.listStyle(PlainListStyle())
If you want to further reduce it and control it to the last pixel apply a .onAppear modifier to the list and set the content inset to your desired value.
List { .... }.onAppear(perform: {
UITableView.appearance().contentInset.top = -60
})
In the above code the value 60 is arbitrary in nature and you need to play around to get a value that fits your UI.
Explanation
The List default style adds a larger header which creates the spacing you were having issues with, this behaviour is similar to GroupedListStyle. From the documentation
On iOS, the grouped list style displays a larger header and footer than the plain style, which visually distances the members of different sections.
You can play around with other List Styles from the documentation to fit your needs better.
Full Playground Code - For .onAppear solution
import SwiftUI
import PlaygroundSupport
struct CustomView: View {
var body: some View {
VStack(alignment:.leading, spacing:0) {
List {
// Extra space for the top half of user icon within UserLoginDetailsRowView.
Spacer().frame(minHeight: 1, idealHeight: 1, maxHeight: 2)
.fixedSize().listRowBackground(Color.clear)
UserLoginDetailsRowView().padding(.init(top: 0, leading: 5, bottom: 5, trailing: 5))
ForEach(1..<2) { _ in
VStack(alignment: .leading) {
Text("App Version").fixedSize(horizontal: false, vertical: true).font(.headline).foregroundColor(.white)
Text("1.1.0").fixedSize(horizontal: false, vertical: true).font(.subheadline).foregroundColor(.white)
Spacer()
}.padding(.bottom, 15)
}.listRowBackground(Color.clear)
}.onAppear(perform: {
UITableView.appearance().contentInset.top = -60
})
}.navigationBarTitle("Back")
}
}
struct UserLoginDetailsRowView: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .center) {
Spacer()
Spacer()
Text("User's full name").lineLimit(2).font(.headline)
Text("Username").lineLimit(2).font(.subheadline)
Spacer()
}
ZStack {
Image(systemName: "person.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 22 , height: 22)
.offset(x: geometry.size.width / 2.8, y: -geometry.size.height/4)
}
}.frame(minHeight: 60.0)
}
}
PlaygroundPage.current.setLiveView(CustomView())

SwiftUI TextEditor Divider doesn't change Y position based on text-line count?

I am trying to make a SwiftUI TextEditor with a Divider that adapts its position to stay under the bottom-most line of text inside of a edit-bio section of the app.
Note: I have a frame on my TextEditor so that it doesn't take up the whole-screen
Right now the Divider is static and stays in one place. Is there a built-in way to make the divider stay under the bottom most line of text?
I would think the Spacer would have given me this behavior?
Thank you!
struct EditBio: View {
#ObservedObject var editProfileVM: EditProfileViewModel
var body: some View {
VStack(spacing: 10) {
TextEditor(text: $editProfileVM.bio)
.foregroundColor(.white)
.padding(.top, 70)
.padding([.leading, .trailing], 50)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
Divider().frame(height: 1).background(.white)
Spacer()
}
}
}
It is doing exactly what you told it to do. But a background color on your TextEditor. You will see that it has a height of 200 + a spacing of 10 from the VStack.
I changed your code to make it obvious:
struct EditBio: View {
#State var editProfileVM = ""
var body: some View {
VStack(spacing: 10) {
TextEditor(text: $editProfileVM)
.foregroundColor(.white)
.padding(.top, 70)
.padding([.leading, .trailing], 50)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
.background(Color.gray)
Divider().frame(height: 1).background(.red)
Spacer()
}
}
}
to produce this:
You can see the TextEditor naturally wants to be taller than 200, but that is limiting it. Therefore, the Spacer() is not going to cause the TextEditor to be any smaller.
The other problem that setting a fixed frame causes will be that your text will end up off screen at some point. I am presuming what you really want is a self sizing TextEditor that is no larger than it's contents.
That can be simply done with the following code:
struct EditBio: View {
#State var editProfileVM = ""
var body: some View {
VStack(spacing: 10) {
SelfSizingTextEditor(text: $editProfileVM)
// Frame removed for the image below.
// .frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
.foregroundColor(.white)
// made the .top padding to be .vertical
.padding(.vertical, 70)
.padding([.leading, .trailing], 50)
.background(Color.gray)
Divider().frame(height: 5).background(.red)
Spacer()
}
}
}
struct SelfSizingTextEditor: View {
#Binding var text: String
#State var textEditorSize = CGSize.zero
var body: some View {
ZStack {
Text(text)
.foregroundColor(.clear)
.copySize(to: $textEditorSize)
TextEditor(text: $text)
.frame(height: textEditorSize.height)
}
}
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
func copySize(to binding: Binding<CGSize>) -> some View {
self.readSize { size in
binding.wrappedValue = size
}
}
}
producing this view:

Custom collapsible view

I'm trying to create a custom collapsible view. The code works but in Collapsible<Content: View> the VStack has strange behavior: the elements overlap when the component is closed.
To note this, try disable clipped() as shown in the image.
Is it a bug or something so stupid that I am not noticing?
Thanks in advance
FIXED CODE:
struct Collapsible<Content: View>: View {
var label: String
var content: () -> Content
init(label: String, #ViewBuilder _ content: #escaping () -> Content) {
self.label = label
self.content = content
}
#State private var collapsed: Bool = true
var body: some View {
VStack(spacing: 0) {
Button(action: {
withAnimation(.easeInOut) {
self.collapsed.toggle()
}
}, label: {
HStack {
Text(label)
Spacer(minLength: 0)
Image(systemName: self.collapsed ? "chevron.down" : "chevron.up")
}
.padding()
.background(Color.white.opacity(0.1))
}
)
.buttonStyle(PlainButtonStyle())
self.content()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: collapsed ? 0 : .none, alignment: .top) // <- added `alignment` here
.clipped() // Comment to see the overlap
.animation(.easeOut)
.transition(.slide)
}
}
}
struct CollapsibleDemoView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Collapsible(label: "Collapsible") {
Text("Content")
.padding()
.background(Color.red)
}
Spacer(minLength: 0)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
The .frame modifier has a parameter alignment which defaults to center which results in the behaviour you're seeing: the layout bounds are set to zero height but the content is rendered vertically centered beyond the bounds (if not clipped). You can fix this by adding the alignment:
.frame(maxHeight: 0, alignment: .top)

Positioning Views with GeometryReader in SwiftUI

I have a set of child views (GridView1 and GridView2) within a scrollview. The child views are within a VStack since I need them one below the other. I have a GeometryReader wrapping the child views and each of the child views have a GeometryReader inside them. I need the GeometryReader inside the child view to determine the width of the column spacing I need in a LazyVGrid.
The problem is that, using a GeometryReader inside the Child views causes the two child views stack on top of each other. I have tried to set the frame size of the child view but that limits the vertical scrolling and is not giving me the right result.
I would appreciate any help in resolving this problem.
ContentView:
struct ContentView: View {
var body: some View {
GeometryReader { geo in
ScrollView {
VStack(spacing: 20) {
GridView1()
GridView2()
}//.frame(minHeight: 0, maxHeight: .infinity)
}
}
.padding()
}
}
GridView1:
struct GridView1: View {
var body: some View {
GeometryReader { g in
let maxwidth = g.size.width/6 > 100 ? g.size.width/6 : 100
let columns = Array(repeating: GridItem(.flexible(minimum: 100, maximum: maxwidth), spacing: 0), count: 6)
ScrollView(.horizontal) {
LazyVGrid(columns: columns, alignment: .leading, spacing: 10, pinnedViews: [.sectionHeaders]) {
Section(header: Text("Grid 1").font(.title)) {
ForEach(0...200, id:\.self) { index in
Text("\(index)").frame(maxWidth: .infinity)
}
}
}
}
.background(Color.blue)
}
}
}
GridView2 (GridView1 and GridView2 are essentially the same thing)
struct GridView2: View {
var body: some View {
GeometryReader { g in
let maxwidth = g.size.width/10 > 100 ? g.size.width/10 : 100
let columns = Array(repeating: GridItem(.flexible(minimum: 100, maximum: maxwidth), spacing: 0), count: 10)
ScrollView(.horizontal) {
LazyVGrid(columns: columns, alignment: .leading, spacing: 20, pinnedViews: [.sectionHeaders]) {
Section(header: Text("Grid 2").font(.title)) {
ForEach(1000...1200, id:\.self) { index in
ZStack {
Text("\(index)")
}
.frame(maxWidth: .infinity)
.background(Color.green)
}
}//.frame(minHeight: 0, maxHeight: .infinity)
}
}//.frame(minHeight: 1000, maxHeight: .infinity)
//.frame(maxWidth: .infinity, maxHeight: .infinity)
//.background(Color.red)
}
}
}
here is what I am expecting:
Here is what I am currently getting. As you can see below, the child views are bunching up at the top.
Instead of use internal GeometryReader that confuses external ScrollView, pass width from top GeometryReader into sub-views grids.
Here is worked solution. Tested with Xcode 12.1 / iOS 14.1
struct ContentView: View {
var body: some View {
GeometryReader { geo in
ScrollView {
VStack(spacing: 20) {
GridView1(width: geo.size.width)
GridView2(width: geo.size.width)
}
}
}
.padding()
}
}
struct GridView1: View {
let width: CGFloat
var body: some View {
let maxwidth = width/6 > 100 ? width/6 : 100
let columns = Array(repeating: GridItem(.flexible(minimum: 100, maximum: maxwidth), spacing: 0), count: 6)
ScrollView(.horizontal) {
LazyVGrid(columns: columns, alignment: .leading, spacing: 10, pinnedViews: [.sectionHeaders]) {
Section(header: Text("Grid 1").font(.title)) {
ForEach(0...200, id:\.self) { index in
Text("\(index)").frame(maxWidth: .infinity)
}
}
}
}
.background(Color.blue)
}
}
struct GridView2: View {
let width: CGFloat
var body: some View {
let maxwidth = width/10 > 100 ? width/10 : 100
let columns = Array(repeating: GridItem(.flexible(minimum: 100, maximum: maxwidth), spacing: 0), count: 10)
ScrollView(.horizontal) {
LazyVGrid(columns: columns, alignment: .leading, spacing: 20, pinnedViews: [.sectionHeaders]) {
Section(header: Text("Grid 2").font(.title)) {
ForEach(1000...1200, id:\.self) { index in
ZStack {
Text("\(index)")
}
.frame(maxWidth: .infinity)
.background(Color.green)
}
}
}
}
}
}

Automatically adjustable view height based on text height in SwiftUI

I am trying to create a view in SwiftUI where the background of the image on the left should scale vertically based on the height of the text on the right.
I tried a lot of different approaches, from GeometryReader to .layoutPriority(), but I haven't managed to get any of them to work.
Current state:
Desired state:
I know that I could imitate the functionality by hardcoding the .frame(100) for the example I posted, but as text on the right is dynamic, that wouldn't work.
This is full code for the view in the screenshot:
import SwiftUI
struct DynamicallyScalingView: View {
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.font(.system(size: 32))
.padding(20)
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
}
.padding(.horizontal)
}
}
struct DailyFactView_Previews: PreviewProvider {
static var previews: some View {
DynamicallyScalingView()
}
}
Here is a solution based on view preference key. Tested with Xcode 11.4 / iOS 13.4
struct DynamicallyScalingView: View {
#State private var labelHeight = CGFloat.zero // << here !!
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.font(.system(size: 32))
.padding(20)
.frame(minHeight: labelHeight) // << here !!
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
.background(GeometryReader { // << set right side height
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
.onPreferenceChange(ViewHeightKey.self) { // << read right side height
self.labelHeight = $0 // << here !!
}
.padding(.horizontal)
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
This is the answer without workaround.
struct DynamicallyScalingView: View {
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.frame(maxHeight: .infinity) // Add this
.font(.system(size: 32))
.padding(20)
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
.frame(maxHeight: .infinity) // Add this
}
.padding(.horizontal)
.fixedSize(horizontal: false, vertical: true) // Add this
}
}

Resources