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

Posted

技术标签:

【中文标题】在 SwiftUI 列表中移动项目时不需要的动画【英文标题】:Unwanted animation when moving items in SwiftUI list 【发布时间】:2020-05-03 08:10:41 【问题描述】:

我有一个 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: $0, toOffset: $1)
                
            
            .navigationBarItems(trailing: EditButton())
        
    

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

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

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

关于如何解决这个问题的任何想法?或者它可能是一个已知的错误?

我使用的是 XCode 11.4.1,构建目标是 ios 13.4

(还请注意,在“现实世界”应用程序中,我使用的是 Core Data,当移动项目时,它们的顺序在数据库中更新,然后状态也更新,但动画问题看起来完全一样。 )

【问题讨论】:

【参考方案1】:

这里是解决方案(使用 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())
    

【讨论】:

谢谢,它有效!尽管看起来我已经过分简化了我的示例,并且在“真实”应用程序中它会导致更奇怪的动画(每次更改顺序时替换列表中的所有元素)。在我的应用程序中,我有基于“订单”属性排序的列表项目。然而,它给了我一个玩 ids 的线索,我找到了可行的解决方案。它是使用我的“订单”属性作为这样的 id:ForEach(numbers, id: \.order) 现在它可以工作了:) 仍然想知道为什么它只在向上移动一个项目时不起作用,如果它是一个 SwiftUI 错误......再次感谢!【参考方案2】:

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

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

在 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
        
    

【讨论】:

【参考方案3】:

这是基于 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: $0, toOffset: $1)
                    
                
                .navigationBarItems(trailing: EditButton())
            
        
    

【讨论】:

【参考方案4】:

就我而言(同样,无法解释原因,但它可能对某人有所帮助) 我改变了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)

【讨论】:

【参考方案5】:

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

我将ForEach 视图在我的List 视图中生成的每个视图的ID 设置为在拖放一行以重新排序后将分配给这些视图中的另一个的ID,如id 是基于公共子字符串设置的,与给定 ForEach 迭代产品对应的索引配对。

这是一个简单的代码 sn-p 来演示我(但不是这个问题的作者)犯的错误:

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

如果在运行上述代码时,我要将索引 2 处的项目(即 id 为“product-2”的行)重新排序为索引 0,那么我重新排序的行将开始具有该行的 id 以前在索引 0 处。并且之前在索引 0 处的行将开始具有其正下方的行的 id,依此类推。

将现有 id 的重新分配给 同一列表内的其他视图,以响应在该列表中重新排序的行,将混淆 SwiftUI,并导致在被重新排序的行在被删除后移动到正确的新位置时出现动画故障

附:我建议读者调查他们的代码以查看他们是否犯了同样的错误,请执行以下操作:

    作为响应到正在重新排序的行。这样的值可以是一个索引,但它也可以是其他东西,例如您存储在 NSManagedObject-Subclass 实例中的 orderNumber 属性的值你在 ForEach 视图中循环。

    另外,检查您是否在“行”视图上调用任何自定义 View 方法,如果是,请调查是否有任何自定义 View 方法设置了 @ 987654327@ 用于调用它们的视图。 真实代码就是这种情况,这让我更难发现错误:P!

【讨论】:

以上是关于在 SwiftUI 列表中移动项目时不需要的动画的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI找回丢失的列表视图(List)动画

SwiftUI找回丢失的列表视图(List)动画

调整大小时的 SwiftUI 文本剪辑

是啥导致 SwiftUI 嵌套的 View 项目在初始绘制后出现跳跃动画?

SwiftUI - 在列表中的视图内触发的动画也不会为列表设置动画

SwiftUI:动画列表中的圆圈