'SwiftUI strange behavior when moving items between sections in a List
so I've been trying to make a component using swiftUI
that allows you to move items in a List
between sections.
I prepared an example with two sections: "First List" and "Second List". Whenever you tap on an item it swaps sections. Here's a screenshot:
When I tap on "First List: 1", it correctly moves to the second section:
However, its name should now be changed to "Second List: 1" because of the way I named the elements in the sections (see code below). So that's strange. But it gets stranger:
When I now tap on "First List: 1" in the second section this happens:
It doesn't properly swap back. It just gets duplicated, but this time the name of the duplicate is actually correct.
Considering the code below I don't understand how this is possible. It seems that swiftUI
somehow reuses the item, even though it re-renders the view? It also seems to reuse the .onTapGesture
closure, because the method that's supposed to put the item back into the first section is never actually called.
Any idea what's going on here? Below is a fully working example of the problem:
import SwiftUI
import Combine
struct TestView: View {
@ObservedObject var viewModel: ViewModel
class ViewModel: ObservableObject {
let objectWillChange = PassthroughSubject<ViewModel,Never>()
public enum List {
case first
case second
}
public var first: [Int] = []
public var second: [Int] = []
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
self.objectWillChange.send(self)
}
init(first: [Int]) {
self.first = first
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("First List")) {
ForEach(self.viewModel.first, id: \.self) { id in
Text("First List: \(id)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
}
Section(header: Text("First List")) {
ForEach(self.viewModel.second, id: \.self) { id in
Text("Second List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .second)
}
}
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Testing"))
}.environment(\.editMode, .constant(EditMode.active))
}
}
struct TestView_Preview: PreviewProvider {
static var previews: some View {
TestView(viewModel: TestView.ViewModel(first: [1, 2, 3, 4, 5]))
}
}
Solution 1:[1]
The only way I've solved this is to prevent diffing of the list by adding a random id to the list. This removes animations though, so looking for a better solution
List {
...
}
.id(UUID())
Removing the sections also fixes this, but isn't a valid solution either
Solution 2:[2]
I've found myself in a similar situation and have a found a more elegant workaround to this problem. I believe the issue lies with iOS13. In iOS14 the problem no longer exists. Below details a simple solution that works on both iOS13 and iOS14.
Try this:
extension Int {
var id:UUID {
return UUID()
}
}
and then in your ForEach
reference \.id
or \.self.id
and not \.self
i.e like so in both your Sections:
ForEach(self.viewModel.first, id: \.id) { id in
Text("First List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
This will make things work. However, when fiddling around I did find these issues:
- Animations were almost none existent in iOS14. This can be fixed though.
- In iOS13 the
.listStyle(GroupedListStyle())
animation looks odd. Remove this and animations look a lot better. - I haven't tested this solution on large lists. So be warned around possible performance issues. For smallish lists it works.
Once again, this is a workaround but I think Apple is still working out the kinks in SwiftUI.
Update
PS if you use any onDelete
or onMove
modifiers in iOS14 this adds animations to the list which causes odd behaviour. I've found that using \.self
works for iOS14 and \.self.id
for iOS13. The code isn't pretty because you'll most likely have #available(iOS 14.0, *)
checks in your code. But it works.
Solution 3:[3]
I don't know why, but it seems like your swap method does something weird on the first object you add, because if the second one works, maybe you've lost some instance.
By the way, do you need to removeAll every time you add a new object in each list?
public function interchange (identifier elementWithIdentifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll (where: {$ 0 == identifier})
self.second.append (identifier)
case .second:
print ("Called")
self.second.removeAll (where: {$ 0 == identifier})
self.first.append (identifier)
}
self.objectWillChange.send (self)
}
maybe your problem is in this function, everything looks great.
Solution 4:[4]
The fix is simple - use default ObservableObject
publishers (which are correctly observed by ObservedObject
wrapper) instead of Combine here, which is not valid for this case.
class ViewModel: ObservableObject {
public enum List {
case first
case second
}
@Published public var first: [Int] = [] // << here !!
@Published public var second: [Int] = [] // << here !!
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
}
init(first: [Int]) {
self.first = first
}
}
Tested with Xcode 13.3 / iOS 15.4
*and even with animation wrapping swap into withAnimation {}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | Yonas |
Solution 2 | |
Solution 3 | |
Solution 4 |