Im trying to create a list of Hstack'd cards, That is to say, I want to create a scroll view of a series of rows. Each row would contain an HStack of two views displayed side by side, and initialized by some list data structure.
struct MyHStackView: View {
var myArray = [SomeStruct(1), SomeStruct(3), SomeStruct(4), SomeStruct(5), SomeStruct(6)]
var body: some View {
ScrollView(.vertical) {
VStack {
ForEach(0..<self.myArray.count) { index in
HStack {
SubView(myArray[index])
SubView(myArray[index+1])
}
}
}
}
The only issue is my current implementation touches every element of the array, is there a stride function built into the new ForEach so that I can index on every other element on the array to initialize the row? How would you approach this?
If just every other, you may try
VStack {
ForEach(0 ..< self.myArray.count/2) { index in
HStack {
SubView(myArray[index * 2])
SubView(myArray[index * 2 + 1])
}
}
}
Otherwise, you may need to use the stride function:
ForEach(Array(stride(from: 0, to: self.myArray.count, by: 2)), id: \.self) { index in
// ...
}
For simple use-cases the solution posted by E. Coms may work. If you plan on modifying or re-ordering the list, it may give you trouble since the id specified is the same as the index and List won't be able to correctly animate removals/additions/re-ordering.
I would create a data-structure to represent the tuple in an identifiable manner:
struct SomeStructTouple: Identifiable {
let a: SomeStruct
let b: SomeStruct
var id: String {
"\(a.id)-\(b.id)"
}
}
and then create an array of touples to generate the list.
Related
As the title suggests I'm getting an error in the below code (related to the loop when trying to build.
struct ContentView: View {
var testImages = ["Image1", "Image2", "Image3"].shuffled()
var body: some View {
VStack{
ForEach(0...2) { number in
Image(self.testImages[number])
}
}
}
}
Any help would be much appreciated.
0...2 is a type of ClosedRange, but ForEach only works with Range types.
Try changing the loop to use the ..< range syntax:
ForEach(0..<3) { number in
Image(self.testImages[number])
}
use the code below, this will count the number of elements in the array, at least you won't need to modify the number each time you add or remove elements in your array
ForEach(0..<Int(testImages.count), id:\.self) { number in
Image(self.testImages[number])
}
Please see the code below. Pressing the button once (or twice at most) is almost certain to crash the app. The app shows a list containing two sections, each of which have four items. When button is pressed, it inserts a new item into each section and also changes the section order.
I have just submitted FB9952691 to Apple. But I wonder if anyone on SO happens to know 1) Does UIKit has the same issue? I'm just curious (the last time I used UIkit was two years ago). 2) Is it possible to work around the issue in SwiftUI? Thanks.
import SwiftUI
let groupNames = (1...2).map { "\($0)" }
let groupNumber = groupNames.count
let itemValues = (1...4)
let itemNumber = itemValues.count
struct Item: Identifiable {
var value: Int
var id = UUID()
}
struct Group: Identifiable {
var name: String
var items: [Item]
var id = UUID()
// insert a random item to the group
mutating func insertItem() {
let index = (0...itemNumber).randomElement()!
items.insert(Item(value: 100), at: index)
}
}
struct Data {
var groups: [Group]
// initial data: 2 sections, each having 4 items.
init() {
groups = groupNames.map { name in
let items = itemValues.map{ Item(value: $0) }
return Group(name: name, items: items)
}
}
// multiple changes: 1) reverse group order 2) insert a random item to each group
mutating func change() {
groups.reverse()
for index in groups.indices {
groups[index].insertItem()
}
}
}
struct ContentView: View {
#State var data = Data()
var body: some View {
VStack {
List {
ForEach(data.groups) { group in
Section {
ForEach(group.items) { item in
Text("\(group.name): \(item.value)")
}
}
header: {
Text("Section \(group.name)")
}
}
}
Button("Press to crash the app!") {
withAnimation {
data.change()
}
}
.padding()
}
}
}
More information:
The error message:
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UITableView internal inconsistency: encountered out of bounds global row index while preparing batch updates (oldRow=8, oldGlobalRowCount=8)'
The issue isn't caused by animation. Removing withAnimation still has the same issue. I believe the issue is caused by the section order change (though it works fine occasionally).
Update: Thank #Yrb for pointing out an out-of-index bug in insertItem(). That function is a setup utility in the example code and is irrelevant to the issue with change(). So please ignore it.
The problem is here:
// multiple changes: 1) reverse group order 2) insert a random item to each group
mutating func change() {
groups.reverse()
for index in groups.indices {
groups[index].insertItem()
}
}
You are attempting to do too much to the array at once, so in the middle of reversing the order, the array counts are suddenly off, and the List (and it's underlying UITableView) can't handle it. So, you can either reverse the rows, or add an item to the rows, but not both at the same time.
As a bonus, this will be your next crash:
// insert a random item to the group
mutating func insertItem() {
let index = (0...itemNumber).randomElement()!
items.insert(Item(value: 100), at: index)
}
though it is not causing the above as I fixed this first. You have set a fixed Int for itemNumber which is the count of the items in the first place. Arrays are 0 indexed, which means the initial array indices will be (0...3). This line let index = (0...itemNumber).randomElement()! gives you an index that is in the range of (0...4), so you have a 20% chance of crashing your app each time this runs. In this sort of situation, always use an index of (0..<Array.count) and make sure the array is not empty.
I got Apple's reply regarding FB9952691. The issue has been fixed in iOS16 (I verified it).
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.
In UIKit we have UITableViewDataSourcePrefetching (https://andreygordeev.com/2017/02/20/uitableview-prefetching/).
I want to do something similar in SwiftUI in order to show a large collection of data, where all of it cannot be in memory at the same time.
I came up with a solution, where an invisible rectangle has an .onAppear callback which handles loading more data. The issue is that this makes the experience pretty slow, since the data isn't actually prefetched, but only loaded when we reach a point in table.
struct ContentView: View {
#ObjectBinding var elements: [Int]
var body: some View {
ForEach(elements.identified(by: \.self)) { element in
Text("\(element)")
}
Rectangle().opacity(0).onAppear {
// Add more data
}
}
}
I would like to be able to add this .onAppear event on an earlier element created by the ForEach, to actually fetch data before we have reached the end of the current data.
Another alternative I thought of was to create a custom collection, which in its subscript function loads more data if one of the last indicies is accessed. The issue with that solution is that SwiftUI access all indicies when the View is initially loaded.
You can try to use the index inside the ForEach, like here
ForEach(0..<store.repos.count) { index in
if (self.store.repos.count - index < 10) {
RepoRow(repo: self.store.repos[index])
.onAppear(perform: self.fetchNextPage)
} else {
RepoRow(repo: self.store.repos[index])
}
}