Ответ 1
Откройте табличное представление в раскадровке, затем щелкните инспектор атрибутов, оттуда измените стиль на "Сгруппированный" с "Обычный".
Я использую CoreData
с NSFetchResultController
для отображения данных в UITableView
. У меня есть одна проблема: UITableView
изменяет contentOffSet.y
когда новая строка вставляется/перемещается/удаляется. Когда пользователь прокручивает, например, до середины, UITableView
отскакивает, когда вставляется новая строка.
Эта ссылка github на проект, который содержит минимальный код для воспроизведения этого поведения: https://github.com/Jasperav/FetchResultControllerGlitch (код также приведен ниже)
Это показывает глюк. Я стою в середине моего UITableView
и постоянно вижу, как вставляются новые строки, независимо от текущего contentOffSet.y
.:
Как предотвратить прокрутку UITableView вверх, когда NSFetchedResultsController добавляет новую запись? не имеет значения, так как я явно установил rowHeight
и estimatedRowHeight
rowHeight
.
Ошибка: UITableView перейти наверх с UITableViewAutomaticDimension пробовал это до endUpdates
без удачи
UITableView питание от FetchedResultsController с UITableViewAutomaticDimension - Клетки двигаются, rowHeight
estimatedRowHeight
когда стол перезагружается же, как первое звено, я поставил rowHeight
и estimatedRowHeight
.
Я также попытался переключиться на performBatchUpdates
вместо begin/endUpdates
, что также не сработало.
UITableView
просто не должен перемещаться при вставке/удалении/перемещении строк, когда эти строки не видны пользователю. Я ожидаю, что что-то вроде этого просто должно работать из коробки.
Это то, что я в конечном итоге хочу (просто репликация экрана чата в 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
}()
}
Откройте табличное представление в раскадровке, затем щелкните инспектор атрибутов, оттуда измените стиль на "Сгруппированный" с "Обычный".
Мне удалось этого добиться.
Недостатком является отключение извлечения, также отключаются обновления или удаление существующих строк. Эти изменения будут применены после перезапуска загрузки.
Я также пытался настроить 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
}()
}
Сделай это
tableView.bounces = false
И это будет работать
Представление таблицы - сложный зверь. Он ведет себя по-разному в зависимости от его конфигурации. В табличном представлении корректируется смещение содержимого при вставке, обновлении, удалении и перемещении строк. Если табличное представление используется в контроллере табличного представления, вызывается метод делегата 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
}()
}
Шаг 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)
}
}
}
И это все! Это не будет делать то, что вы, когда представление полностью прокручивается до вершины, но я верю, что вы можете справиться с этим делом самостоятельно.
let lastScrollOffset = tableView.contentOffset;
tableView.beginUpdates();
tableView.insertRows(at: [newIndexPath!], with: .automatic);
tableView.endUpdates();
tableView.layer.removeAllAnimations();
tableView.setContentOffset(lastScrollOffset, animated: false);
Сделайте все возможное, чтобы установить приблизительную высоту для всех типов ячеек таблицы. Даже если высоты несколько динамичны, это помогает UITableView.
Сохраните позицию прокрутки и после обновления tableView и вызова endUpdates() сбросьте смещение содержимого.
Вы также можете проверить этот учебник
Вы можете попробовать это, отредактировав ответ pooja выше, я столкнулся с проблемой, подобной вашей, UIView.performWithoutAnimation удаляет проблему для меня. Надеюсь, это поможет.
UIView.performWithoutAnimation {
let lastScrollOffset = tableView.contentOffset;
tableView.beginUpdates();
tableView.insertRows(at: [newIndexPath!], with: .automatic);
tableView.endUpdates();
tableView.setContentOffset(lastScrollOffset, animated: false);
}
РЕДАКТИРОВАТЬ
Вы также можете попробовать вышеописанное, но вместо вставки строк вы можете использовать перезагрузку данных в табличном представлении, но перед этим добавьте данные, извлеченные из источника данных, и установите последний набор содержимого в блоке.