I have a SwiftUI List like in the example code below.
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
Text(number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
When I enter edit mode and move the item one position up the strange animation happens after I drop the item (see the gif below). It looks like the dragged item comes back to its original position and then moves to the destination again (with animation)
What's interesting it doesn't happen if you drag the item down the list or more than one position up.
I guess it's because the List performs animation when the items in the state get reordered even though they were already reordered on the view side by drag and drop. But apparently it handles it well in all the cases other than moving item one position up.
Any ideas on how to solve this issue? Or maybe it's a known bug?
I'm using XCode 11.4.1 and the build target is iOS 13.4
(Please also note that in the "real world" app I'm using Core Data and when moving items their order is updated in the DB and then the state is updated, but the problem with the animation looks exactly the same.)
Here is solution (tested with Xcode 11.4 / iOS 13.4)
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
HStack {
Text(number)
}.id(UUID()) // << here !!
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
Here's a solution based on Mateusz K's comment in the accepted answer. I combined the hashing of order and number. I'm using a complex object in place of number which gets dynamically updated. This way ensures the list item refreshes if the underlying object changes.
class HashNumber : Hashable{
var order : Int
var number : String
init(_ order: Int, _ number:String){
self.order = order
self.number = number
}
static func == (lhs: HashNumber, rhs: HashNumber) -> Bool {
return lhs.number == rhs.number && lhs.order == rhs.order
}
//
func hash(into hasher: inout Hasher) {
hasher.combine(number)
hasher.combine(order)
}
}
func createHashList(_ input : [String]) -> [HashNumber]{
var r : [HashNumber] = []
var order = 0
for i in input{
let h = HashNumber(order, i)
r.append(h)
order += 1
}
return r
}
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(createHashList(numbers), id: \.self) { number in
Text(number.number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
I know that this wasn't what caused issues for the Author of this Question, but I identified what caused the animation glitch for my list.
I was setting the id of each View produced by the ForEach View within my List View, to one that would get assigned to another one of those Views after dragging and dropping a row to re-order it, as the id's were set based on a common substring, paired with the index with which the given ForEach-iteration's product corresponded.
Here's a simple code snippet to demonstrate the mistake I (but not the Author of this Question) made:
struct ContentView: View {
private let shoppingListName: String
#FetchRequest
private var products: FetchedResults<Product>
init(shoppingList: ShoppingList) {
self.shoppingListName = shoppingList.name?.capitalized ?? "Unknown"
self._products = FetchRequest(
sortDescriptors: [
.init(keyPath: \Product.orderNumber, ascending: true)
],
predicate: .init(format: "shoppingList == %#", shoppingList.objectID)
)
var body: some View {
NavigationView {
List {
ForEach(Array(products.enumerated()), id: \element.objectID) { index, product in
Text(product.name ?? "Unknown")
.id("product-\(index)") // Problematic
}
.onMove {
var products = Array(products)
products.move(fromOffsets: $0, toOffset: $1)
for (index, product) in products.enumerated() {
product.orderNumber = Int64(index)
}
}
}
.toolbar {
ToolbarItem {
EditButton()
}
}
.navigationBarTitle("\(shoppingListName) Shopping List")
}
}
}
If, when running the above code, I were to reorder item at index 2 (i.e. row with id "product-2") to index 0, then the row I'd reordered would start having the id which the row that was previously at index 0 had. And the row that was previously at index 0 would start having the id of the row directly below it, and so on and so forth.
This re-assignment of existing id's to other Views within the same list in response to a row being reordered within that list, would confuse SwiftUI, and cause there to be an animation glitch whilst the row being reordered moved into its correct new position after having been dropped.
P.S. I recommend that readers who investigate their code to see if they've made this same mistake, do the following:
Check to see if you're setting the id's of the "row" Views based on any value that can get "shifted around" amongst the rows within the list, in response to a row being reordered. Such a value could be an index, but it could also be something else, such as the value of an orderNumber property that you store in the NSManagedObject-Subclass instances over which you're looping in the ForEach View.
Also, check to see if you're calling any custom View methods on the "row" Views, and if so, investigate to see whether or not any of those custom View methods are setting id's for the View's on which they're being called. <-- This was the case in my real code, which made my mistake a bit harder for me to spot :P!
I have same problem. I do not know bug or not, but I found workaround solution.
class Number: ObservableObject, Identifiable {
var id = UUID()
#Published var number: String
init(_ number: String) {
self.number = number
}
}
class ObservedNumbers: ObservableObject {
#Published var numbers = [ Number("1"), Number("2"), Number("3") ]
func onMove(fromOffsets: IndexSet, toOffset: Int) {
var newNumbers = numbers.map { $0.number }
newNumbers.move(fromOffsets: fromOffsets, toOffset: toOffset)
for (newNumber, number) in zip(newNumbers, numbers) {
number.number = newNumber
}
self.objectWillChange.send()
}
}
struct ContentView: View {
#ObservedObject var observedNumbers = ObservedNumbers()
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(observedNumbers.numbers) { number in
Text(number.number)
}
.onMove(perform: observedNumbers.onMove)
}
.navigationBarItems(trailing: EditButton())
}
}
}
In CoreData case I just use NSFetchedResultsController. Implementation of ObservedNumbers.onMove() method looks like:
guard var hosts = frc.fetchedObjects else {
return
}
hosts.move(fromOffsets: set, toOffset: to)
for (order, host) in hosts.enumerated() {
host.orderPosition = Int32(order)
}
try? viewContext.save()
And in delegate:
internal func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any,
at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
switch type {
case .delete:
hosts[indexPath!.row].stopTrack()
hosts.remove(at: indexPath!.row)
case .insert:
let hostViewModel = HostViewModel(host: frc.object(at: newIndexPath!))
hosts.insert(hostViewModel, at: newIndexPath!.row)
hostViewModel.startTrack()
case .update:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
case .move:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
hosts[newIndexPath!.row].update(host: frc.object(at: newIndexPath!))
default:
return
}
}
In my case (again, cannot explain why but it could maybe help someone)
I changed the ForEach(self.houses, id: \.id) { house in ...
into
ForEach(self.houses.indices, id: \.self) { i in
Text(self.houses[i].name)
.id(self.houses[i].id)
}
Related
I'm encountering a strange behaviour with List when using section and either task or onAppear.
Let's say I have a list with sections and rows in each section. When I put a task to run a async task when each row appears, it doesn't call it even though it's displayed on screen. The same problem applies when using onAppear instead of task.
It's easily reproducible with the given example. You can just run and scroll down to the bottom. You'll notice that the last task isn't called despite the row and the section is on screen.
struct ContentView: View {
private var dataSource: [Int: [String]] = (0..<30).reduce([Int: [String]]()) { result, key in
var result = result
let items = (0..<4).map { "Item \($0)" }
result[key] = items
return result
}
var body: some View {
List {
ForEach(Array(dataSource.keys), id: \.self) { section in
let rows = dataSource[section]
Section {
ForEach(rows ?? [], id: \.self) { row in
Text("\(row)")
}
.task {
print("TASK \(section)")
}
} header: {
Text("Section \(section)")
}
}
}
}
}
Does anyone has an explanation ? Am I missing something ?
I managed to fix this problem by using a ScrollView which embeds a LazyVStack, but by doing so I'm loosing some of the features from List, such as swipe to delete.
.task is when the underlying UIView appears, which in the case of List is a UICollectionViewCell and those only appear once and are reused when scrolling so have already appeared so .task won't run again.
Btw ForEach is not a for loop.
I was found this solution on this way. May be help for you.
private var dataSource: [Int: [String]] = (0..<30).reduce([Int: [String]]()) { result, key in
var result = result
let items = (0..<4).map { "Item \($0)" }
result[key] = items
return result
}
var body: some View {
let arrData = dataSource.keys.sorted()
List {
ForEach(Array(arrData), id: \.self) { section in
let rows = dataSource[section]
Section {
ForEach(rows ?? [], id: \.self) { row in
Text("\(row)")
}
.task {
print("TASK \(section)")
}
} header: {
Text("Section \(section)")
}
}
}
}
}
I have a Stepper component that corresponds to number of people (for now, I don't want to restrict what the stepper goes up to, but users will likely never enter more than 10). I want to dynamically generate a box that is essentially a Person object/class based on the current value of Stepper.
Current code
struct ContentView: View {
#State private var numPeople = 0
var body: some View {
VStack {
Text("Enter number of people below:")
Stepper("\(numPeople.formatted()) People", value: $numPeople)
}
}
Desired outcome if user clicks plus button until the number 3.
Should this be done using a for loop (or ForEach loop)? Should a component other than Stepper be used?
There are a few different ways of doing this but for growth sake you can consider a PersonModel and basing the stepper on the count of Array<PersonModel> using a computed variable that adds and removes from the array.
import SwiftUI
struct PersonModel: Identifiable{
let id: UUID = .init()
let name: String
}
class PersonVM: ObservableObject{
#Published var people: [PersonModel] = []
var personCount: Int{
get{
//Single source of truth.
people.count
}
set{
let diff: Int = people.count - newValue
if diff == 0{
//No change
} else if diff < 0 { //Add the difference
let new: [PersonModel] = (0..<abs(diff)).map{ n in
PersonModel(name: "Person \(people.count + n + 1)")
}
people.append(contentsOf: new)
} else if !people.isEmpty{ //Remove the difference if the array isn't empty
people.removeLast(diff)
}
}
}
}
#available(iOS 15.0, *)
struct PersonView: View {
#StateObject private var vm: PersonVM = .init()
var body: some View {
ScrollView {
Text("Enter number of people below:")
Stepper("\(vm.people.count.formatted()) People", value: $vm.personCount, in: 0...10, step: 1)
ForEach(vm.people) { person in
ZStack{
Color.blue
Text(person.name)
.foregroundColor(.white)
}.padding(2)
}
}
}
}
i am a newbie in IOS development, so I wanted to do something like when the user scroll and reaches the bottom of the list, the list updates and append it data. basically an infinite scrolling.
But I have no idea other than setting it via engineering the offset, it would take a lot of time and effort + might not be that clean/brilliant solution. Are there any other solution other than using a 3rd person library?
Thank you
here's my code :
ForEach(homeContent.MainContent){data in
homeContentItem(name: data.name ?? "", image: data.image ?? "", released: data.released ?? "")
.padding(.horizontal)
.onAppear{
homeContent.appendData(currentItem: data)
}
}
What this code does is well infinite scrolling, but it'll always update and that is something that I don't want to, I want it when user scroll to the bottom first in order to update the list
Note : if there any similar question, please do comment it so that I could delete this one I guess
You were close with onAppear, you only missing checking index of item. You need to append new data only when last element is visible, not for anyone. I suggest you using ForEachIndexed:
struct ContentView: View {
#State
var items = (0...100).map { _ in Item() }
var body: some View {
ScrollView {
LazyVStack {
ForEachIndexed(items) { i, item in
Text("\(i) \(item.id)")
.onAppear {
if i == items.indices.last {
items += (0...10).map { _ in Item() }
print("new items added", i, items.count)
}
}
}
}
}
}
}
struct ForEachIndexed<Data: RandomAccessCollection, Content: View, ID: Hashable>: View {
let data: [EnumeratedSequence<Data>.Element]
let content: (Int, Data.Element) -> Content
let id: KeyPath<EnumeratedSequence<Data>.Element, ID>
init(_ data: Data, #ViewBuilder content: #escaping (Int, Data.Element) -> Content) where Data.Element: Identifiable, ID == Data.Element.ID {
self.data = Array(data.enumerated())
self.content = content
self.id = \.element.id
}
var body: some View {
ForEach(data, id: id) { element in
content(element.offset, element.element)
}
}
}
struct Item: Identifiable {
let id: String
init() {
id = UUID().uuidString
}
}
I'm not sure what the problem is with my project. Basically, I have a pretty typical data structure: I'm using Firestore for a real-time doc based database, and I have a bunch of different collections and docs and fields. Just very simple, nothing crazy.
I have some model classes, and then some ViewModels to fetch data, filter, add documents to Firestore, and so on.
The app itself is almost 100% SwiftUI, and I'd like to keep it that way, just a challenge for my own development. I've hit a bit of a wall though.
In the app, I have a series of Views with NavigationView Lists that I pass small pieces of data to as you navigate. An example (this is for educational institutions) might be List of Schools > List of Classes > List of Students in Class > List of Grades for Student. Basically the perfect use for a navigation view and a bunch of lists.
The bug:
When I move from one list to the next in the stack, I fetch the firestore data, which loads for a second (enough that the list populates), and then "unloads" back to nothing. Here is some code where this happens (I've cleaned it up to make it as simple as possible):
struct ClassListView: View {
let schoolCode2: String
let schoolName2: String
#ObservedObject private var viewModelThree = ClassViewModel()
var body: some View {
VStack{
List{
if viewModelThree.classes.count > 0{
ForEach(self.viewModelThree.classes) { ownedClass in
NavigationLink(destination: StudentListView()){
Text(ownedClass.className)
}
}
} else {
Text("No Classes Found for \(schoolName2)")
}
}
}
.navigationBarTitle(schoolName2)
.onAppear(){
print("appeared")
self.viewModelThree.fetchData(self.schoolCode2)
}
}
}
So that's the ClassListView that I keep having issues with. For debugging, I added the else Text("No Classes Found") line and it does in fact show. So basically, view loads (this is all in a Nav view from a parent), it fetches the data, which is shown for a second (list populates) and then unloads that data for some reason, leaving me with just the "No classes found".
For more context, here is the code for the ClassViewModel (maybe that's where I'm going wrong?):
struct Classes: Identifiable, Codable {
#DocumentID var id: String? = UUID().uuidString
var schoolCode: String
var className: String
}
enum ClassesCodingKeys: String, CodingKey {
case id
case schoolCode
case className
}
class ClassViewModel: ObservableObject {
#Published var classes = [Classes]()
private var db = Firestore.firestore()
func fetchData(_ schoolCode: String) {
db.collection("testClasses")
.order(by: "className")
.whereField("schoolCode", isEqualTo: schoolCode)
.addSnapshotListener{ (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("no docs found")
return
}
self.classes = documents.compactMap{ queryDocumentSnapshot -> Classes? in
return try? queryDocumentSnapshot.data(as: Classes.self)
}
}
}
func addClass(currentClass: Classes){
do {
let _ = try db.collection("testClasses").addDocument(from: currentClass)
}
catch {
print(error)
}
}
}
Most relevant bit is the fetchData() function above.
Maybe the problem is in the view BEFORE this (the parent view?). Here it is:
struct SchoolUserListView: View {
#State private var userId: String?
#EnvironmentObject var session: SessionStore
#ObservedObject private var viewModel = UserTeacherViewModel()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State private var counter = 0
#State private var showingAddSchool: Bool = false
#Environment(\.presentationMode) var presentationMode
func getUser() {
session.listen()
}
var body: some View {
VStack{
List{
if viewModel.teachers.count > 0{
ForEach(viewModel.teachers) { ownedClass in
NavigationLink(destination: ClassListView(schoolName2: ownedClass.schoolName, schoolCode2: ownedClass.schoolCode)){
Text(ownedClass.schoolName)
}
}
} else {
Button(action:{
self.presentationMode.wrappedValue.dismiss()
}){
Text("You have no schools, why not add one?")
}
}
}
.navigationBarTitle("Your Schools")
}
.onAppear(){
self.getUser()
self.userId = self.session.session?.uid
}
.onReceive(timer){ time in
if self.counter == 1 {
self.timer.upstream.connect().cancel()
} else {
print("fetching")
self.viewModel.fetchData(self.userId!)
}
self.counter += 1
}
}
}
And the FURTHER Parent to that View (and in fact the starting view of the app):
struct StartingView: View {
#EnvironmentObject var session: SessionStore
func getUser() {
session.listen()
}
var body: some View {
NavigationView{
VStack{
Text("Welcome!")
Text("Select an option below")
Group{
NavigationLink(destination:
SchoolUserListView()
){
Text("Your Schools")
}
NavigationLink(destination:
SchoolCodeAddView()
){
Text("Add a School")
}
Spacer()
Button(action:{
self.session.signOut()
}){
Text("Sign Out")
}
}
}
}
.onAppear(){
self.getUser()
}
}
}
I know that is a lot, but just so everyone has the order of parents:
StartingView > SchoolUserListView > ClassListView(BUG occurs here)
By the way, SchoolUserListView uses a fetchData method just like ClassListView(where the bug is) and outputs it to a foreach list, same thing, but NO problems? I'm just not sure what the issue is, and any help is greatly appreciated!
Here is a video of the issue:
https://imgur.com/a/kYtow6G
Fixed it!
The issue was the EnvironmentObject for presentationmode being passed in the navigation view. That seems to cause a LOT of undesired behavior.
Be careful passing presentationMode as it seems to cause data to reload (because it is being updated).
I'm facing a strange behavior using a static List in SwiftUI. I can't determine if it's a SwiftUI bug or something I'm doing wrong. I have a very simple List that looks like this :
var body: some View {
List {
SettingsPickerView<TrigonometryUnit>(title: "Trigonometry Units", selection: $viewModel.trigonometryUnitIndex, items: TrigonometryUnit.allCases)
SettingsPickerView<DecimalSeparator>(title: "Decimal Separator", selection: $viewModel.decimalSeparatorIndex, items: DecimalSeparator.allCases)
SettingsPickerView<GroupingSeparator>(title: "Grouping Separator", selection: $viewModel.groupingSeparatorIndex, items: GroupingSeparator.allCases)
SettingsPickerView<ExponentSymbol>(title: "Exponent Symbol", selection: $viewModel.exponentSymbolIndex, items: ExponentSymbol.allCases)
}
}
Each cell of the List looks like this :
struct SettingsPickerView<T: Segmentable>: View {
let title: String
#Binding var selection: Int
let items: [T]
var body: some View {
Section(header: Text(title)) {
ForEach(items.indices) { index in
self.cell(for: self.items[index], index: index)
}
}
}
private func cell(for item: T, index: Int) -> some View {
print(title, item.title, items.map({ $0.title }))
return Button(action: {
self.selection = index
}, label: {
HStack {
Text(item.title)
Spacer()
if index == self.selection {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.rpnCalculatorOrange)
}
}
})
}
}
And finally, this is what a Segmentable object looks like:
enum GroupingSeparator: Int, CaseIterable {
case defaultSeparator
case space
case comma
}
extension GroupingSeparator: Segmentable {
var id: String {
switch self {
case .defaultSeparator:
return "groupingSeparator.default"
case .space:
return "groupingSeparator.space"
case .comma:
return "groupingSeparator.comma"
}
}
var title: String {
switch self {
case .defaultSeparator:
return "Default"
case .space:
return "Space"
case .comma:
return "Comma"
}
}
}
When the SettingsView is loaded. everything looks fine. But as soon as I start scrolling, and some other cells are instantiated, there are some cell displayed, but not the proper ones. Here is some screenshots and logs.
When the view is loaded, no scrolling, here is what the screen looks like:
But, what I got on the console is pretty weird and doesn't follow the order of the SettingsPickerView written in the main View:
Trigonometry Units Radians ["Radians", "Degrees"] <-- Fine
Trigonometry Units Degrees ["Radians", "Degrees"] <-- Fine
Decimal Separator Default ["Default", "Dot", "Comma"] <-- Fine
Decimal Separator Default ["Default", "Dot", "Comma"] <-- Fine
Trigonometry Units Degrees ["Radians", "Degrees"] <-- Not expected. Should be Grouping Separator
Trigonometry Units Radians ["Radians", "Degrees"] <-- Not expected. Should be Grouping Separator
The second section is ok and properly displayed:
But the third section is completely broken:
The third section displays its title properly, but display some of the data of the first section. I tried to add an identifier to the button in the cell because the issue looks like SwiftUI can't identify the proper data. But adding an identifier to the button broke the binding, and the checkbox don't change anymore.
private func cell(for item: T, index: Int) -> some View {
print(title, item.title, items.map({ $0.title }))
return Button(action: {
self.selection = index
}, label: {
HStack {
Text(item.title)
Spacer()
if index == self.selection {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.rpnCalculatorOrange)
}
}
})
.id(UUID().uuidString) // This solve the display issue but broke the binding.
}
Does someone experienced something like this before ?
Thanks in advance for your help.
Here is fixed block of code (due to used indexes only List is confused and reuses rows, so solution is to make rows identifiable by items).
Tested with Xcode 11.4
struct PickerView<T: Segmentable>: View {
// ... other code here
var body: some View {
Section(header: Text(title)) {
// Corrected section construction !!
ForEach(Array(items.enumerated()), id: \.element.id) { index, _ in
self.cell(for: self.items[index], index: index)
}
}
}
// ... other code here