在 SwiftUI 列表中移动项目时不需要的动画

Unwanted animation when moving items in SwiftUI list

我有一个 SwiftUI 列表,如下面的示例代码所示。

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: [=10=], toOffset: )
                }
            }
            .navigationBarItems(trailing: EditButton())
        }
    }
}

当我进入编辑模式并将项目向上移动一个位置时,在我放下项目后会出现奇怪的动画(请参见下面的 gif)。看起来被拖动的项目回到了原来的位置,然后再次移动到目的地(有动画)

有趣的是,如果您将项目向下拖动到列表中或向上拖动多个位置,则不会发生这种情况。

我猜这是因为当状态中的项目被重新排序时列表执行动画,即使它们已经在视图端通过拖放重新排序。但显然它在所有情况下都处理得很好,除了将项目向上移动一个位置。

关于如何解决这个问题有什么想法吗?或者这可能是一个已知错误?

我正在使用 XCode 11.4.1,构建目标是 iOS 13.4

(另请注意,在 "real world" 应用程序中,我使用的是核心数据,当移动项目时,它们的顺序会在数据库中更新,然后状态也会更新,但动画问题看起来确实如此一样。)

这是解决方案(使用 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: [=10=], toOffset: )
            }
        }
        .navigationBarItems(trailing: EditButton())
    }
}

我有同样的问题。我不知道是否有错误,但我找到了解决方法。

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 { [=10=].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())
        }
    }
}

在 CoreData 案例中我只使用 NSFetchedResultsControllerObservedNumbers.onMove() 方法的实现如下所示:

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

在委托中:

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

这是一个基于 Mateusz K 在已接受答案中的评论的解决方案。我结合了顺序和数字的散列。我正在使用一个复杂的对象来代替动态更新的数字。这样可以确保在基础对象发生变化时刷新列表项。

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: [=10=], toOffset: )
                    }
                }
                .navigationBarItems(trailing: EditButton())
            }
        }
    }

就我而言(同样,无法解释原因,但它可能对某人有所帮助) 我改变了 ForEach(self.houses, id: \.id) { house in ... 进入

ForEach(self.houses.indices, id: \.self) { i in
    Text(self.houses[i].name)
        .id(self.houses[i].id)
}

我知道这不是导致问题作者出现问题的原因,但我确定了导致我的列表出现动画故障的原因。

我将我的 List 视图中 ForEach 视图生成的每个视图的 ID 设置为在拖放一行后将分配给另一个视图的 ID re-order 它,因为 id 是基于一个公共子字符串设置的,与给定 ForEach-iteration 产品对应的索引配对。

这是一个简单的代码片段来证明我(但不是这个问题的作者)所犯的错误:

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: [=10=], toOffset: )

                   for (index, product) in products.enumerated() {
                       product.orderNumber = Int64(index)
                   }
                }
            }
            .toolbar {
                ToolbarItem {
                    EditButton()
                }
            }
            .navigationBarTitle("\(shoppingListName) Shopping List")
        }
    }
}

如果在 运行 上面的代码中,我将索引 2 处的项目(即 ID 为“product-2”的行)重新排序为索引 0,那么我重新排序的行将开始具有先前 在索引 0 处的行所具有的 ID。 之前在索引 0 处的行将开始具有其正下方的行的 ID,依此类推。

re-assignment 的现有 ID 其他视图 同一列表 响应在该列表中重新排序的行,会 混淆 SwiftUI,并导致出现 动画故障 ,同时移动重新排序的行掉落后进入正确的新位置。

P.S。我建议研究代码的读者看看他们是否犯了同样的错误,请执行以下操作:

  1. 检查您是否根据任何值设置“行”视图的id在列表中的行之间移动”,以响应正在重新排序的行。这样的值 可能 是一个索引,但它 也可能是其他东西 ,例如 orderNumber [=72] 的值=],您存储在 NSManagedObject-Subclass 个实例中,您在 ForEach 视图中对其进行循环。

  2. 此外,请检查您是否在“行”视图上调用任何自定义视图方法,如果是,请调查是否这些自定义视图方法中没有任何一个正在为调用它们的视图设置 id<-- 我的 真实代码 就是这种情况,这让我更难发现我的错误 :P!