I have a scrolling view that displays an Object's Name as a Text View within a ForEach, I also have a GeometryReader because I need to know the position of each Object within the ScrollView. When displaying the Text View I have a function showObject() set the object's number to be it's position according to the GeometryReader.
So here's my problem. Above the ScrollView I have 3 Texts that show the name and number of the Objects for debug purposes. When scrolling, object[0]'s number updates like I expect it to, but object[1] and object[2] stay at the initialized 100 value. I have a print set up in my showObject() function and it's receiving the correct information, however it appears that it's failing to set the number on my objects after object[0]. Does anyone know why this is happening? Or perhaps a better way of achieving what I'm trying to do?
struct MyObject {
var name:String
var number:CGFloat = 100
}
struct MyScrollView: View {
#State var objects: [MyObject] = [MyObject(name: "a"), MyObject(name: "b"), MyObject(name: "c")]
var body: some View {
VStack{
Text(objects[0].name + " --- " + (objects[0].number.description))
Text(objects[1].name + " --- " + (objects[1].number.description))
Text(objects[2].name + " --- " + (objects[2].number.description))
ScrollView(showsIndicators: false){
VStack{
ForEach(self.options.indices) { i in
GeometryReader {geo in
self.showObject(index: i, num: geo.frame(in: .global).minY)
} // geo
} // ForEach
} // VStack inside ScrollView
} // ScrollView
} // top vstack
} // body
func showObject(index: Int, num: CGFloat) -> Text
{
objects[index].number = num
print (objects[index].name + " -- should be: " + num.description + " -- actual: " + objects[index].number.description)
return Text(objects[index].name)
}
} // view
Any change in #State var objects result in your MyScrollView redraw, ie recreation. So, rendering engine starts drawing your MyScrollView, comes to objects[index].number = num, which modifies #State var objects that reports that new redraw needed...
a -- should be: 94.0 -- actual: 100.0
2019-11-22 08:14:11.535085+0200 Testing[73866:816126] [SwiftUI] Modifying state during view update, this will cause undefined behavior.
... rendering interrupted and goes from start. This is why you observed only 1st MyObject changes.
Solution... well, you need to place .number out of workflow affecting recursive redraw. The simplest is defer modifications to next event cycle, like
if self.objects[index].number != num { // avoid redraw for same values
DispatchQueue.main.async {
self.objects[index].number = num
}
}
However, in general it is better to put MyModel outside of view and make some ViewModel wrapper, which would have view-effecting published name but not number, but this depends on your real needs.
Related
I am trying to create a LazyVGrid based on user selection in another view. As follows, the peoplelist and selectedpersonID are coming from other view.
I understand that I cannot use the "selectedpersons" during initializing of this view. I looked here(Cannot use instance member 'service' within property initializer; property initializers run before 'self' is available) to use onAppear() of the LazyVGrid.
It goes well during compiling and works ok if you select 1 person.
Once I selected 2 persons, I got a Fatal error that Index out of range at row.
struct Someview: View {
#ObservedObject var peoplelist : PersonList
let selectedpersonID : Set<UUID>?
#State private var days : [String] = Array(repeating: "0", count: selectedpersons.count * 5) //got first error here, during compiling
var body: some View {
VStack{
LazyVGrid(columns: columns) {
Text("")
ForEach(1..<6) { i in
Text("\(i)").bold()
}
ForEach(0..< selectedpersons.count , id: \.self) { row in
Text(selectedpersons[row].Name)
ForEach(0..<5) { col in
TextField("", text: $days[row * 5 + col])
}
}
}
.onAppear(){
days = Array(repeating: "0", count: selectedpersons.count * 5)}//no problem during compiling, but will have error when more than 1 person are selected.
.padding()
}
}
var selectedpersons: [Persons] {
return peoplelist.persons.filter {selectedpersonID!.contains($0.id)}
}
}
It seems to me that this OnAppear() is still slower than the content inside the LazyVGrid? So, the days is not changed quick enough for building the content insider the LazyVGrid?
Or did I make an error of the index in the array of days?
It's crashing because ForEach isn't a for loop its a View that needs to be supplied Identifiable data. If you're using indices, id: \self or data[index] then something has gone wrong. There are examples of how to use it correctly in the documentation.
Also onAppear is for performing a side-effect action when the UIView that SwiftUI manages appears, it isn't the correct place to set view data, the data should be already in the correct place when the View struct is init. Making custom Views is a good way to solve this.
I have scaled down one of the Apps I am working on to use to learn how to program App Intents and utilize Siri in my apps. I will post the scaled-down version of my code below in its simplest form. This is a simple app that merely keeps a total in an #State var named counter. The app shows the current total along with 2 buttons, one button being labeled "minus" and the other labeled "add".
When someone taps the minus button, 1 is subtracted from counter if counter is greater than 0. When someone taps the plus button, 1 is added to counter as long as it is not greater than 10,000.
The buttons actually call functions called decrementCounter and incrementCounter which do the math and update the value of the state variable counter.
The app works just fine as is. Minus and plus buttons work and the view is updated to reflect the current value of counter as buttons are pushed.
The problem is when I try to use an App Intent to add or subtract from counter. In this example, I only put in two App Intents, one to add to counter and the other to have Siri tell you what the current value of counter is.
The app intent called SiriAddOne calls the same function as is used when a button is pressed, however, counter does not get incremented.
Also, the app intent SiriHowMany will always tell you the counter is zero.
It's like the App Intents are not able to access the counter variable used in the view.
I do know that the functions are being called because, in my main program where I extracted this from, the incrementCounter and decrementCounter functions do others things as well. Those other things all work when I use the App Intent to call the function, but the counter variable remains unchanged.
Hopefully, someone can tell me what I am doing wrong here or how I need to go about doing this correctly. Thank you.
import SwiftUI
import AppIntents
struct ContentView: View {
// This variable counter is the only thing that changes
// and should be what forces the view to update
#State var counter = 0
var body: some View {
VStack {
Spacer()
Text("Total")
// Displays the current value of the counter
Text(String(counter))
Spacer()
// When button is pressed call decrementCounter function
Button(action: {
decrementCounter()
}, label: {
Text("Minus")
})
Spacer()
// When button is pressed call incrementCounter function
Button(action: {
incrementCounter()
}, label: {
Text("Add")
})
Spacer()
}
.padding()
}
// subtract 1 from the counter
// when this happens the view should update to
// to reflect the new value.
func decrementCounter() {
if counter > 0 {
counter -= 1
}
return
}
// Add 1 to the counter
// when this happens the view should update to
// to reflect the new value.
func incrementCounter() {
if counter <= 9999 {
counter += 1
}
return
}
// Set up App Intent, perform action when matched
// and have siri state it has been done.
#available(iOS 16, *)
struct SiriAddOne: AppIntent {
static var title: LocalizedStringResource = "Add 1"
static var description = IntentDescription("Adds 1 to the counter")
#MainActor
func perform() async throws -> some IntentResult {
ContentView().incrementCounter()
return .result(dialog: "Okay, added 1 to counter.")
}
}
// Set up App Intent, perform action when matched
// and have siri state the current value of the counter.
#available(iOS 16, *)
struct SiriHowMany: AppIntent {
static var title: LocalizedStringResource = "How Many"
static var description = IntentDescription("How Many?")
func perform() async throws -> some IntentResult {
return .result(dialog: "You have \(ContentView().counter).")
}
}
// Defines the two shortcut phrases to be used to call the two AppIntents
#available(iOS 16, *)
struct SiriAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SiriAddOne(),
phrases: ["Add one to \(.applicationName)"]
)
AppShortcut(
intent: SiriHowMany(),
phrases: ["How many \(.applicationName)"]
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I am trying to change the swipeAction from "Paid" to "UnPaid" based on payment status and somehow seems to be failing. Error: "The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions"
Appreciate any help
struct ContentView: View {
var data: [Data] = [data1, data2, data3, data4]
#State var swipeLabel = true
var body: some View {
let grouped = groupByDate(data)
List {
ForEach(Array(grouped.keys).sorted(by: >), id: \.self) { date in
let studentsDateWise = grouped[date]!
Section(header:Text(date, style: .date)) {
ForEach(studentsDateWise, id:\.self) { item in
HStack {
Text(item.name)
padding()
Text(item.date, style: .time)
if(item.paymentStatus == false) {
Image(systemName: "person.fill.questionmark")
.foregroundColor(Color.red)
} else {
Image(systemName: "banknote")
.foregroundColor(Color.green)
}
} // HStack ends here
.swipeActions() {
if(item.paymentStatus) {
Button("Paid"){}
} else {
Button("UnPaid"){}
}
}
} // ForEach ends here...
} // section ends here
} // ForEach ends here
} // List ends here
} // var ends here
}
The body func shouldn't do any grouping or sorting. You need to prepare your data first into properties and read from those in body, e.g. in an onAppear block. Also if your Data is a struct you can't use id: \.self you need to either specify a unique identifier property on the data id:\.myUniqueID or implement the Indentifiable protocol by either having an id property or an id getter that computes a unique identifier from other properties.
I would suggest separating all this code into small Views with a small body that only uses one or a two properties. Work from bottom up. Then eventually with one View works on an array of dates and another on an array of items that contains the small Views made earlier.
You should probably also learn that if and foreach in body are not like normal code, those are converted into special Views. Worth watching Apple's video Demystify SwiftUI to learn about structural identity.
Why running this code shows "Fatal error: Index out of range"?
import SwiftUI
struct MyData {
var numbers = [Int](repeating: 0, count: 5)
}
#main
struct TrySwiftApp: App {
#State var myData = MyData()
var body: some Scene {
WindowGroup {
ChildView(myData: myData)
.frame(width: 100, height: 100)
.onAppear {
myData.numbers.removeFirst() // change myData
}
}
}
}
struct ChildView: View {
let myData: MyData // a constant
var body: some View {
ForEach(myData.numbers.indices) {
Text("\(myData.numbers[$0])") // Thread 1: Fatal error: Index out of range
}
}
}
After checking other questions,
I know I can fix it by following ways
// fix 1: add id
ForEach(myData.numbers.indices, id: \.self) {
//...
}
or
// Edited:
//
// This is not a fix, see George's reply
//
// fix 2: make ChildView conforms to Equatable
struct ChildView: View, Equatable {
static func == (lhs: ChildView, rhs: ChildView) -> Bool {
rhs.myData.numbers == rhs.myData.numbers
}
...
My Questions:
How a constant value (defined by let) got out of sync?
What ForEach really did?
Let me give you a simple example to show you what happened:
struct ContentView: View {
#State private var lowerBound: Int = 0
var body: some View {
ForEach(lowerBound..<11) { index in
Text(String(describing: index))
}
Button("update") { lowerBound = 5 }.padding()
}
}
if you look at the upper code you would see that I am initializing a ForEach JUST with a Range like this: lowerBound..<11 which it means this 0..<11, when you do this you are telling SwiftUI, hey this is my range and it will not change! It is a constant Range! and SwiftUI says ok! if you are not going update upper or lower bound you can use ForEach without showing or given id! But if you see my code again! I am updating lowerBound of ForEach and with this action I am breaking my agreement about constant Range! So SwiftUI comes and tell us if you are going update my ForEach range in count or any thing then you have to use an id then you can update the given range! And the reason is because if we have 2 same item with same value, SwiftUI would have issue to know which one you say! with using an id we are solving the identification issue for SwiftUI! About id you can use it like this: id:\.self or like this id:\.customID if your struct conform to Hash-able protocol, or in last case you can stop using id if you confrom your struct to identifiable protocol! then ForEach would magically sink itself with that.
Now see the edited code, it will build and run because we solved the issue of identification:
struct ContentView: View {
#State private var lowerBound: Int = 0
var body: some View {
ForEach(lowerBound..<11, id:\.self) { index in
Text(String(describing: index))
}
Button("update") { lowerBound = 5 }.padding()
}
}
Things go wrong when you do myData.numbers.removeFirst(), because now myData.numbers.indices has changed and so the range in the ForEach showing Text causes problems.
You should see the following warning (at least I do in Xcode 13b5) hinting this could cause issues:
Non-constant range: not an integer range
The reason it is not constant is because MyData's numbers property is a var, not let, meaning it can change / not constant - and you do change this. However the warning only shows because you aren't directly using a range literal in the ForEach initializer, so it assumes it's not constant because it doesn't know.
As you say, you have some fixes. Solution 1 where you provide id: \.self works because now it uses a different initializer. Definition for the initializer you are using:
#available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a given constant
/// range.
///
/// The instance only reads the initial value of the provided `data` and
/// doesn't need to identify views across updates. To compute views on
/// demand over a dynamic range, use ``ForEach/init(_:id:content:)``.
///
/// - Parameters:
/// - data: A constant range.
/// - content: The view builder that creates views dynamically.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
Stating:
The instance only reads the initial value of the provided data and doesn't need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).
So that's why your solution 1 worked. You switched to the initializer which didn't assume the data was constant and would never change.
Your solution 2 isn't really a "solution". It just doesn't update the view at all, because myData.numbers changes so early that it is always equal, so the view never updates. You can see the view still has 5 lines of Text, rather than 4.
If you still have issues with accessing the elements in this ForEach and get out-of-bounds errors, this answer may help.
I was wondering how can one get DragGesture Velocity?
I understand the formula works and how to manually get it but when I do so it is no where what Apple returns (at least some times its very different).
I have the following code snippet
struct SecondView: View {
#State private var lastValue: DragGesture.Value?
private var dragGesture: some Gesture {
DragGesture()
.onChanged { (value) in
self.lastValue = value
}
.onEnded { (value) in
if lastValue = self.lastValue {
let timeDiff = value.time.timeIntervalSince(lastValue.time)
print("Actual \(value)") // <- A
print("Calculated: \((value.translation.height - lastValue.translation.height)/timeDiff)") // <- B
}
}
var body: some View {
Color.red
.frame(width: 50, height: 50)
.gesture(self.dragGesture)
}
}
From above:
A will output something like Value(time: 2001-01-02 16:37:14 +0000, location: (250.0, -111.0), startLocation: (249.66665649414062, 71.0), velocity: SwiftUI._Velocity<__C.CGSize>(valuePerSecond: (163.23212105439427, 71.91841849340494)))
B will output something like Calculated: 287.6736739736197
Note from A I am looking at the 2nd value in valuePerSecond which is the y velocity.
Depending on how you drag, the results will be either different or the same. Apple provides the velocity as a property just like .startLocation and .endLocation but unfortunately there is no way for me to access it (at least none that I know) so I have to calculate it myself, theoretically my calculations are correct but they are very different from Apple. So what is the problem here?
This is another take on extracting the velocity from DragGesture.Value. It’s a bit more robust than parsing the debug description as suggested in the other answer but still has the potential to break.
import SwiftUI
extension DragGesture.Value {
/// The current drag velocity.
///
/// While the velocity value is contained in the value, it is not publicly available and we
/// have to apply tricks to retrieve it. The following code accesses the underlying value via
/// the `Mirror` type.
internal var velocity: CGSize {
let valueMirror = Mirror(reflecting: self)
for valueChild in valueMirror.children {
if valueChild.label == "velocity" {
let velocityMirror = Mirror(reflecting: valueChild.value)
for velocityChild in velocityMirror.children {
if velocityChild.label == "valuePerSecond" {
if let velocity = velocityChild.value as? CGSize {
return velocity
}
}
}
}
}
fatalError("Unable to retrieve velocity from \(Self.self)")
}
}
Just like this:
let sss = "\(value)"
//Intercept string
let start = sss.range(of: "valuePerSecond: (")
let end = sss.range(of: ")))")
let arr = String(sss[(start!.upperBound)..<(end!.lowerBound)]).components(separatedBy: ",")
print(Double(arr.first!)!)