Grid layout that puts things in column order - swiftui

I have the code below which works great. It displays words in alphabetical order in a nice grid. And it works great on different devices, iphone, ipad. Eg, when rotating an ipad from portrait to landscape, I get more columns. It fills the screen no matter what device/orientation, and to see anything missing I scroll vertically. All good.
However, the one issue I'd like to solve is I'd like the items to be displayed in column order. Right now they are displayed in row order, first row1, then row2, etc, but I want to do it by column. First populate col1, then go to col2, etc.
I understand that LazyHGrid does populate in this order but I can't seem to get something that works (eg, I end up with all words in one row). Ideas?
struct ContentView: View {
func getWords() -> [String] {
var retval: [String] = []
let alpha = "abcdefghijklmnopqrstuvwxyz"
for _ in 0...500 {
let length = Int.random(in: 4...16)
retval.append( String(alpha.shuffled().prefix(length)).capitalized )
}
return retval.sorted()
}
func getColumns() -> [GridItem] {
return [GridItem(.adaptive(minimum: 150))]
}
var body: some View {
ScrollView() {
LazyVGrid(columns: getColumns(), alignment: .leading) {
ForEach(getWords(), id: \.self) { word in
Text(word)
}
}.padding()
}
}
}
EDIT: This is a version with the HGrid, but it just displays everything in one row. I don't want to have to specify the number of rows/columns, I really want things to work exactly like the VGrid version, except for the col vs row layout.
var body: some View {
ScrollView() {
LazyHGrid(rows: getColumns(), alignment: .top) {
ForEach(getWords(), id: \.self) { word in
Text(word)
}
}.padding()
}
}

The LazyHStack is the way to go, and while you do have to specify the number of rows, you don't have to hard code that number. I had to alter your MRE a bit as you do have to have the words initialized before you hit the LazyHGrid(), so your calling the function in the ForEach won't work. In a working app, you would have some variable already initialized to use, so this should not be a problem. So, an example of your view would be this:
struct ContentView: View {
#State var words: [String] = []
// The GridItem has to be .flexible, or odd numbers of words will add an extra column
let row = GridItem(.flexible(), alignment: .leading)
#State var numberOfColumns = 2.0
var body: some View {
VStack{
Stepper("Columns") {
numberOfColumns += 1
} onDecrement: {
if numberOfColumns > 2 {
numberOfColumns -= 1
}
}
ScrollView() {
// The parameter for rows becomes an array that you create on the fly,
// repeating the row for half the words rounded to give an extra line
// for an odd number of words.
LazyHGrid(rows: Array(repeating: row, count: Int((Double(words.count) / numberOfColumns).rounded()))) {
ForEach(words, id: \.self) { word in
Text(word)
}
}.padding()
}
}
.onAppear {
// Didn't want to deal with a static func, so just set the words here.
words = getWords()
}
}
func getWords() -> [String] {
var retval: [String] = []
let alpha = "abcdefghijklmnopqrstuvwxyz"
for _ in 0...500 {
let length = Int.random(in: 4...16)
retval.append( String(alpha.shuffled().prefix(length)).capitalized )
}
return retval.sorted()
}
}
Edit:
I believe this is what you are looking for. The following code will set the columns as above, and automatically compute the number of columns based off of the width of the word. It will also recompute the number of columns upon rotation, or changing of the list of words. I built in some ability to play with the view to see how it works. This was simply a math problem. The PreferenceKeys just give the numbers you need for the computations.
Of course, the PreferenceKeys use GeometryReaders to determine these sizes, but there is no other way to get this information. It is very likely that behind the scenes, LazyVGrid and LazyHGrid are also using GeometryReader.

Related

SwiftUI Nested ForEach causes Fatal error: each layout item may only occur once

I'm trying to create a LazyVGrid view to display the contents of objects in an array using nested ForEach statements. The code is causing an app crash with the message "Fatal error: each layout item may only occur once".
Each object contains an array of values that need to be displayed as a row in the grid. The row consists of cell with the object's sku string followed by a number of cells with the integers from the object's array.
I chose to use a class instead of a struct because I need to update the array within the class.
This is the class for the objects in the array.
class RoomPickupData: Identifiable {
let id = UUID()
var sku: String = ""
// Array of counts for sku on a particular date
var roomsForDate: [Date: Int] = [:]
}
In the code the first ForEach is used to put header information in the first line of the grid.
The next ForEach and the ForEach nested in it are causing the error.
The outer ForEach iterates through the array of objects so I can create a row in the grid for each object. Each row consists of a string followed values from the roomsForDate array. The size of the roomsForDate array is not fixed.
If I comment out the nested ForEach the code executes but with it I get the fatal error.
If I comment out Text(roomData.value.description) so there is noting in the inner ForEach, the code runs fine too. If I replace Text(roomData.value.description) with Text("") the app crashes.
ForEach nesting seems like the best way to accomplish producing a grid view from a two dimensional array or array of objects each containing an array.
I've found other posts showing that nested ForEach statements can be used in SwiftUI.
Here is the code. Your help with this issue is appreciated.
struct RoomTableView: View {
#ObservedObject var checkfrontVM: CheckFrontVM
let dateFormatter = DateFormatter()
init(checkfrontVM: CheckFrontVM) {
self.checkfrontVM = checkfrontVM
dateFormatter.dateFormat = "MM/dd/yyyy"
}
func initColumns() -> [GridItem] {
var columns: [GridItem]
var columnCount = 0
if self.checkfrontVM.roomPickupDataArray.first != nil {
columnCount = self.checkfrontVM.roomPickupDataArray.first!.roomsForDate.count
} else {
return []
}
columns = [GridItem(.flexible(minimum: 100))] + Array(repeating: .init(.flexible(minimum: 100)), count: columnCount)
return columns
}
var body: some View {
if self.checkfrontVM.roomPickupDataArray.first != nil {
ScrollView([.horizontal, .vertical]) {
LazyVGrid(columns: initColumns()) {
Text("Blank")
ForEach(self.checkfrontVM.roomPickupDataArray.first!.roomsForDate.sorted(by: {
$0 < $1
}), id: \.key) { roomData in
Text(dateFormatter.string(from: roomData.key))
}
ForEach(self.checkfrontVM.roomPickupDataArray) { roomPickupData in
Group {
Text(roomPickupData.sku)
.frame(width: 80, alignment: .leading)
.background(roomTypeColor[roomPickupData.sku])
.border(/*#START_MENU_TOKEN#*/Color.black/*#END_MENU_TOKEN#*/)
ForEach(roomPickupData.roomsForDate.sorted(by: { $0.key < $1.key}), id: \.key) { roomData in
Text(roomData.value.description)
}
}
}
}
.frame(height: 600, alignment: .topLeading)
}
.padding(.all, 10)
}
}
}
Every item in ForEach needs a unique ID. The code can be modified by adding the following line to the Text view in the inner ForEach to assign a unique ID:
.id("body\(roomPickupData.id)-\(roomData.key)")
ForEach(self.checkfrontVM.roomPickupDataArray) { roomPickupData in
Group {
Text(roomPickupData.sku)
.frame(width: 80, alignment: .leading)
.background(roomTypeColor[roomPickupData.sku])
.border(Color.black)
ForEach(roomPickupData.roomsForDate.sorted(by: { $0.key < $1.key}), id: \.key) { roomData in
Text(roomData.value.description)
.id("body\(roomPickupData.id)-\(roomData.key)") //<-
}
}
}
I was facing the same error for ForEach inside ForEach inside a LazyVStack I have solved it by adding the UUID() identifier to the innermost View like
.id(UUID)
Here is an example:
LazyVStack {
ForEach(vmHome.allCategories, id: \.self) { category in
VStack {
HStack {
Text(category)
.font(.title3)
.bold()
.padding(10)
Spacer()
}
ScrollView(.horizontal) {
LazyHStack{
ForEach(vmHome.getMovies(forCat: category), id: \.self.id) { movie in
StandardHomeMovieView(movie: movie)
.id(UUID())
}
}
}
}
}
}

How to create a responsive grid layout in SwiftUI?

It's common practice to have some data that has multiple properties in an array to be displayed in a device of a viewport, that is either compact or wide (iPhone, iPad, portrait/landscape, etc). For example, we might want to show a 1 column of 2 items in "portrait compact view", or a 1 column and 4 items in a "portrait wide view".
Code wise, we have the UserInterfaceSizeClass, that can be used as follows:
#Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
#Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?
...
HStack {
if horizontalSizeClass == .compact {
...
} else {
...
}
}
Or, something like calculating the ratio:
GeometryReader { proxy in
if proxy.size.width > 324.0/2.0 {
WideView()
} else {
NarrowView()
}
}
And a grid can be understood as a 2D array, that we can iterate over the desired number of columns and nested in the loop, loop through the desired number of rows.
VStack {
ForEach(0 ..< self.rows, id: \.self) { row in
HStack {
ForEach(0 ..< self.columns, id: \.self) { column in
...
}
}
}
}
I hope that this far makes sense and comprehended as some basic principles used independently of the tech stack.
So, given a list of 1-dimensional collection of data (please assume a big number of planets):
class Planet {
var name: String
var size: Double
init(name:String, size:Double) {
self.name = name
self.size = size
}
}
var planets = [Planet]()
let planet = Planet(name: "Mars", size: 30.5)
planets.append(planet)
The data needs to be allocated to the response grid view.
So, what's the best approach to create a responsive grid layout in SwiftUI, considering the data and the different viewports and device portrait/landscape modes exposed above?
Let know if there are good practices to approach this!

SwiftUI: Dismissed view causes Index out of Range error

This one has been tearing my hair out, I'm hoping someone can show me where I'm going wrong.
The goal is I need to be able to manage a dynamic list of items (i.e. the list could grow or shrink), where each item's properties can be adjusted (edited), and the items are related to a given parent.
In the abridged code below I've called the item Cell, which belongs to a Row. A Row can have many Cells, and each Cell has an amount which can be changed by the user. This is an abridged version of my code, which I hope makes the relationship labelling a little clearer but, crucially, it gives me the same error which is:
If the number of Cells is less than the original amount (i.e. 3 or less in this example) then the application crashes with an 'Index out of Range' error whenever the view is dismissed. Up to that point cells can be added or removed without any issue whatsoever and I don't get this error when changing the amount of cell items. I've looked all over SO and various blogs and can't find anyone who has encountered this particular issue - it seems most of the Index out of Range posts occur when actually modifying the list, my error only happens once the list is shrunk and then dismissed.
I've attached some sample code below - you should be able to cut/paste and try it out.
PS. I know calculateTotals() is sketch; please don't add letters or punctuation to your totals - it's just to test the bindings are bubbling correctly :)
Cell
struct Cell: Identifiable {
var amount: String
var id = UUID()
init(_ amount: String = "0.00"){
self.amount = amount
}
}
Row
class Row: ObservableObject {
#Published var cells: [Cell]
init(){
self.cells = [
Cell("10"),
Cell("15"),
Cell("20"),
Cell("25")]
}
}
ContentView
struct ContentView: View {
#State private var displayAmounts = false
var body: some View {
NavigationView {
Button(action: {
self.displayAmounts.toggle()
}) {
Text("View amounts")
}
}
.sheet(isPresented: self.$displayAmounts) {
CellSheet()
}
}
}
CellSheet
struct CellSheet: View {
#ObservedObject var row: Row = Row()
private func deleteCell(at offsets: IndexSet) {
self.row.cells.remove(atOffsets: offsets)
}
private func calculateTotals() -> String {
var total = Double("0.00")!
for cell in self.row.cells {
if( "" != cell.amount ) {
total += Double(cell.amount)!
}
}
return String("\(total)")
}
var body: some View {
VStack{
List {
ForEach(row.cells.indices, id: \.self){ i in
CellItem(amount: self.$row.cells[i].amount)
}.onDelete(perform: deleteCell)
}
Button(action: {
self.row.cells.append(Cell())
}) {
Text("Add new cell")
}
Text(calculateTotals())
}
}
}
CellItem - some oddness here: if I wrap the TextField with an HStack then the app crashes immediately if remove a cell -- even if the total number of cells is greater than the original amount (4).
struct CellItem: View {
#Binding var amount: String
var body: some View {
// Uncomment HStack and deleting rows immediately causes index out of range.
// HStack {
TextField("Amount: ", text: $amount)
// }
}
}
I'm really at a loss as to why/how this is happening. Clearly Swift is trying to access an index that doesn't exist (if I remove the binding and just output the value there's no problem), but I don't understand why that would cause issues when the view is dismissed. My guess is that Swift is caching some things in memory? The HStack wrapping issue is also peculiar.
Anyway, I'm relatively new to Swift so it's possible I'm overlooking something obvious. For additional context, I'm running XCode 11.4.1 and targeting iOS 13.4.
You should be able to lift all of this code straight into a new project and it will compile. Any help will be gratefully received :)
Ok, literally minutes after creating this post (and I've been on this for a couple of days), I think I have a solution. I've modified my List() in ContentView as follows:
List {
ForEach(Array(row.cells.enumerated()), id:\.1.id) { (i, cell) in
CellItem(amount: self.$row.cells[i].amount)
}.onDelete(perform: deleteCell)
}
This was based on this answer as I think (?) enumerated() is better suited when the array is of a variable length. The HStack crashing is also resolved with the above implementation. Maybe someone can add some more context to this.

How can I select a SwiftUI Text view, or string, based on available space?

For example, if my UI needed to display a length Measurement in human readable form, it might want to choose from one of the following formats to display one inch:
1"
1 in
1 inch
one inch
So far I have tried:
truncationMode(_:): only accepts positional argument, no option for custom truncation
GeometryReader: tells me what space is available (super useful!) but I don't see how to dynamically select a dynamically sized sub-view, seems to be optimized for generating fixed sized sub-views or overflowing the position
When I try to find another app that might have solved this problem it seems that they all rearrange the layout on orientation or other size change. I want to continue to have a single HStack of Text views that fit the space, keeping all the important information from being truncated when possible.
Let's define this View:
struct FlexibleTextView: View {
let possibleTexts: [String]
var body: some View {
GeometryReader { geometry in
Text(self.possibleTexts.last(where: { $0.size(withAttributes: [.font: UIFont.systemFont(ofSize: 17.0)]).width < geometry.size.width }) ?? self.possibleTexts[0])
.lineLimit(1)
}
}
init(_ possibleTexts: [String]) {
self.possibleTexts = possibleTexts.sorted {
$0.size(withAttributes: [.font: UIFont.systemFont(ofSize: 17.0)]).width < $1.size(withAttributes: [.font: UIFont.systemFont(ofSize: 17.0)]).width
}
}
}
When you init it, the possible texts are automatically sorted by their actual width. It takes the last one (so the one width the greatest width) where the width is smaller than the width of the container, which we get from GeometryReader. If even the first, so the smallest text is to big, (so .last(where: { ... }) will return nil), we still use that first text, but you could also change this yourself to whatever you would like.
Here's an interactive example:
struct ContentView: View {
#State var width: CGFloat = 80
var body: some View {
VStack {
FlexibleTextView(["1\"", "1 in", "1 inch", "one inch"])
.frame(width: width, height: 17)
.border(Color.red)
Slider(value: $width, in: 10 ... 80)
}
.padding()
}
}
With the slider, you can adjust the width to see the effect.

View is not rerendered in Nested ForEach loop

I have the following component that renders a grid of semi transparent characters:
var body: some View {
VStack{
Text("\(self.settings.numRows) x \(self.settings.numColumns)")
ForEach(0..<self.settings.numRows){ i in
Spacer()
HStack{
ForEach(0..<self.settings.numColumns){ j in
Spacer()
// why do I get an error when I try to multiply i * j
self.getSymbol(index:j)
Spacer()
}
}
Spacer()
}
}
}
settings is an EnvironmentObject
Whenever settings is updated the Text in the outermost VStack is correctly updated. However, the rest of the view is not updated (Grid has same dimenstions as before). Why is this?
Second question:
Why is it not possible to access the i in the inner ForEach-loop and pass it as a argument to the function?
I get an error at the outer ForEach-loop:
Generic parameter 'Data' could not be inferred
TL;DR
Your ForEach needs id: \.self added after your range.
Explanation
ForEach has several initializers. You are using
init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
where data must be a constant.
If your range may change (e.g. you are adding or removing items from an array, which will change the upper bound), then you need to use
init(_ data: Data, id: KeyPath<Data.Element, ID>, content: #escaping (Data.Element) -> Content)
You supply a keypath to the id parameter, which uniquely identifies each element that ForEach loops over. In the case of a Range<Int>, the element you are looping over is an Int specifying the array index, which is unique. Therefore you can simply use the \.self keypath to have the ForEach identify each index element by its own value.
Here is what it looks like in practice:
struct ContentView: View {
#State var array = [1, 2, 3]
var body: some View {
VStack {
Button("Add") {
self.array.append(self.array.last! + 1)
}
// this is the key part v--------v
ForEach(0..<array.count, id: \.self) { index in
Text("\(index): \(self.array[index])")
//Note: If you want more than one views here, you need a VStack or some container, or will throw errors
}
}
}
}
If you run that, you'll see that as you press the button to add items to the array, they will appear in the VStack automatically. If you remove "id: \.self", you'll see your original error:
`ForEach(_:content:)` should only be used for *constant* data.
Instead conform data to `Identifiable` or use `ForEach(_:id:content:)`
and provide an explicit `id`!"
ForEach should only be used for constant data. So it is only evaluated once by definition. Try wrapping it in a List and you will see errors being logged like:
ForEach, Int, TupleView<(Spacer, HStack, Int, TupleView<(Spacer, Text, Spacer)>>>, Spacer)>> count (7) != its initial count (0). ForEach(_:content:) should only be used for constant data. Instead conform data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!
I was surprised by this as well, and unable to find any official documentation about this limitation.
As for why it is not possible for you to access the i in the inner ForEach-loop, I think you probably have a misleading compiler error on your hands, related to something else in the code that is missing in your snippets. It did compile for me after completing the missing parts with a best guess (Xcode 11.1, Mac OS 10.14.4).
Here is what I came up with to make your ForEach go over something Identifiable:
struct SettingsElement: Identifiable {
var id: Int { value }
let value: Int
init(_ i: Int) { value = i }
}
class Settings: ObservableObject {
#Published var rows = [SettingsElement(0),SettingsElement(1),SettingsElement(2)]
#Published var columns = [SettingsElement(0),SettingsElement(1),SettingsElement(2)]
}
struct ContentView: View {
#EnvironmentObject var settings: Settings
func getSymbol(index: Int) -> Text { Text("\(index)") }
var body: some View {
VStack{
Text("\(self.settings.rows.count) x \(self.settings.columns.count)")
ForEach(self.settings.rows) { i in
VStack {
HStack {
ForEach(self.settings.columns) { j in
Text("\(i.value) \(j.value)")
}
}
}
}
}
}
}