(Happy to accept an answer in Swift or Objective-C)
My table view has a few sections, and when a button is pressed, I want to insert a row at the end of section 0. Pressing the button again, I want delete that same row. My almost working code looks like this:
// model is an array of mutable arrays, one for each section - (void)pressedAddRemove:(id)sender { self.adding = !self.adding; // this is a BOOL property self.navigationItem.rightBarButtonItem.title = (self.adding)? @"Remove" : @"Add"; // if adding, add an object to the end of section 0 // tell the table view to insert at that index path [self.tableView beginUpdates]; NSMutableArray *sectionArray = self.model[0]; if (self.adding) { NSIndexPath *insertionPath = [NSIndexPath indexPathForRow:sectionArray.count inSection:0]; [sectionArray addObject:@{}]; [self.tableView insertRowsAtIndexPaths:@[insertionPath] withRowAnimation:UITableViewRowAnimationAutomatic]; // if removing, remove the object from the end of section 0 // tell the table view to remove at that index path } else { NSIndexPath *removalPath = [NSIndexPath indexPathForRow:sectionArray.count-1 inSection:0]; [sectionArray removeObject:[sectionArray lastObject]]; [self.tableView deleteRowsAtIndexPaths:@[removalPath] withRowAnimation:UITableViewRowAnimationAutomatic]; } [self.tableView endUpdates]; }
This behaves properly sometimes, but sometimes not, depending on where the table view is scrolled:
I can see this happen in slow-motion the simulator with "Debug->Toggle Slow Animations". The same problem occurs in reverse on the deletion.
I've found that the size of the jump in offset is related to the how far into section 0 the table is scrolled: the jump tiny when the offset is tiny. The jump gets bigger as the scrolling approaches half of section 0 total height (the problem is at it's worst here, jump == half the section height). Scrolling further, the jump gets smaller. When the table is scrolled so that only a tiny amount of section 0 is still visible, the jump is tiny.
Can you help me understand why this is and how to fix?
So, to remove a cell from a table view you first remove it from your data source, then you call deleteRows(at:) on your table view, providing it with an array of index paths that should be zapped. You can create index paths yourself, you just need a section and row number.
moveRow(at:to:) Moves the row at a specified location to a destination location.
When a user slides horizontally across a row the editing style of the Tabel View Cell is set to delete. When the delete button is pressed, the item is deleted in the array and also the row is deleted in the Table View. Build and run the project and swipe-to-delete a row from the Table View.
On iOS 11, UITableView uses estimated row height as default.
It leads to unpredictable behaviors when inserting/reloading or deleting rows because the UITableView has a wrong content size most of the time:
To avoid too many layout calculations, the tableView asks heightForRow
only for each cellForRow
call and remembers it (in normal mode, the tableView asks heightForRow
for all the indexPaths of the tableView). The rest of the cells has a height equal to the estimatedRowHeight
value until their corresponding cellForRow
is called .
// estimatedRowHeight mode contentSize.height = numberOfRowsNotYetOnScreen * estimatedRowHeight + numberOfRowsDisplayedAtLeastOnce * heightOfRow // normal mode contentSize.height = heightOfRow * numberOfCells
I guess UIKit struggles to animate correctly the changes because of this trick.
One solution is to disable the estimatedRowHeight
mode by setting estimatedRowHeight to 0 and implementing heightForRow
for each of your cells.
Of course, if your cells have dynamic heights (with onerous layout calculations most of time so you used estimatedRowHeight
for a good reason), you would have to find a way to reproduce the estimatedRowHeight
optimization without compromising the contentSize of your tableView. Take a look at AsyncDisplayKit or UITableView-FDTemplateLayoutCell.
Another solution is to try to find a estimatedRowHeight
which suits well. Since iOS 10, you can also try to use UITableView.automaticDimension
. UIKit will find a value for you:
tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = UITableView.automaticDimension
On iOS 11, it's already the default value.
I fixed jump by caching height of cell rows, as well as height of section footers and headers. Approach require to have unique cache identifier for sections and rows.
// Define caches private lazy var sectionHeaderHeights = SmartCache<NSNumber>(type: type(of: self)) private lazy var sectionFooterHeights = SmartCache<NSNumber>(type: type(of: self)) private lazy var cellRowHeights = SmartCache<NSNumber>(type: type(of: self)) // Cache section footer height func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { let section = sections[section] switch section { case .general: let view = HeaderFooterView(...) view.sizeToFit(width: tableView.bounds.width) sectionFooterHeights.set(cgFloat: view.bounds.height, forKey: section.cacheID) return view case .something: ... } } // Cache cell height func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let section = sections[indexPath.section] switch section { case .general: cellRowHeights.set(cgFloat: cell.bounds.height, forKey: section.cacheID) case .phones(let items): let item = items[indexPath.row] cellRowHeights.set(cgFloat: cell.bounds.height, forKey: section.cacheID + item.cacheID) case .something: ... } } // Use cached section footer height func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { let section = sections[section] switch section { default: return sectionFooterHeights.cgFloat(for: section.cacheID) ?? 44 case .something: ... } } // Use cached cell height func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { let section = sections[indexPath.section] switch section { case .general: return cellRowHeights.cgFloat(for: section.cacheID) ?? 80 case .phones(let items): let item = items[indexPath.row] return cellRowHeights.cgFloat(for: section.cacheID + item.cacheID) ?? 120 case .something: ... } }
Reusable class for caches can look like below:
#if os(iOS) || os(tvOS) || os(watchOS) import UIKit #elseif os(OSX) import AppKit #endif public class SmartCache<ObjectType: AnyObject>: NSCache<NSString, AnyObject> { } public extension SmartCache { public convenience init(name: String) { self.init() self.name = name } public convenience init(type: AnyObject.Type) { self.init() name = String(describing: type) } public convenience init(limit: Int) { self.init() totalCostLimit = limit } } extension SmartCache { public func isObjectCached(key: String) -> Bool { let value = object(for: key) return value != nil } public func object(for key: String) -> ObjectType? { return object(forKey: key as NSString) as? ObjectType } public func object(for key: String, _ initialiser: () -> ObjectType) -> ObjectType { let existingObject = object(forKey: key as NSString) as? ObjectType if let existingObject = existingObject { return existingObject } else { let newObject = initialiser() setObject(newObject, forKey: key as NSString) return newObject } } public func object(for key: String, _ initialiser: () -> ObjectType?) -> ObjectType? { let existingObject = object(forKey: key as NSString) as? ObjectType if let existingObject = existingObject { return existingObject } else { let newObject = initialiser() if let newObjectInstance = newObject { setObject(newObjectInstance, forKey: key as NSString) } return newObject } } public func set(object: ObjectType, forKey key: String) { setObject(object, forKey: key as NSString) } } extension SmartCache where ObjectType: NSData { public func data(for key: String, _ initialiser: () -> Data) -> Data { let existingObject = object(forKey: key as NSString) as? NSData if let existingObject = existingObject { return existingObject as Data } else { let newObject = initialiser() setObject(newObject as NSData, forKey: key as NSString) return newObject } } public func data(for key: String) -> Data? { return object(forKey: key as NSString) as? Data } public func set(data: Data, forKey key: String) { setObject(data as NSData, forKey: key as NSString) } } extension SmartCache where ObjectType: NSNumber { public func float(for key: String, _ initialiser: () -> Float) -> Float { let existingObject = object(forKey: key as NSString) if let existingObject = existingObject { return existingObject.floatValue } else { let newValue = initialiser() let newObject = NSNumber(value: newValue) setObject(newObject, forKey: key as NSString) return newValue } } public func float(for key: String) -> Float? { return object(forKey: key as NSString)?.floatValue } public func set(float: Float, forKey key: String) { setObject(NSNumber(value: float), forKey: key as NSString) } public func cgFloat(for key: String) -> CGFloat? { if let value = float(for: key) { return CGFloat(value) } else { return nil } } public func set(cgFloat: CGFloat, forKey key: String) { set(float: Float(cgFloat), forKey: key) } } #if os(iOS) || os(tvOS) || os(watchOS) public extension SmartCache where ObjectType: UIImage { public func image(for key: String) -> UIImage? { return object(forKey: key as NSString) as? UIImage } public func set(value: UIImage, forKey key: String) { if let cost = cost(for: value) { setObject(value, forKey: key as NSString, cost: cost) } else { setObject(value, forKey: key as NSString) } } private func cost(for image: UIImage) -> Int? { if let bytesPerRow = image.cgImage?.bytesPerRow, let height = image.cgImage?.height { return bytesPerRow * height // Cost in bytes } return nil } private func totalCostLimit() -> Int { let physicalMemory = ProcessInfo.processInfo.physicalMemory let ratio = physicalMemory <= (1024 * 1024 * 512 /* 512 Mb */ ) ? 0.1 : 0.2 let limit = physicalMemory / UInt64(1 / ratio) return limit > UInt64(Int.max) ? Int.max : Int(limit) } } #endif
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With