'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:

enter image description here

When I tap on "First List: 1", it correctly moves to the second section:

enter image description here

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:

enter image description here

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:

  1. Animations were almost none existent in iOS14. This can be fixed though.
  2. In iOS13 the .listStyle(GroupedListStyle()) animation looks odd. Remove this and animations look a lot better.
  3. 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 {}

demodemo2

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