【问题标题】:Bounce occurs when changing rows更改行时发生弹跳
【发布时间】:2023-03-12 20:51:01
【问题描述】:

故障

我正在使用CoreDataNSFetchResultController 来让数据显示在UITableView 中。我有一个问题:UITableView 在插入/移动/删除新行时更改了contentOffSet.y。当用户滚动到时,例如中间,UITableView 在插入新行时弹跳。

复制项目

此 github 链接指向包含重现此行为的最少代码的项目:https://github.com/Jasperav/FetchResultControllerGlitch(代码也在下方)

这是显示故障。我站在我的UITableView 中间,我不断地看到新行被插入,不管当前的contentOffSet.y。:

类似问题

担忧

我也尝试切换到performBatchUpdates 而不是begin/endUpdates,但也没有成功。

UITableView用户看不到这些行时插入/删除/移动行时不应该移动。我希望这样的事情应该开箱即用。

最终目标

这就是我最终想要的(只是复制 WhatsApp 的聊天屏幕):

  • 当用户完全滚动到插入新行的顶部(对于 WhatsApp,这是底部),UITableView 应该为新插入的行设置动画并更改当前的contentOffSet.y
  • 当用户没有完全滚动到顶部(或底部,取决于插入新行的位置)时,用户看到的单元格不应在插入新行时反弹。这对应用程序的用户体验非常不利。
  • 它应该适用于动态高度单元格。
  • 我在移动/删除单元格时也看到了这种行为。这里的所有故障都有什么简单的解决方法吗?

如果UICollectionView 更合适,那很好。

用例

我正在尝试复制 WhatsApp 聊天屏幕。我不确定他们是否使用 NSFetchResultController,但除此之外,最终目标是为他们提供准确的用户体验。因此,插入、移动、删除和更新单元格应该按照 WhatsApp 的方式进行。因此,对于一个工作示例:转到 WhatsApp,对于一个不工作示例:下载项目。

复制粘贴代码

代码(ViewController.swift):

import CoreData
import UIKit

class ViewController: UIViewController, NSFetchedResultsControllerDelegate, UITableViewDataSource, UITableViewDelegate {
    
    let tableView = MyTableView()
    let resultController = ViewController.createResultController()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Initial cells
        for i in 0...40 {
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
            
            x.something = randomString(length: i + 1)
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }
        
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
            
            x.something = self.randomString(length: Int.random(in: 10...50))
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }
        
        resultController.delegate = self
        
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
        
        tableView.delegate = self
        tableView.dataSource = self
        tableView.estimatedRowHeight = 75
        
        
        try! resultController.performFetch()
    }
    
    public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .delete:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
        case .move:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .update:
            tableView.moveRow(at: indexPath!, to: newIndexPath!)
        }
    }
    
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.beginUpdates()
    }
    
    public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return resultController.fetchedObjects?.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return CGFloat(resultController.object(at: indexPath).height)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyTableViewCell
        
        cell.textLabel?.text = resultController.object(at: indexPath).something

        return cell
    }

    
    private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
        let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()
        
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
        
        return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
    }
    
    func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0...length-1).map{ _ in letters.randomElement()! })
    }
}

class MyTableView: UITableView {
    init() {
        super.init(frame: .zero, style: .plain)
        
        register(MyTableViewCell.self, forCellReuseIdentifier: "cell")
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MyTableViewCell: UITableViewCell {
    
}

class CoreDataContext {
    static let persistentContainer: NSPersistentContainer =  {
        let container = NSPersistentContainer(name: "FetchViewControllerGlitch")
        
        container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
            guard let error = error else {
                return
            }
            fatalError(error.localizedDescription)
        })
        
        return container
    }()
}

【问题讨论】:

  • UIKit 太糟糕了。 :( 有时甚至不可能用标准组件使简单的事情工作。
  • 只要玩contentOffset,当你添加新行时,获取新行的高度并更新tableview的contentoffset以保持tableview的位置相同。确保你保持动画=假

标签: ios swift uitableview core-data nsfetchedresultscontroller


【解决方案1】:
   let lastScrollOffset = tableView.contentOffset;
   tableView.beginUpdates();
   tableView.insertRows(at: [newIndexPath!], with: .automatic);
   tableView.endUpdates();
   tableView.layer.removeAllAnimations();
   tableView.setContentOffset(lastScrollOffset, animated: false);
  1. 尽最大努力为所有表格单元格类型建立估计高度。即使高度有些动态,这也有助于 UITableView。

  2. 保存滚动位置并在更新 tableView 并调用 endUpdates() 后重置内容偏移量。

您也可以查看tutorial

【讨论】:

    【解决方案2】:

    我已经做到了。

    • 如果用户向下滚动表格视图,则通过删除 resultController.delegate 停止应用更新
    • 如果用户回到表格视图的顶部,通过再次设置 resultController.delegate 重新开始应用
    • 禁用时间之间的同步差异

    缺点是禁用提取也禁用更新或删除现有行。 这些更改将在 fetch 重新启动后应用。

    我也尝试在controller(_:didChange:at:for:newIndexPath:) 上调整 contentOffset,但它根本不起作用。

    代码如下。

    import CoreData
    import UIKit
    
    class ViewController: UIViewController, NSFetchedResultsControllerDelegate, UITableViewDataSource, UITableViewDelegate, UIScrollViewDelegate {
    
        let tableView = MyTableView()
        let resultController = ViewController.createResultController()
        var needsSync = false
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Initial cells
            for i in 0...40 {
                let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
    
                x.something = randomString(length: i + 1)
                x.date = Date()
                x.height = Float.random(in: 50...100)
            }
    
            Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
                let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
    
                x.something = self.randomString(length: Int.random(in: 10...50))
                x.date = Date()
                x.height = Float.random(in: 50...100)
            }
    
            resultController.delegate = self
    
            view.addSubview(tableView)
            tableView.translatesAutoresizingMaskIntoConstraints = false
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    
            tableView.delegate = self
            tableView.dataSource = self
            tableView.estimatedRowHeight = 75
    
    
            try! resultController.performFetch()
        }
    
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let threshold = CGFloat(100)
            if scrollView.contentOffset.y > threshold && resultController.delegate != nil {
                resultController.delegate = nil
            }
            if scrollView.contentOffset.y <= threshold && resultController.delegate == nil {
                resultController.delegate = self
                needsSync = true
                try! resultController.performFetch()
            }
        }
    
        public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
            switch type {
            case .insert:
                tableView.insertRows(at: [newIndexPath!], with: .automatic)
            case .delete:
                tableView.deleteRows(at: [indexPath!], with: .automatic)
            case .move:
                tableView.deleteRows(at: [indexPath!], with: .automatic)
                tableView.insertRows(at: [newIndexPath!], with: .automatic)
            case .update:
                tableView.moveRow(at: indexPath!, to: newIndexPath!)
            }
        }
    
        func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
            if needsSync {
                tableView.reloadData()
            }
            tableView.beginUpdates()
        }
    
        public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
            if needsSync {
                needsSync = false
            }
            tableView.endUpdates()
        }
    
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return resultController.fetchedObjects?.count ?? 0
        }
    
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            return CGFloat(resultController.object(at: indexPath).height)
        }
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyTableViewCell
    
            cell.textLabel?.text = resultController.object(at: indexPath).something
    
            return cell
        }
    
    
        private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
            let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()
    
            fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
    
            return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
        }
    
        func randomString(length: Int) -> String {
            let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
            return String((0...length-1).map{ _ in letters.randomElement()! })
        }
    }
    
    class MyTableView: UITableView {
        init() {
            super.init(frame: .zero, style: .plain)
    
            register(MyTableViewCell.self, forCellReuseIdentifier: "cell")
        }
    
        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    
    class MyTableViewCell: UITableViewCell {
    
    }
    
    class CoreDataContext {
        static let persistentContainer: NSPersistentContainer =  {
            let container = NSPersistentContainer(name: "FetchViewControllerGlitch")
    
            container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
                guard let error = error else {
                    return
                }
                fatalError(error.localizedDescription)
            })
    
            return container
        }()
    }
    

    【讨论】:

    • 我不认为这“太老套”,但是,实际上不是简单的解决方案。这个故障似乎是因为 contentHeight 是通过插入行来改变的,但 contentOffset 不是。因此,即使用户不在表格视图的顶部,内容也会向下移动。因此,最直接的方法是在插入行后立即添加 contentOffset,但它在我的环境中不起作用(新的手动设置的 contentOffset 被忽略)。我目前没有足够的时间进行进一步调查,但也许这会成为一些暗示。
    • 好的,提到的要求之一是移动/删除/更新单元格应该继续工作(请参阅最终目标部分),但这个答案并非如此:(
    【解决方案3】:

    这样做

    tableView.bounces = false
    

    它会起作用的

    【讨论】:

    【解决方案4】:

    第 1 步:定义“不移动”的含义。对于人类来说,很明显它在跳跃。但是计算机发现 contentOffset 保持不变。因此,让我们非常精确地定义第一个具有可见顶部的单元格在更改后应该准确地保持在它的位置。所有其他单元格都可以移动,但这是我们的锚点。

    var somethingIdOfAnchorPoint:String?
    var offsetAnchorPoint:CGFloat?
    
    func findHighestCellThatStartsInFrame() -> UITableViewCell? {
      var anchorCell:UITableViewCell?
      for cell in self.tableView.visibleCells {
        let topIsInFrame = cell.frame.origin.y >= self.tableView.contentOffset.y
        if topIsInFrame {
    
          if let currentlySelected = anchorCell{
            let isHigerUpInView = cell.frame.origin.y < currentlySelected.frame.origin.y
            if  isHigerUpInView {
              anchorCell = cell
            }
          }else{
            anchorCell = cell
    
          }
        }
      }
      return anchorCell
    }
    
    func setAnchorPoint() {
      self.somethingIdOfAnchorPoint = nil;
      self.offsetAnchorPoint = nil;
    
      if let cell = self.findHighestCellThatStartsInFrame() {
        self.offsetAnchorPoint = cell.frame.origin.y - self.tableView.contentOffset.y
        if let indexPath = self.tableView.indexPath(for: cell) {
          self.somethingIdOfAnchorPoint = resultController.object(at: indexPath).something
        }
      }
    }
    

    当我们调用setAnchorPoint 时,我们会找到并记住哪个实体(不是indexPath,因为这可能会很快改变)靠近顶部以及它离顶部的确切距离。

    接下来让我们在更改发生之前致电setAnchorPoint

     func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
          self.setAnchorPoint()
          tableView.beginUpdates()
      }
    

    在更改完成后,我们滚动回我们假设没有任何动画的位置:

    public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
        self.tableView.layoutSubviews()
        self.scrollToAnchorPoint()
    }
    
    func scrollToAnchorPoint() {
      if let somethingId = somethingIdOfAnchorPoint, let offset = offsetAnchorPoint {
        if let item = resultController.fetchedObjects?.first(where: { $0.something == somethingId }),
          let indexPath = resultController.indexPath(forObject: item) {
            let rect = self.tableView.rectForRow(at: indexPath)
            let contentOffset = rect.origin.y - offset
            self.tableView.setContentOffset(CGPoint.init(x: 0, y: contentOffset), animated: false)
        }
      }
    }
    

    就是这样!当视图完全滚动到顶部时,这不会执行您的操作,但我相信您可以自己处理这种情况。

    【讨论】:

      【解决方案5】:

      您可以尝试这是对上述 pooja 答案的编辑,我遇到了像您这样的问题 UIView.performWithoutAnimation 为我解决了这个问题。希望它有所帮助。

       UIView.performWithoutAnimation {
      
              let lastScrollOffset = tableView.contentOffset;
              tableView.beginUpdates();
              tableView.insertRows(at: [newIndexPath!], with: .automatic);
              tableView.endUpdates();
              tableView.setContentOffset(lastScrollOffset, animated: false); 
          }
      

      编辑

      您也可以尝试上述方法,但您可以在 tableview 上使用重新加载数据而不是插入行,但在此之前将获取的数据附加到您的数据源,并在块内设置最后一个 contentoffset。

      【讨论】:

        【解决方案6】:

        表格视图是一个复杂的野兽。它的行为取决于其配置。表格视图在插入、更新、删除和移动行时调整内容偏移量。如果在表视图控制器中使用表视图,则调用滚动视图委托方法 scrollViewDidScroll(_:)。

        解决方法是撤销那里的内容偏移调整。但是,这违背了表格视图的意图,因此需要多次执行,直到调用 viewDidLayoutSubviews()。因此,该解决方案不是最佳解决方案,但它适用于动态高度单元格、节页眉、节页脚,并且应该符合您的目标。

        对于解决方案,我重新构建了您的代码。您的 ViewController 不再基于 UIViewController 而是基于 UITableViewController。解决方案的本质部分是属性fixUpdateContentOffset的处理和使用。

        import CoreData
        import UIKit
        
        class ViewController: UITableViewController, NSFetchedResultsControllerDelegate {
        
            let resultController = ViewController.createResultController()
        
            private var fixUpdateContentOffset: CGPoint?
        
            override func viewDidLoad() {
                super.viewDidLoad()
        
                // Initial cells
                for i in 0...40 {
                    let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
        
                    x.something = randomString(length: i + 1)
                    x.date = Date()
                    x.height = Float.random(in: 50...100)
                }
        
                Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
                    let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)
        
                    x.something = self.randomString(length: Int.random(in: 10...50))
                    x.date = Date()
                    x.height = Float.random(in: 50...100)
                }
        
                resultController.delegate = self
        
                tableView.estimatedRowHeight = 75
        
                try! resultController.performFetch()
            }
        
            override func viewDidLayoutSubviews() {
                super.viewDidLayoutSubviews()
        
                fixUpdateContentOffset = nil
            }
        
            override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
                fixUpdateContentOffset = nil
            }
        
            override func scrollViewDidScroll(_ scrollView: UIScrollView) {
                if let fixUpdateContentOffset = fixUpdateContentOffset,
                    tableView.contentOffset.y.rounded(.toNearestOrAwayFromZero) != fixUpdateContentOffset.y.rounded(.toNearestOrAwayFromZero) {
                    tableView.contentOffset = fixUpdateContentOffset
                }
            }
        
            public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
                switch type {
                case .insert:
                    tableView.insertRows(at: [newIndexPath!], with: .automatic)
                case .delete:
                    tableView.deleteRows(at: [indexPath!], with: .automatic)
                case .move:
                    tableView.deleteRows(at: [indexPath!], with: .automatic)
                    tableView.insertRows(at: [newIndexPath!], with: .automatic)
                case .update:
                    tableView.moveRow(at: indexPath!, to: newIndexPath!)
                }
            }
        
            func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
                fixUpdateContentOffset = tableView.contentOffset
                tableView.beginUpdates()
            }
        
            public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
                tableView.endUpdates()
                fixUpdateContentOffset = tableView.contentOffset
            }
        
            override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
                return resultController.fetchedObjects?.count ?? 0
            }
        
            override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
                return CGFloat(resultController.object(at: indexPath).height)
            }
        
            override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
                let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        
                cell.textLabel?.text = resultController.object(at: indexPath).something
        
                return cell
            }
        
        
            private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
                let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()
        
                fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
        
                return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
            }
        
            func randomString(length: Int) -> String {
                let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
                return String((0...length-1).map{ _ in letters.randomElement()! })
            }
        }
        
        class CoreDataContext {
            static let persistentContainer: NSPersistentContainer =  {
                let container = NSPersistentContainer(name: "FetchViewControllerGlitch")
        
                container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
                    guard let error = error else {
                        return
                    }
                    fatalError(error.localizedDescription)
                })
        
                return container
            }()
        }
        

        【讨论】:

        • 我无法让它与 UIViewController 一起工作,这是必须的。有办法吗?如果你让它工作,我奖励 500 美元
        【解决方案7】:

        这是 Telegram、WhatsApp 或其他信使等消息列表的典型问题。 如果你使用 UICollectionView,你可以考虑使用StableCollectionViewLayout

        它使用 UICollectionViewLayout 子类来解决这个问题。

        UICollectionViewLayout 有一些方法可以提供帮助:

        override open func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
            super.prepare(forCollectionViewUpdates: updateItems)
        
            // there is possible to calculate a content offset the difference
            // with the help layout attributes for each updated item or only visible items
            self.offset = calculate(...)
        }
        
        override open func finalizeCollectionViewUpdates() {
            super.finalizeCollectionViewUpdates()
            self.offset = nil
        }
        
        override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
            // there is necessary to add difference to/instead proposedContentOffset
            if let offset = self.offset {
               return offset
            }
            return proposedContentOffset
        }
        

        这个解决方案有很多水下岩石,但它非常孤立。 CollectionViewLayout 中会发生一切神奇的事情。 它将允许仅使用插入/重新加载/删除 UICollectionView 方法。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2014-06-26
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2022-01-11
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多