Challenge with automatically populating a grid/table - swiftui

I have an array of words which I would like to summarise by starting letter and sorted by length of word in a grid/table as follows:
Top row is a heading for columns
Left column is a heading for rows
Columns are starting letters of words
Rows are length of words
Trailing column is the sum of rows
Bottom row is the sum of columns
Example of what I’d like to achieve:
what I'd like to achieve
The array of words changes often; unique starting letters can be any letter from the alphabet (but always only 3 different letters for each array) and length of words can range from 4 to 15 characters.
My challenge is so far my code is cumbersome and “inelegant” but most importantly does not work where a grid value is nil. I should perhaps be building a dictionary of the data and using that to populate the grid, but not entirely sure how to do that. Any help or suggestions will be much appreciated.
Results of my attempt so far:
attempt so far
My MRE is:
struct ContentView: View {
let words: [String] = ["deed", "denote", "donut", "eden", "ended", "heeded", "hood"]
let letters = Array(" " + "DEH" + " ")
var body: some View {
let wordsByLength = Dictionary.init(grouping: words, by: \.count).mapValues(\.count).sorted(by: <)
Form {
HStack(spacing: 5) {
ForEach(letters, id: \.self) { position in
Text(position.description).bold()
.frame(minWidth: 30, maxWidth: .infinity)
}
}
HStack(spacing: 5) {
VStack {
ForEach(wordsByLength.sorted(by: <), id: \.key) { key, value in
Text(key.description).bold()
}
}
.frame(minWidth: 30, maxWidth: .infinity)
VStack {
ForEach(Dictionary.init(grouping: words.filter({ $0.hasPrefix(letters[1].lowercased().description)}), by: \.count).mapValues(\.count).sorted(by: <), id: \.key) { key, value in
Text(value.description)
}
}
.frame(minWidth: 30, maxWidth: .infinity)
VStack {
ForEach(Dictionary.init(grouping: words.filter({ $0.hasPrefix(letters[2].lowercased().description)}), by: \.count).mapValues(\.count).sorted(by: <), id: \.key) { key, value in
Text(value.description).foregroundColor(.red)
}
}
.frame(minWidth: 30, maxWidth: .infinity)
VStack {
ForEach(Dictionary.init(grouping: words.filter({ $0.hasPrefix(letters[3].lowercased().description)}), by: \.count).mapValues(\.count).sorted(by: <), id: \.key) { key, value in
Text(value.description).foregroundColor(.red)
}
}
.frame(minWidth: 30, maxWidth: .infinity)
VStack {
ForEach( Dictionary.init(grouping: words, by: \.count).mapValues(\.count).sorted(by: <), id: \.key) { key, value in
Text(value.description).bold()
}
}
.frame(minWidth: 30, maxWidth: .infinity)
}
}
}
}

Keep it simple!
If you were in my team and I saw code like:
Dictionary.init(grouping: words.filter({ $0.hasPrefix(letters[1].lowercased().description)}), by: \.count).mapValues(\.count).sorted(by: <), id: \.key)
I'd (politely) ask you to think how you could simplify it. There's zero chance you would look at that code in 6 months time and understand what it did.
Think about what each cell contains:
Top Row:
starting letters of the words
Next rows:
lengths of the words
number of words for each starting letter and length
count of words with length
Bottom Row
count of words for each starting letter
Now we understand what goes in each square, we can write functions for each, i.e.:
var initialLetters: [String] {
Set(words.compactMap(\.first).map { String($0) }).sorted(by: <)
}
var wordLengths: [Int] {
Set(words.map(\.count)).sorted(by: <)
}
func countOfWordsStartingWith(_ string: String, length: Int) -> Int {
words.filter { $0.hasPrefix(string)}.filter { $0.count == length }.count
}
func countOfWordsLength(_ length: Int) -> Int {
words.filter { $0.count == length }.count
}
func countOfWordsStartingWith(_ string: String) -> Int {
words.filter { $0.hasPrefix(string)}.count
}
Then your view becomes:
struct ContentView: View {
let words = ["deed", "denote", "donut", "eden", "ended", "heeded", "hood"]
var body: some View {
Form {
Grid {
GridRow {
Color.clear // Used to fill empty spaces in the Grid
.frame(width: 1, height: 1)
ForEach(initialLetters, id: \.self) { letter in
Text(letter.uppercased())
.bold()
}
Color.clear
.frame(width: 1, height: 1)
}
Divider()
ForEach(wordLengths, id: \.self) { wordLength in
GridRow {
Text(wordLength, format: .number)
.bold()
ForEach(initialLetters, id: \.self) { letter in
Text(countOfWordsStartingWith(letter, length: wordLength), format: .number)
}
Text(countOfWordsLength(wordLength), format: .number)
.italic()
}
Divider()
}
GridRow {
Color.clear
.frame(width: 1, height: 1)
ForEach(initialLetters, id: \.self) { letter in
Text(countOfWordsStartingWith(letter), format: .number)
.italic()
}
Color.clear
.frame(width: 1, height: 1)
}
}
}
.padding()
}
Note how this now only has one "source of truth" (words), so no need for a separate array of letters.
It's also dynamic, so it works whatever you add to the words:
let words = ["whatever", "deed", "denote", "donut", "eden", "ended", "heeded", "hood"]

Related

SwiftUI Form & Section items do not have equal height?

I am using a SwiftUI Form view but the 1st item in the Section of multiple items ("Account" in RED) has a shorter height than the following two items "Notifications" & "Preferences".
I tried adding a .frame(height: ) modifier to SettingsItem's VStack but that didn't force all the items to have the same height.
The only way the different sections look correct is if it's a single item in its own section, see the image below where the text "Password" is even inside its container but 3 items sharing a section are not even.
2 QUESTIONS
How can I make all items in a section have the same vertical height.
Is it possible to remove the corner radius so that the form has sharp edges? I need to get rid of the corner radius because I will add negative 20 padding leading & trailing so that each item expands to the edge of the screen so I don't want to see rounded corners.
struct Settings: View {
var body: some View {
NavigationView {
Form {
Section(header: Text("ACCOUNT SETTINGS")
.foregroundColor(.gray)) {
SettingsItem(showDivider: true, text: "Account")
SettingsItem(showDivider: true, text: "Notifications")
SettingsItem(showDivider: false, text: "Preferences")
}
Section(header: Text("SECURITY")
.foregroundColor(.gray)) {
SettingsItem(showDivider: false, text: "Password")
}
Section(header: Text("ABOUT")
.foregroundColor(.gray)) {
SettingsItem(showDivider: true, text: "FAQ")
SettingsItem(showDivider: true, text: "About")
SettingsItem(showDivider: false, text: "Contact")
}
}
}
}
}
struct SettingsItem: View {
let showDivider: Bool
var text: String
var body: some View {
VStack {
HStack {
Text(text)
.font(.system(size: 18, weight: .regular))
.foregroundColor(.gray)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.gray)
}
if showDivider {
Divider()
}
}
}
}
Removing the dividers fixes the problem:
struct ContentView: View {
var body: some View {
NavigationView {
Form {
Section("ACCOUNT SETTINGS") {
SettingsItem(text: "Account")
SettingsItem(text: "Notifications")
SettingsItem(text: "Preferences")
}
Section("SECURITY") {
SettingsItem(text: "Password")
}
Section("ABOUT") {
SettingsItem(text: "FAQ")
SettingsItem(text: "About")
SettingsItem(text: "Contact")
}
}
}
}
}
struct SettingsItem: View {
var text: String
var body: some View {
VStack {
HStack {
Text(text)
.font(.system(size: 18, weight: .regular))
.foregroundColor(.gray)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.gray)
}
}
}
}

SwitUI - VStack - Jumping when text entered

I am developing a basic passcode entry screen, consisting off a top Stack to display currently entry, then some HStack's displaying the numbers
VStack(){
HStack(spacing: 20){
ForEach(codes,id: \.self){i in
Text("*")
}
}
HStack(){
<Number 1 - 3>
}
HStack(){
<Number 4 - 6>
}
HStack(){
<Number 7 - 9>
}
HStack(){
<Number 0>
}
}
This issue im facing is when there is no passcode entered the top HStack dosnt use up any space, so has a vertical height of 0, when I enter a code, it forces the whole view to jump a little as the view resizes.
How can I stop that so
If I'm being honest, it was quiet fun to build ! 😄 Don't forget to mark this answer as the right one if it solved your issue. ✅
PROBLEM
The jumping effect is due to SwiftUI updating all views positions based on available space calculated based on your content (passcode digits). The font, font weight, text size, etc… all has an effect on the available space left for other views.
SOLUTION
To avoid that, you need to a predefined frame that will let the parent view know that your digits will never take more space. Doing so, each update won't effect the position of any other view because the allocated top space would always be size you specified and not the digits sizes (or absence).
CODE
import SwiftUI
import Combine
// Using Combine to manage digits and future network calls…
class PasscodeManager: ObservableObject {
let codesQuantity = 4
#Published var codes = [Int]()
}
struct PasscodeView: View {
#StateObject private var manager = PasscodeManager()
var body: some View {
VStack {
Spacer()
// Dots placeholders and passcode digits
selectedCodes
Spacer()
// Numberpad
PasscodeLine(numbers: 1...3) { add(number: $0) }
PasscodeLine(numbers: 4...6) { add(number: $0) }
PasscodeLine(numbers: 7...9) { add(number: $0) }
PasscodeLine(numbers: 0...0) { add(number: $0) }
Spacer()
}
.padding()
}
var selectedCodes: some View {
let minDots = manager.codes.count == manager.codesQuantity ? 0:1
let maxDots = manager.codesQuantity - manager.codes.count
return HStack(spacing: 32) {
ForEach(manager.codes, id: \.self) { Text("\($0)") }
if maxDots != 0 {
ForEach(minDots...maxDots, id: \.self) { _ in
Circle().frame(width: 12)
}
}
}
.font(.title.bold())
// Setting a default height should fix your problem. 🙂
.frame(height: 70)
}
func add(number: Int) {
guard manager.codes.count < manager.codesQuantity else { return }
manager.codes.append(number)
}
}
struct PasscodeLine: View {
let numbers: ClosedRange<Int>
var select: (Int) -> Void
var body: some View {
HStack {
ForEach(numbers, id: \.self) { number in
Spacer()
Button(action: { select(number) },
label: {
Text("\(number)")
.font(.title)
.fontWeight(.medium)
.foregroundColor(Color(.label))
.padding(32)
.background(Color(.quaternarySystemFill))
.clipShape(Circle())
})
}
Spacer()
}
}
}
RESULT

How we can solve unwanted offset in onEnd gesture in swiftUI 2.0

I have this codes for swipe the rows inside a scrollview, at some first try it works fine, but after some scrolling and swipe it starts giving some value for offset even almost zero, and it makes red background shown in edges! I know, probably would some of you came with padding solution up and it would work and cover unwanted red background but even with this unconvinced solution, the text would seen offseted from other ones! I am thinking this is a bug of SwiftUI, otherwise I am very pleased to know the right answer.thanks
import SwiftUI
struct item: Identifiable
{
var id = UUID()
var name : String
var offset : CGFloat = 0
}
struct ContentView: View {
#State var Items: [item] = []
func findIndex(item: item) -> Int
{
for i in 0...Items.count - 1
{
if item.id == Items[i].id
{
return i
}
}
return 0
}
var body: some View {
ScrollView(.vertical, showsIndicators: false)
{
LazyVStack(alignment: .leading)
{
ForEach(Items) { item in
ZStack
{
Rectangle()
.fill(Color.red)
HStack
{
VStack(alignment: .leading)
{
Text(item.name)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.background(Color.yellow)
.offset(x: item.offset)
.gesture(
DragGesture()
.onChanged({ (value) in
Items[findIndex(item: item)].offset = value.translation.width
})
.onEnded({ (value) in
Items[findIndex(item: item)].offset = 0
}))
.animation(.easeInOut)
}
}
}
}
}
}
.onAppear()
{
for i in 0...10
{
Items.append(item(name: "item " + String(i)))
}
}
}
}
It is because drag gesture is handled even if you try to scroll vertically and horizontal transition is applied to your offset. So the solution is to add some kind of boundary conditions that detects horizontal-only drag.
Here is just a simple demo (you can think about more accurate one). Tested with Xcode 12 / iOS 14 as kind of enough for demo.
DragGesture()
.onChanged({ (value) in
// make offset only when some limit exceeds or when it definitely sure
// that you drag in horizontal only direction, below is just demo
if (abs(value.translation.height) < abs(value.translation.width)) {
Items[i].offset = value.translation.width
}
})
Update: forgot that I changed also to use indices to avoid afterwards finding by id, so below is more complete snapshot
ForEach(Items.indices, id: \.self) { i in
ZStack
{
Rectangle()
.fill(Color.red)
HStack
{
VStack(alignment: .leading)
{
Text(Items[i].name)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.background(Color.yellow)
.offset(x: Items[i].offset)
.gesture(
DragGesture()
.onChanged({ (value) in
if (abs(value.translation.height) < abs(value.translation.width)) {
Items[i].offset = value.translation.width
}
})
.onEnded({ (value) in
Items[i].offset = 0.0
}))
.animation(.easeInOut)
}
}
}

List view - any way to scroll horizontally?

I have a list that loads from a parsed CSV file built using SwiftUI and I can't seem to find a way to scroll the list horizontally.
List {
// Read each row of the array and return it as arrayRow
ForEach(arrayToUpload, id: \.self) { arrayRow in
HStack {
// Read each column of the array (Requires the count of the number of columns from the parsed CSV file - itemsInArray)
ForEach(0..<self.itemsInArray) { itemNumber in
Text(arrayRow[itemNumber])
.fixedSize()
.frame(width: 100, alignment: .leading)
}
}
}
}
.frame(minWidth: 1125, maxWidth: 1125, minHeight: 300, maxHeight: 300)
.border(Color.black)
The list renders how I would like but I'm just stuck on this one point.
Preview Image Of Layout
Swift 5;
iOS 13.4
You should use an ScrollView as Vyacheslav Pukhanov suggested but in your case the scrollView size does not get updated after the async call data arrive. So you have 2 options:
Provide a default value or an alternative view.
Provide a fixed size to the HStack inside of the ForeEach. (I used this one)
I faced the same problem laying out an horizontal grid of two columns. Here's my solution
import SwiftUI
struct ReviewGrid: View {
#ObservedObject private var reviewListViewModel: ReviewListViewModel
init(movieId: Int) {
reviewListViewModel = ReviewListViewModel(movieId: movieId)
//ReviewListViewModel will request all reviews for the given movie id
}
var body: some View {
let chunkedReviews = reviewListViewModel.reviews.chunked(into: 2)
// After the API call arrive chunkedReviews will get somethig like this => [[review1, review2],[review3, review4],[review5, review6],[review7, review8],[review9]]
return ScrollView (.horizontal) {
HStack {
ForEach(0..<chunkedReviews.count, id: \.self) { index in
VStack {
ForEach(chunkedReviews[index], id: \.id) { review in
Text("*\(review.body)*").padding().font(.title)
}
}
}
}
.frame(height: 200, alignment: .center)
.background(Color.red)
}
}
}
This is a dummy example don't expect a fancy view ;)
I hope it helps you.
You should use a horizontal ScrollView instead of the List for this purpose.
ScrollView(.horizontal) {
VStack {
ForEach(arrayToUpload, id: \.self) { arrayRow in
HStack {
ForEach(0..<self.itemsInArray) { itemNumber in
Text(arrayRow[itemNumber])
.fixedSize()
.frame(width: 100, alignment: .leading)
}
}
}
}
}

SwiftUI reduce spacing of rows in a list to null

I want to reduce the linespacing in a list to null.
My tries with reducing the padding did not work.
Setting ´.environment(.defaultMinListRowHeight, 0)´ helped a lot.
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.padding(0)
//.frame(height: 60)
.background(Color.yellow)
}
//.frame(height: 60)
.padding(0)
.background(Color.blue)
}
.environment(\.defaultMinListRowHeight, 0)
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Changing the ´separatorStyle´ to ´.none´ only removed the Line but left the space.
Is there an extra ´hidden´ view for the Lists row or for the Separator between the rows?
How can this be controlled?
Would be using ScrollView instead of a List a good solution?
ScrollView(.horizontal, showsIndicators: true)
{
//List {
ForEach(data, id: \.self)
{ item in
HStack{
Text("\(item)")
Spacer()
}
Does it also work for a large dataset?
Well, actually no surprise - .separatorStyle = .none works correctly. I suppose you confused text background with cell background - they are changed by different modifiers. Please find below tested & worked code (Xcode 11.2 / iOS 13.2)
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.background(Color.yellow) // text background
.listRowBackground(Color.blue) // cell background
}
}
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Update:
it's not possible to avoid the blue space between the yellow Texts?
Technically yes, it is possible, however for demo it is used hardcoded values and it is not difficult to fit some, while to calculate this dynamically might be challenging... anyway, here it is
it needs combination of stack for compression, content padding for resistance, and environment for limit:
List {
ForEach(data, id: \.self)
{ item in
HStack { // << A
Text("\(item)")
.padding(.vertical, 2) // << B
}
.listRowBackground(Color.blue)
.background(Color.yellow)
.frame(height: 12) // << C
}
}
.environment(\.defaultMinListRowHeight, 12) // << D
I do it the easy SwiftUI way:
struct ContentView: View {
init() {
UITableView.appearance().separatorStyle = .none
}
var body: some View {
List {
ForEach(0..<10){ item in
Color.green
}
.listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) )
}
}
}
Reduce row spacing is really tricky, try
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.largeTitle)
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
It use ScrollView instead of List and negative padding.
I didn't find any solution based on List, we have to ask Apple to publish xxxxStyle protocols and underlying structures.
UPDATE
What about this negative padding value? For sure it depends on height of our row content and unfortunately on SwiftUI layout strategy. Lets try some more dynamic content! (we use zero padding to demostrate the problem to solve)
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.system(size: item))
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
//.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
Clearly the row spacing is not fixed value! We have to calculate it for every row separately.
Next code snippet demonstrate the basic idea. I used global dictionary (to store height and position of each row) and tried to avoid any high order functions and / or some advanced SwiftUI technic, so it is easy to see the strategy. The required paddings are calculated only once, in .onAppear closure
import SwiftUI
var _p:[Int:(CGFloat, CGFloat)] = [:]
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
#State var space: [CGFloat] = []
func spc(item: CGFloat)->CGFloat {
if let d = data.firstIndex(of: item) {
return d < space.count ? space[d] : 0
} else {
return 0
}
}
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)")
.font(.system(size: item))
.background(Color.yellow)
}
.background(
GeometryReader { proxy->Color in
if let i = self.data.firstIndex(of: item) {
_p[i] = (proxy.size.height, proxy.frame(in: .global).minY)
}
return Color.green
}
)
.padding(.leading, 5)
.padding(.bottom, -self.spc(item: item))
.frame(maxWidth: .infinity)
}.onAppear {
var arr:[CGFloat] = []
_p.keys.sorted(by: <).forEach { (i) in
let diff = (_p[i + 1]?.1 ?? 0) - (_p[i]?.1 ?? 0) - (_p[i]?.0 ?? 0)
if diff < 0 {
arr.append(0)
} else {
arr.append(diff)
}
}
self.space = arr
}
}
}
}
}
Running the code I've got