Text not appearing but onAppear triggers - swift

I am trying to use forEach in HStack and VStack. I am using Text in them and Text is not appearing while running but onAppear print values. Please have a look on my code. Why Text is not appearing? How can I make this work?
#State var sd = ["1","2","3","4","5","6","7"]
VStack {
ForEach(0...sd.count/3) { _ in
HStack {
ForEach(0...2) { _ in
if(self.sd.isEmpty) {
} else {
Text("Test")
.onAppear() {
if(!self.sd.isEmpty) {
print("i appeared")
self.sd.removeFirst()
}
}
}
}
Spacer()
}
}
}
What I am trying to achieve here?
I am trying to create a HStacks with maximum 3 Texts in it. I am using array here only to rendered Text 7 times.
Expected result with array with 7 elements--->
Want to create a VStack of 3 HStacks, In first 2 HStacks I want to render Text 3 times and in last HStack I want only one Text. (Like I have 7 array elements that's why 3 texts in first two hstacks and one in last hstack). If array has 10 elements, then 3 Hstacks of 3 Texts and last Stack with 1 Text. I am unable to render Text because my array is #state var and it refresh view.body every time I remove firstElement from it.
Is there any way to achieve this behaviour I am trying to achieve by using SwiftUI only. I don't want to use UICollection view.

You generally don't want to change a #State variable as a side-effect of the evaluation of body; in this case, your self.sd.removeFirst() is causing the body to be marked as needing to be re-evaluated, so SwiftUI just keeps calling it until your sd array is empty, which is why you don't see anything being rendered.
To get the effect you want, this is one way you can do it:
import SwiftUI
struct ContentView : View {
#State private var sd = ["1","2","3","4","5","6","7"]
private let columns = 3
var body: some View {
VStack {
ForEach(Array(stride(from: 0, to: sd.count, by: columns))) { row in
HStack {
ForEach(0..<self.columns) { col in
if row + col < self.sd.count {
Text("\(self.sd[row + col])")
}
}
}
}
}
}
}
P.S. Most attempts to modify state within the body evaluation seem to result in the runtime warning Modifying state during view update, this will cause undefined behavior. – it seems that your array mutation somehow side-stepped this check, but hopefully that sort of thing will be called out at runtime in the future.

Related

SwiftUI: How to display an array of items and have them modifiable programmatically

I have a list of SwiftUI Text instances, and I want to be able to alter their characteristics (in this example, font weight) after initial creation of the body. It looks like SwiftUI doesn't want you to perform modifying functions on View elements after placing them in the body (i.e. Text().fontWeight() doesn't change the weight of the text in the instance Text() gave you, but returns a different instance which has the font weight you want, which means that these modifying methods only make sense when applied within your view's body, I guess).
My attempts to work with this paradigm have failed, however. Consider the following code to display a line of digits and a "Click Me" button which is intended to bold a different digit with every click. It initializes an array of boldable Text instances. In order to make ForEach work, because Text doesn't conform to Hashable or Identifiable, I needed to make a struct BoldableText which has an identifier, a Text instance, and the intended weight of that Text instance.
A function, highlight(), is intended to advance the bolded text to the next digit, wrapping around to 0 when it reaches the end.
struct BoldableText: Identifiable {
var id: Int // So that it works with ForEach in the View
var theWeight: Font.Weight // The weight of this Text item
var theText:Text // The actual Text item
}
let DIGITS = 6 // How many digits to display
var idx_of_bolded:Int = 0 // Which of them is currently bold?
// A view to show an array of Text items, each boldable after user interaction
struct ArrayView: View {
#State var numbers:[BoldableText] = []
// Set up the numbers array with digit texts with regular font weight
init() {
numbers = []
for i in 0..<DIGITS {
numbers.append(BoldableText(id: i, theWeight:Font.Weight.regular, theText: Text(String(i))))
}
}
// Advance the highlighted number and highlight that text
func highlight() {
numbers[idx_of_bolded].theWeight = Font.Weight.regular
idx_of_bolded = (idx_of_bolded + 1) % DIGITS
numbers[idx_of_bolded].theWeight = Font.Weight.heavy
}
var body: some View {
HStack {
// Display the numbers in a line
ForEach(numbers) { number in
Text(String(number.id)).fontWeight(number.theWeight)
}
Button(action: highlight) {
Text("Click to advance the number")
}
}
}
}
The problem is, this won't even build. The two lines in highlight() which try to change the .theWeight property get flagged for trying to mutate numbers[].
If I fix this by marking func highlight() as mutating, Xcode flags the call to it from the Button() action.
Alternately, I can fix it by moving var numbers ... outside of the struct, but then I can't use #State on it.
That gets it to build, but clicking the button doesn't do anything, I'm guessing because nothing is bound to the font weights, Swift thinks they're just static values.
Meanwhile, if I try binding by using fontWeight($number.theWeight), Xcode complains that it "can't find $number in scope". Same goes for ForEach($numbers)
If I bring #State var numbers... back into the struct and keep ForEach($numbers) the error at fontWeight($number.theWeight) changes to a very intriguing "Cannot convert value of type 'Binding<Font.Weight>' to expected argument type 'Font.Weight?'" (which suggests to me that fontWeight doesn't even accept bound parameters, which seems odd).
Is it possible to even do this with SwiftUI, or must I go back to using a ViewController?
Many changes - conceptual mistakes, see inline. (I recommend to take several SwiftUI full-scale tutorials)
Tested with Xcode 13.4 / iOS 15.5
struct BoldableText: Identifiable {
var id: Int
var theWeight: Font.Weight
var theText: String // << Text is a view, so store just string here !!
}
let DIGITS = 6
struct ArrayView: View {
#State private var numbers:[BoldableText] // << initialized once so will do this in init
#State private var idx_of_bolded:Int = 0 // << affects view, so needs to be state
init() {
// << prepare initial model
let numbers = (0..<DIGITS).map {
BoldableText(id: $0, theWeight:Font.Weight.regular, theText: String($0))
}
_numbers = State(initialValue: numbers) // << state initialization !!
}
func highlight() {
numbers[idx_of_bolded].theWeight = Font.Weight.regular
idx_of_bolded = (idx_of_bolded + 1) % DIGITS // << now updates view !!
numbers[idx_of_bolded].theWeight = Font.Weight.heavy
}
var body: some View {
HStack {
// Display the numbers in a line
ForEach(numbers) { number in
Text(String(number.id)).fontWeight(number.theWeight)
}
Button(action: highlight) {
Text("Click to advance the number")
}
}
}
}

SwiftUI ForEach index jump by 2

I am working on SwiftUI ForEach. Below image shows what I want to achieve. For this purpose I need next two elements of array in single iteration, so that I can show two card in single go. I search on a lot but did find any way to jump index swiftUI ForEach.
Need to show two cards in single iteration
Here is my code in which I have added the element same array for both card which needs to be in sequence.
struct ContentView: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// I need jump of 2 indexes
ForEach(videos) { video in
//need to show the next two elements of the videos array
HStack {
videoCardView(video: video)
Spacer()
//video + 1
videoCardView(video: video)
}
.padding([.leading, .trailing], 30)
.padding([.top, .bottom], 10)
}
}
}
.background(Color(ColorName.appBlack.rawValue))
}
}
Any better suggestion how to build this view.
While LazyVGrid is probably the best solution for what you want to accomplish, it doesn't actually answer your question.
To "jump" an index is usually referred to as "stepping" in many programming languages, and in Swift it's called "striding".
You can stride (jump) an array by 2 like this:
ForEach(Array(stride(from: 0, to: array.count, by: 2)), id: \.self) { index in
// ...
}
You can learn more by taking a look at the Strideable protocol.
ForEach isn’t a for loop, many make that mistake. You need to supply identifiable data to it which should give you the clue to get your data into a suitable format first. You could process the array into another array containing a struct that has an id and holds the first and second video and pass that to the View that does the ForEach. View structs are lightweight make as many as you need.
You could also make a computed var but that wouldn’t be as efficient as a separate View because you might unnecessary recompute if something different changes.
Foreach is constrained compared to a 'for' loop. One way to fool ForEach into behaving differently is to create a shadow array for ForEach to loop through.
My purpose was slightly different than yours, but the workaround below seems like it could solve your challenge as well.
import SwiftUI
let images = ["house", "gear", "car"]
struct ContentView: View {
var body: some View {
VStack {
let looper = createCounterArray()
ForEach (looper, id:\.self) { no in
Image(systemName: images[no])
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.padding()
}
}
}
//
// return an array of the simulated loop data.
//
func createCounterArray() -> [Int] {
// create the data needed
return Array(arrayLiteral: 0,1,1,2)
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

How to perform `ForEach` on a computed property in SwiftUI

Intro
Imagine we have an infinite range like the range of all possible integers and we can NOT just store them in memory. So we need to calculate them chunk by chunk like:
func numbers(around number: Int, distance: Int) -> [Int] {
((number - distance)...(number + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
so now we can build our view like:
struct ContentView: View {
#State var lastVisibleNumber: Int = 0
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(numbers(around: lastVisibleNumber, distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
// .onAppear { lastVisibleNumber = number }
}
}
}
}
}
Describing the issue and what tried
In order to load more numbers when needed, I have tried to update the lastVisibleNumber on the appearance of last visible Number by adding the following modifier on the Text:
.onAppear { lastVisibleNumber = number }
Although there is a safe range that is visible and should prevent the infinite loop (theoretically), adding this modifier will freeze the view for calculating all numbers!
So how can we achieve a scroll view with some kind of infinite data source?
Considerations
The range of Int is just a sample and the question can be about an infinite range of anything. (In a form of an Array!)
we don't want unexpected stops (like showing a dummy spinner at the leading or trailing of the list) in the middle of the scrolling.
For this, you should try using table view and data source. Let's assume you have an array of integers. You may create a buffer with an arbitrary number of instances. Let say 100. In that case, with a similar logic, your distance would become a 50. When you scroll, and close enough to the limits, you create another array and reload the table view data source so that you can pretend like you have an infinite array. Be aware that reusable cell and table view implementation is very consistent and optimized.
Don’t refresh view when lastVisibleNumber changes - it just gets into endless loop. You can create Observableobject, store it and update it only on-demand (I provided a button but you can obviously load it onAppear eith some criteria - e.g. last of the array was shown):
class NumberViewModel: ObservableObject {
var lastVisibleNumber = 0
#Published var lastRefreshedNumber = 0
func numbers(distance: Int) -> [Int] {
((lastRefreshedNumber - distance)...(lastRefreshedNumber + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
}
struct ContentView: View {
#StateObject var viewModel = NumberViewModel()
var body: some View {
VStack {
Button("Refresh view", action: {
viewModel.lastRefreshedNumber = viewModel.lastVisibleNumber
})
ScrollView(.horizontal) {
LazyHStack {
ForEach(viewModel.numbers(distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
.onAppear { viewModel.lastVisibleNumber = number }
}
}
}
}
}
}

Is there a way to disable SwiftUI's Text automatic orphaning fix?

I'm building essentially a memorization app, where tapping a button on the keyboard takes the next word from a predetermined string, and adds it to the outputted text as shown below:
struct TypingGameView: View {
#State var text: String = "Some text that wraps at the incorrect spot and is quite annoying."
#State var outputText: String = ""
#State private var enteredText: String = ""
#State private var wordsTapped = 0
var body: some View {
ZStack {
TextField("", text: $enteredText)
.onChange(of: enteredText) { newValue in
addToOutputText(newValue)
}
Rectangle()
.foregroundColor(.white)
VStack {
Text(outputText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Spacer()
}
}
}
func addToOutputText(_ val: String) {
let words = text.components(seperatedBy: " ")
for (index, word) in words.enumerated() {
if index = wordsTapped {
outputText += "\(word) "
wordsTapped += 1
return
}
}
}
}
The problem is, the last word of the first line jumps to the next line only if there is one other word after it, but then jumps back to the first line once there are more words after that. See below:
To the best of my knowledge, this is an automatic feature of the Text view in SwiftUI to prevent there being any orphaned words. I want to disable this as it makes the words jump around in the view I've created. I've seen solutions with using a CATextLayer in UIKit (see UILabel and UITextView line breaks don't match and Get each line of text in a UILabel), but I need something that works with SwiftUI #State wrappers and would prefer a solution that uses all SwiftUI. End goal is to get same functionality as in above video, but with no automatic orphan fixing.
Edit: Just tried using Group with individual Text views inside for each word. Still does the same thing :/
Simply add three spaces to the end of the string:
let myString: String = "Modo ligth (colores predefinidos claros)."
Text(myString)
let myString: String = "Modo ligth (colores predefinidos claros)."
let myNewString: String = myString + " " // <- Three additional spaces
Text(myNewString)
To expand on Wamba's answer. You can use Zero Width spaces so that if the text is on a single line it won't have visible space after it, nor potentially wrap onto a third line.
/// This makes the text wrap so that it can have one word on the last line (a widow), by default iOS will leave an extra gap on the second to last line
/// and move the second to last word to the last line. With 2 line text this can make the text look like a column and make the UI look unbalanced.
public extension String {
var fixWidow: String {
self + "\u{200B}\u{200B}\u{200B}\u{200B}"
}
}
// Use case:
Text("a short sentence that often wraps to two lines".fixWidow)
This is a hack, and I don't like it, but the app user can't see hacky code only a weird UI so this is preferable until Apple finally gives SwiftUI the same functionality as UIKit.

In SwiftUI, How can I change the way List stack the row?

As default SwiftUI List stack the row by time, which results in the most recently created row is at the end of the list.
Instead I want most recently created row to be at upfront, the first row of the list.
Is there anyway to achieve this?
You can reverse the List so the most recent data is stored on top.
Here is an example with a button so you can add elements to the List and test it out.
struct ContentView: View {
#State var dataStore = [0, 1, 2]
var body: some View {
VStack {
List(dataStore.reversed(), id: \.self) { data in
Text(String(data))
}
Button(action: {
self.dataStore += [self.dataStore.count]
}) {
Text("Insert to list")
}
}.listStyle(GroupedListStyle())
}
}