Bounce occurs when changing rows
Asked Answered
P

7

17

Glitch

I am using CoreData with a NSFetchResultController to have data displayed in a UITableView. I have one problem: the UITableView changes the contentOffSet.y when a new row is inserted/moved/deleted. When the user have scrolled to, for e.g. the middle, the UITableView bounces when a new row is inserted.

Reproduction project

This github link to a project which contains the minimum code to reproduce this behavior: https://github.com/Jasperav/FetchResultControllerGlitch (the code is down below as well)

This is showing the glitch. I am standing in the middle of my UITableView and I am constantly seeing new rows being inserted, regardless of the current contentOffSet.y.:

enter image description here

Similar questions

Concerns

I also tried switch to performBatchUpdates instead of begin/endUpdates, that didn't worked out also.

The UITableView just shouldn't move when inserting/deleting/moving rows when those rows aren't visible to the user. I expect something like this just should work out of the box.

Final goal

This is what I eventually want (just a replication of the chat screen of WhatsApp):

  • When the user is completely scrolled to the top (for WhatsApp this is the bottom) where the new rows are being inserted, the UITableView should animate the new inserted row and change the current contentOffSet.y.
  • When the user isn't completely scrolled to the top (or bottom, depending where the new rows are being inserted) the cells the user is seeing should not bounce around when a new row is inserted. This is really bad for the user experience of the application.
  • It should work for dynamic height cells.
  • I also see this behavior when moving/deleting cells. Is there any easy fix for all glitches here?

If a UICollectionView would be a better fit, that would be fine to.

Use case

I am trying to replicate the WhatsApp chat screen. I am not sure if they use NSFetchResultController, but besides that, the final goal is to provide them the exact user experience. So inserting, moving, deleting and updating cells should be done the way WhatsApp is doing it. So for a working example: go to WhatsApp, for a not-working example: download the project.

Copy paste code

Code (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
    }()
}
Purkey answered 20/1, 2019 at 22:34 Comment(2)
UIKit is so bad. :( Sometimes it's not even possible to make simple things work with standard components.Planchette
Just play with contentOffset, When you are adding new row, get the height of new row and update the contentoffset of tableview to keep the tableview's position same. make sure that you kept animated=falseEnthusiast
P
1
   let lastScrollOffset = tableView.contentOffset;
   tableView.beginUpdates();
   tableView.insertRows(at: [newIndexPath!], with: .automatic);
   tableView.endUpdates();
   tableView.layer.removeAllAnimations();
   tableView.setContentOffset(lastScrollOffset, animated: false);
  1. Do the best you can establishing estimated heights for all of your table cell types. Even if heights are somewhat dynamic this helps the UITableView.

  2. Save your scroll position and after updating your tableView and making a call to endUpdates() reset the content offset.

You can also check this tutorial

Pember answered 30/1, 2019 at 2:53 Comment(0)
R
0

I've managed to achieve this.

  • stop applying update if user scrolls down table view, by removing resultController.delegate
  • restart applying if user is back to top of table view, by setting resultController.delegate again
  • sync diff between disabled time

Drawback is disabling fetch also disables updates or deletion of existing rows. Those change will be applied after fetch restarts.

I've also tried to adjust contentOffset on controller(_:didChange:at:for:newIndexPath:) but it didn't work at all.

Code follows.

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
    }()
}
Rann answered 25/1, 2019 at 3:44 Comment(2)
I don't think this "too hacky", but yeah, actually not simple solution. This glitch seems because contentHeight is changed by inserting row but contentOffset is not. So content move down even if user is not at top of table view. Thus, most straightforward way is that add contentOffset right after inserting row, but it somehow doesn't works in my environment (new manually set contentOffset got ignored). I don't have enough time for further investigation for now, but maybe this become some hint.Rann
Ok, well one of the mentioned requirements is that moving/deleting/updating cells should keep working (see final goal section), but that isn't the case with this answer :(Purkey
T
0

Do this

tableView.bounces = false

And it will work

Thier answered 25/1, 2019 at 8:23 Comment(3)
No but when I read developer.apple.com/documentation/uikit/uiscrollview/… I can not imagine it works, but I will try it when I get homePurkey
in that case the total working solution would be:--#48155484Thier
Ok thanks I put a comment here when I tried your answer when I get homePurkey
E
0

The table view is a complex beast. It behaves differently depending on its configuration. The table view adjusts the content offset when inserting, updating, deleting and moving rows. If the table view is used within a table view controller the scrollview delegate method scrollViewDidScroll(_:) is called.

The solution is to revoke the content offset adjustment there. However, this is against the intent of the table view and therefore needs to be done several times until viewDidLayoutSubviews() is called. So the solution is not optimal, but it works with dynamic height cells, section headers, section footers and should match your goals.

For the solution I have rebuilt your code. Your ViewController is no longer based on UIViewController but on UITableViewController. The essential part of the solution is the treatment and use of the property 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
    }()
}
Eduardo answered 29/1, 2019 at 14:50 Comment(1)
I can not get it to work with a UIViewController, this is a must. Is there a way around? I award a bounty of 500 if you get it to workPurkey
P
0

Step 1: Define what you mean by "not move". For humans it is very clear that it is jumping. But the computer sees that the contentOffset is staying the same. So let us be very precise and define that the first cell that has a visible top should stay exactly where it after the change. All the other cells can move around, but this is our anchor.

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
    }
  }
}

When we call setAnchorPoint we find and remember which entity (not indexPath because that may change shortly) is near the top and exactly how far from the top it is.

Next lets call setAnchorPoint right before changes happen:

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

And after the changes are done we scroll back to where we are suppose to be without any animation:

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

And that is it! This will not do what you when when the view is completely scrolled to the top, but I trust that you can handle that case yourself.

Perlaperle answered 29/1, 2019 at 15:22 Comment(0)
S
0

you can try this its a edit on above pooja's answer, I've faced issue like yours the UIView.performWithoutAnimation removes the issue for me.Hope it helps.

 UIView.performWithoutAnimation {

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

EDIT

you can also try the above but instead of insert rows you can use reload data on tableview but before that append the data fetched to you datasource, and set the last contentoffeset inside the block.

Scenario answered 30/1, 2019 at 7:48 Comment(0)
C
0

It's a typical issue for messages lists like Telegram, WhatsApp, or other messengers. If you use UICollectionView, you might consider using StableCollectionViewLayout

It uses the UICollectionViewLayout subclass to solve this issue.

UICollectionViewLayout has some methods that can help:

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
}

This solution has a lot of underwater rocks, but it is quite isolated. Everything magic will happen inside CollectionViewLayout. It will allow using just insert/reload/delete UICollectionView methods.

Clorindaclorinde answered 17/10, 2021 at 20:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.