How to create a responsive grid layout in SwiftUI? - 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!

Related

Grid layout that puts things in column order

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.

Creating grid-like view where the items position in the grid are fixed in SwiftUI

I am trying to recreate the BlockPuzzle app with SwiftUI. I am having trouble trying create the game pieces square's to have a fixed position with VStacks and HStacks. I want to show a view conditionally but if the view isn't shown then the positions of each view are changed due to swiftUIs adaptive positioning. Does anyone know I can achieve this?
I want to create this:
how I want the game pieces to look
This is what I saying how the square's move:
my version of the pieces
If you look at my version the middle piece at the bottom section has one block is moved to the middle by swiftUI but I want it to stay aligned with the first column.
Here is the code:
var body: some View {
VStack {
ForEach(model, id: \.self) { row in
HStack {
ForEach(row, id: \.self) { block in
if block == false {
EmptyView()
} else {
BlockView()
}
}
}
}
}
}
model is a 2D array of arrays of booleans. I'm guessing the condition of false is where I am trying to fix.

Why doesn't my swift ui view update after I change a State var?

I'm new to Swift development, and I'm trying to make a View, where you can click an item and it gets bigger, while the old big item gets smaller. I'm using an #State var called chosen to know which Element should be big at the moment. The items itself are Views with a Button on top. The idea is, that I click the button and the button will change the chosen variable, which is working. But it seems that my view doesn't redraw itself and everything stays as is. The simplified pseudocode looks like this:
struct MyView: View {
#State var chosen = 0
var body: some View {
VStack(){
ForEach(0 ..< 4) { number in
if self.chosen == number {
DifferentView()
.frame(big)
.clipShape(big)
}else{
ZStack{
DifferentView()
.frame(small)
.clipShape(small)
Button(action: {self.chosen = number}){Rectangle()}
}
}
}
}
}
You're using this overload of ForEach.init(_:content:), which accepts a constant range. While your range doesn't change, it also appears to be that this ForEach variant doesn't update the content (it was surprising to me).
You need to use the following overload: ForEach.init(_:id:content:) - supplying id with a keypath:
ForEach(0 ..< 4, id: \.self) { number in
// ...
}
But because there is a conditional, it trips up SwiftUI (hard to know why). The way to avoid it is to wrap it in something, like a Group or a ZStack, or even a function that generates the inner view:
ForEach(0 ..< 4, id: \.self) { number in
Group {
self.chosen == number {
// ...
} else {
// ...
}
}
}
Or, like so:
ForEach(0 ..< 4, id: \.self) { number in
self.inner(for: number)
}
#ViewBuilder
func inner(for number: Int) -> some View {
self.chosen == number {
// ...
} else {
// ...
}
}

SwiftUI LazyGrid dynamic column width

How can I make the Grid item width dynamic so that it takes the width of the text?
Using the code below the text is truncated, I would like all the text to be displayed without the truncation, taking into account the variable text lengths.
struct ContentView: View {
let data = ["O Menino","The Boy", "The Girl", "A Menina","Mae","Mother"]
let layout = [
GridItem(.adaptive(minimum:50))
]
var body: some View {
ScrollView{
LazyVGrid(columns: layout, spacing: 20){
ForEach(data, id: \.self){ item in
VStack{
Text(item).lineLimit(1)
}.background(Color.red)
}
}
}
}
}
It is VGrid, it grows vertically filling columns. In your case it is only one column.
If you want to fit all those content in screen, you'd need to increase number of grid columns, like
let data = ["O Menino","The Boy", "The Girl", "A Menina","Mae","Mother"]
let layout = Array(repeating: GridItem(.adaptive(minimum:50)), count: 4)

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.