SwiftUI: How to call a function to populate a LazyVGrid - swiftui

I'm working on some code in SwiftUI (learning as I go) where I'm constructing a vertical grid of items (This is heavily simplified for the purposes of this question):
let col1 = GridItem(alignment: .leading)
let col2 = GridItem(alignment: .trailing)
LazyVGrid(columns: [col1, col2]) {
Text("C1")
Text("C1")
Text("C2")
Text("C2")
}
So I get something like this:
+----+----+
| C1 | C1 |
+----+----+
| C2 | C2 |
+----+----+
Now in my code I'm doing some other stuff so I'd like to extract a function so my code looks something like this:
let col1 = GridItem(alignment: .leading)
let col2 = GridItem(alignment: .trailing)
LazyVGrid(columns: [col1, col2]) {
row("C1")
row("C2")
}
func row(text: String) -> ???? {
Text(text)
Text(text)
}
But I'm finding it difficult to see how to do it. Does the function return an array? or is there some aspect of Swift's builders I can use here? I tried an array but LazyVGrid's build didn't like it.

Research the #ViewBuilder attribute. This makes the function behave like the closure you're passing to LazyVGrid and many of the SwiftUI Views.
#ViewBuilder
func row(text: String) -> some View {
Text(text)
Text(text)
}

Related

Using TimelineView to use in a custom view modifier

I am looking to use the Timelineview to automatically update the background colour for a popup sheet that is displaying a list.
I am trying to figure out how to only call it once so I can use it for multiple sections in one list and in different views that display the type of data but with different information.
I have custom colours that I would like to use and call them from the assets catalogue.
A Sample Code:
List {
Section(header: Text("Ear")
.modifier(SectionHeaderModifer())) {
ForEach(Ear.indices, id: \.self) {index in
VStack {
VStack() {
Spacer()
Text(Ear[index].name)
.modifier(NameModifier())
}
HStack {
Text(String(format: "$%.2f", Ear[index].value))
.modifier(ValueModifier())
Text(Ear[index].code)
.modifier(CodeModifier())
}
.frame(width:270)
}
.modifier(RowFormatModifier())
.listRowBackground(Color("\(hour)").opacity(0.9))
.listRowSeparator(.hidden)
}
}
Section(header: Text("Nose")
.font(.title)
.fontWeight(.thin)) {
ForEach(Nose.indices, id: \.self) {index in
VStack {
VStack() {
Spacer()
Text(Nose[index].name)
.modifier(NameModifier())
}
HStack {
Text(String(format: "$%.2f", Nose[index].value))
.modifier(ValueModifier())
Text(Nose[index].code)
.modifier(CodeModifier())
}
.frame(width:270)
}
.frame(width: 330, height:95, alignment: .center)
.modifier(RowFormatModifier())
.listRowBackground(Color("18").opacity(0.9))
.listRowSeparator(.hidden)
}
}
The second section is using the Color("18") which is in reference to 1800 just for reference as well. I have custom colour for each hour and thus why I am using the Timeline view to update the variable
The hour variable would be updated by the Timelineview as:
TimelineView(.animation) {context in
let now = Date()
let hour = Calendar.current.component(.hour, from: now)
}
I am not sure and I have tried and failed to try to put this in a ViewModifier where I could update the list row colour.
struct ChangeRowColor: ViewModifier {
func body(content: Content) -> some View {
content
TimelineView(.animation) {context in
let now = Date()
let hour = Calendar.current.component(.hour, from: now)
}
.listRowBackground(Color("\(hour)").opacity(0.9))
}
I know this is wrong because it can't see the hour variable.
Any suggestions? Where should I call the timeline to determine the hour? I only want to do it once and pass this into the different sections and other views that are of similar format.
Thanks!

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.

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 to add .fontWeight as part of a ViewModifer in SwiftUI

I am trying to create ViewModifiers to hold all my type styles in SwiftUI. When I try to add a .fontWeight modifier I get the following error:
Value of type 'some View' has no member 'fontWeight'
Is this possible? Is there a better way to manage type styles in my SwiftUI project?
struct H1: ViewModifier {
func body(content: Content) -> some View {
content
.foregroundColor(Color.black)
.font(.system(size: 24))
.fontWeight(.semibold)
}
}
Font has weight as one of it's properties, so instead of applying fontWeight to the text you can apply the weight to the font and then add the font to the text, like this:
struct H1: ViewModifier {
// system font, size 24 and semibold
let font = Font.system(size: 24).weight(.semibold)
func body(content: Content) -> some View {
content
.foregroundColor(Color.black)
.font(font)
}
}
You can achieve this by declaring the function in an extension on Text, like this:
extension Text {
func h1() -> Text {
self
.foregroundColor(Color.black)
.font(.system(size: 24))
.fontWeight(.semibold)
}
}
To use it simply call:
Text("Whatever").h1()
How about something likeā€¦
extension Text {
enum Style {
case h1, h2 // etc
}
func style(_ style: Style) -> Text {
switch style {
case .h1:
return
foregroundColor(.black)
.font(.system(size: 24))
.fontWeight(.semibold)
case .h2:
return
foregroundColor(.black)
.font(.system(size: 20))
.fontWeight(.medium)
}
}
}
Then you can call using
Text("Hello, World!").style(.h1) // etc

Make Equal-Width SwiftUI Views in List Rows

I have a List that displays days in the current month. Each row in the List contains the abbreviated day, a Divider, and the day number within a VStack. The VStack is then embedded in an HStack so that I can have more text to the right of the day and number.
struct DayListItem : View {
// MARK: - Properties
let date: Date
private let weekdayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
return formatter
}()
private let dayNumberFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "d"
return formatter
}()
var body: some View {
HStack {
VStack(alignment: .center) {
Text(weekdayFormatter.string(from: date))
.font(.caption)
.foregroundColor(.secondary)
Text(dayNumberFormatter.string(from: date))
.font(.body)
.foregroundColor(.red)
}
Divider()
}
}
}
Instances of DayListItem are used in ContentView:
struct ContentView : View {
// MARK: - Properties
private let dataProvider = CalendricalDataProvider()
private var navigationBarTitle: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM YYY"
return formatter.string(from: Date())
}
private var currentMonth: Month {
dataProvider.currentMonth
}
private var months: [Month] {
return dataProvider.monthsInRelativeYear
}
var body: some View {
NavigationView {
List(currentMonth.days.identified(by: \.self)) { date in
DayListItem(date: date)
}
.navigationBarTitle(Text(navigationBarTitle))
.listStyle(.grouped)
}
}
}
The result of the code is below:
It may not be obvious, but the dividers are not lined up because the width of the text can vary from row to row. What I would like to achieve is to have the views that contains the day information be the same width so that they are visually aligned.
I have tried using a GeometryReader and the frame() modifiers to set the minimum width, ideal width, and maximum width, but I need to ensure that the text can shrink and grow with Dynamic Type settings; I chose not to use a width that is a percentage of the parent because I was uncertain how to be sure that localized text would always fit within the allowed width.
How can I modify my views so that each view in the row is the same width, regardless of the width of text?
Regarding Dynamic Type, I will create a different layout to be used when that setting is changed.
I got this to work using GeometryReader and Preferences.
First, in ContentView, add this property:
#State var maxLabelWidth: CGFloat = .zero
Then, in DayListItem, add this property:
#Binding var maxLabelWidth: CGFloat
Next, in ContentView, pass self.$maxLabelWidth to each instance of DayListItem:
List(currentMonth.days.identified(by: \.self)) { date in
DayListItem(date: date, maxLabelWidth: self.$maxLabelWidth)
}
Now, create a struct called MaxWidthPreferenceKey:
struct MaxWidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let nextValue = nextValue()
guard nextValue > value else { return }
value = nextValue
}
}
This conforms to the PreferenceKey protocol, allowing you to use this struct as a key when communicating preferences between your views.
Next, create a View called DayListItemGeometry - this will be used to determine the width of the VStack in DayListItem:
struct DayListItemGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: MaxWidthPreferenceKey.self, value: geometry.size.width)
}
.scaledToFill()
}
}
Then, in DayListItem, change your code to this:
HStack {
VStack(alignment: .center) {
Text(weekdayFormatter.string(from: date))
.font(.caption)
.foregroundColor(.secondary)
Text(dayNumberFormatter.string(from: date))
.font(.body)
.foregroundColor(.red)
}
.background(DayListItemGeometry())
.onPreferenceChange(MaxWidthPreferenceKey.self) {
self.maxLabelWidth = $0
}
.frame(width: self.maxLabelWidth)
Divider()
}
What I've done is I've created a GeometryReader and applied it to the background of the VStack. The geometry tells me the dimensions of the VStack which sizes itself according to the size of the text. MaxWidthPreferenceKey gets updated whenever the geometry changes, and after the reduce function inside MaxWidthPreferenceKey calculates the maximum width, I read the preference change and update self.maxLabelWidth. I then set the frame of the VStack to be .frame(width: self.maxLabelWidth), and since maxLabelWidth is binding, every DayListItem is updated when a new maxLabelWidth is calculated. Keep in mind that the order matters here. Placing the .frame modifier before .background and .onPreferenceChange will not work.
I was trying to achieve something similar. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment. I was looking to make this column in row as fixed width. And here is a very simple solution I applied to achieve that and it worked for me. Hope it can benefit someone else too.
var body: some View { // view for each row in list
VStack(){
HStack {
Text(wire.labelValueDate)
.
.
.foregroundColor(wire.labelColor)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 110.0, alignment: .trailing)
}
}
}