Отскок происходит при смене строк

Сбой

Я использую CoreData с NSFetchResultController для отображения данных в UITableView. У меня есть одна проблема: UITableView изменяет contentOffSet.y когда новая строка вставляется/перемещается/удаляется. Когда пользователь прокручивает, например, до середины, UITableView отскакивает, когда вставляется новая строка.

Репродукция проекта

Эта ссылка github на проект, который содержит минимальный код для воспроизведения этого поведения: https://github.com/Jasperav/FetchResultControllerGlitch (код также приведен ниже)

Это показывает глюк. Я стою в середине моего UITableView и постоянно вижу, как вставляются новые строки, независимо от текущего contentOffSet.y.:

enter image description here

Похожие вопросы

Обеспокоенность

Я также попытался переключиться на 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
    }()
}

Ответы

Ответ 1

Откройте табличное представление в раскадровке, затем щелкните инспектор атрибутов, оттуда измените стиль на "Сгруппированный" с "Обычный".

Ответ 2

Мне удалось этого добиться.

  • прекратить применение обновления, если пользователь прокручивает представление таблицы, удаляя resultController.delegate
  • перезапустите приложение, если пользователь вернулся в верхнюю часть таблицы, снова установив resultController.delegate
  • синхронизировать разницу между отключенным временем

Недостатком является отключение извлечения, также отключаются обновления или удаление существующих строк. Эти изменения будут применены после перезапуска загрузки.

Я также пытался настроить contentOffset на 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

Сделай это

tableView.bounces = false

И это будет работать

Ответ 4

Представление таблицы - сложный зверь. Он ведет себя по-разному в зависимости от его конфигурации. В табличном представлении корректируется смещение содержимого при вставке, обновлении, удалении и перемещении строк. Если табличное представление используется в контроллере табличного представления, вызывается метод делегата scrollview 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
    }()
}

Ответ 5

Шаг 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)
    }
  }
}

И это все! Это не будет делать то, что вы, когда представление полностью прокручивается до вершины, но я верю, что вы можете справиться с этим делом самостоятельно.

Ответ 6

   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() сбросьте смещение содержимого.

Вы также можете проверить этот учебник

Ответ 7

Вы можете попробовать это, отредактировав ответ pooja выше, я столкнулся с проблемой, подобной вашей, UIView.performWithoutAnimation удаляет проблему для меня. Надеюсь, это поможет.

 UIView.performWithoutAnimation {

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

РЕДАКТИРОВАТЬ

Вы также можете попробовать вышеописанное, но вместо вставки строк вы можете использовать перезагрузку данных в табличном представлении, но перед этим добавьте данные, извлеченные из источника данных, и установите последний набор содержимого в блоке.