代码之家  ›  专栏  ›  技术社区  ›  J. Doe

更改行时发生反弹

  •  15
  • J. Doe  · 技术社区  · 7 年前

    差错

    CoreData 用一个 NSFetchResultController 使数据显示在 UITableView . 我有一个问题,那就是 UITableView 更改 contentOffSet.y 插入/移动/删除新行时。当用户滚动到(例如中间)时 UITableView 插入新行时反弹。

    此github链接到一个项目,该项目包含复制此行为的最少代码: https://github.com/Jasperav/FetchResultControllerGlitch

    这显示出故障。我站在我的身体中间 UITableView 内容偏移量.y .:

    enter image description here

    类似问题

    关注

    我也试着换成 performBatchUpdates begin/endUpdates ,但也没有成功。

    这个 UITableView 这些行对用户不可见 . 我想这样的事情应该是现成的。

    最终目标

    • 当用户完全滚动到插入新行的顶部(对于WhatsApp,这是底部)时 UITableView 内容偏移量.y .
    • 当用户没有完全滚动到顶部(或底部,取决于插入新行的位置)时,插入新行时,用户看到的单元格不应反弹。这对应用程序的用户体验非常不利。
    • 我在移动/删除单元格时也看到这种行为。有什么简单的方法可以解决所有的问题吗?

    如果 UICollectionView 会更合适,这样就可以了。

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

    复制粘贴代码

    代码(视图控制器.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
        }()
    }
    
    6 回复  |  直到 6 年前
        1
  •  1
  •   Pooja Kamath    7 年前
       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
  •  0
  •   taka    7 年前

    • 如果用户向下滚动表视图,则通过删除结果控制器.delegate
    • 如果用户返回到表视图的顶部,则通过设置结果控制器.delegate再一次
    • 禁用时间之间的同步差异

    这些更改将在fetch重新启动后应用。

    我也试着调整一下 controller(_:didChange:at:for:newIndexPath:) 但根本没用。

    代码如下。

    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
        }()
    }
    
        3
  •  0
  •   Nishant Pathania    7 年前

    做这个

    tableView.bounces = false
    

    它会起作用的

        4
  •  0
  •   Jon Rose    7 年前

    第一步:定义“不动”的含义。对人类来说,很明显它是在跳跃。但是计算机看到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

    下一个让我们打电话 刚毛点

     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
  •  0
  •   Shivam Gaur    7 年前

    你可以试试这个,这是一个编辑上面普亚的答案,我已经面临的问题一样,你的 UIView.PerformWithout动画

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

    编辑

    您也可以尝试上面的方法,但不必插入行,您可以在tableview上使用reload data,但在此之前,请将提取的数据附加到数据源,并在块内设置最后一个contentOffset。

        6
  •  0
  •   Ralf    7 年前

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

    为了解决这个问题,我重建了你的代码。您的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
        }()
    }