简体   繁体   中英

Unwanted animation when moving items in SwiftUI list

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())
    }
}

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 (ie 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.

PS I recommend that readers who investigate their code to see if they've made this same mistake, do the following:

  1. 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.

  2. 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
        }
    }

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())
            }
        }
    }

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)
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM