How to allow an off screen UITableViewCell push down content offset? - ios

I have a UITableView, which contains a bunch of cells.
If a user clicks on the second cell, the first cell is supposed to animate and expand its height significantly and push all of the other cells down, leaving the user's scroll position in the same place. My code is working 100% correctly when both cells are on the screen. The contentSize of the UITableView grows significantly, and the contentOffset does not change.
But if the user scrolls down so that only the second cell is visible, when they tap it, the first cell expands off screen and nothing happens for the user.
The contentSize of the UITableView does not change, nor does the contentOffset. Once the user scrolls up slightly, and sees the bottom of the expanded cell, the contentSize and the contentOffset update to reflect the fact that the first cell is indeed much bigger (but nothing changes from the user's perspective)
Calling heightForRowAtIndexPath for the the index path of the cell before and after expanding returns the expected values.
My code has a lot going on, but the main piece that is supposed to be animating the expansion is here:
UIView animateWithDuration:0.3
animations:^{
performSelectorOnMainThread(#selector(updateItemHeights:), withObject: nil, waitUntilDone: YES)
}
completion:^(BOOL finished){}]
And the implementation of of updateItemHeights:
beginUpdates
endUpdates
self.contentSize = sizeThatFits([contentSize.width, CGFLOAT_MAX])
It seems like iOS is trying to keep the user in their current context by allowing the cells above to expand.
How do I get an off screen cell to push the other cells down?

Thanks to the table view dequeue system, when a cell is not visible, it is not loaded. So the table view won't animate a change if it is not visible on screen.
I see 2 options here :
Scrolling to the animated cell before updating its height
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let animatedIndexPath = ...
let visibleRows = tableView.indexPathsForVisibleRows ?? []
if visibleRows.contains(animatedIndexPath) {
self.tableView.reloadRows(at: [animatedIndexPath], with: .automatic)
} else {
UIView.animate(withDuration: 0.3, animations: {
self.tableView.scrollToRow(at: animatedIndexPath, at: .none, animated: false)
}) { _ in
self.tableView.reloadRows(at: [animatedIndexPath], with: .automatic)
}
}
}
Adjusting the content offset once the cell is updated
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let animatedIndexPath = ...
let visibleRows = tableView.indexPathsForVisibleRows ?? []
if visibleRows.contains(animatedIndexPath) {
self.tableView.reloadRows(at: [animatedIndexPath], with: .automatic)
} else {
let offset = tableView.contentOffset
tableView.reloadData()
tableView.layoutIfNeeded() // forces the new offset computation
tableView.setContentOffset(offset, animated: true)
}
}
(you might encounter some issues because of the table view dynamic height computation, disable it tableView.estimatedRowHeight = 0)

Related

UITableView is jumping when I insert new rows

I tried many ways to solve this problem, but I couldn't. My tableView jumps after it loads more data. I call the downloading method in willDisplay:
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let lastObject = objects.count - 1
if indexPath.row == lastObject {
page = page + 1
getObjects(page: page)
}
}
and insert rows here:
func getObjects(page: Int) {
RequestManager.sharedInstance.getObjects(page: page, success: { objects in
DispatchQueue.main.async(execute: {
self.objects = self.objects + objects
self.tableView.beginUpdates()
var indexPaths = [IndexPath]()
for i in 0...objects.count - 1 {
indexPaths.append(IndexPath(row: i, section: 0))
}
self.tableView.insertRows(at: indexPaths, with: .bottom)
self.tableView.endUpdates()
});
})
}
So what do I wrong? Why tableView jumps after inserting new rows?
I have just find the solution to stop jumping the table view while
inserting multiple rows in the table View. Am facing this issue from
last few days so finally I resolved the issue.
We have to just set the content offset of table view while
inserting rows in the table view. You have to just pass your array of
IndexPath rest you can use this function.
Hope so this method will help you.
func insertRows() {
if #available(iOS 11.0, *) {
self.tableView.performBatchUpdates({
self.tableView.setContentOffset(self.tableView.contentOffset, animated: false)
self.tableView.insertRows(at: [IndexPath], with: .bottom)
}, completion: nil)
} else {
// Fallback on earlier versions
self.tableView.beginUpdates()
self.tableView.setContentOffset(self.tableView.contentOffset, animated: false)
self.tableView.insertRows(at: [IndexPath], with: .right)
self.tableView.endUpdates()
}
}
I had a similar problem with tableView. Partially I decided this with beginUpdates() and endUpdates()
self.tableView.beginUpdates()
self.tableView.endUpdates()
But this didn't solve the problem.
For iOS 11, the problem remained.
I added an array with the heights of all the cells and used this data in the method tableView(_:heightForRowAt:)
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return cellHeights[indexPath.row] ?? 0
}
Also add this method tableView(_:estimatedHeightForRowAt:)
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return cellHeights[indexPath.row] ?? 0
}
After that, the jumps stopped.
First, check your tableView(_:estimatedHeightForRowAt:) - this will never be accurate but the more likely the cell height ends up with this estimate the less work the table view will do.
So if there are 100 cells in your table view, 50 of them you are sure will end up with a height of 75 - that should be the estimate.
Also it's worth a while noting that there is no limit on the number of times the table view may ask its delegate of the exact cell height. So if you have a table view of 1000 rows there will a big performance issue on the layout out of the cells (delays in seconds) - implementing the estimate reduces drastically these calls.
Second thing you need to revisit the cell design, are there any views or controls whose height need to calculated by the table view? Like an image with top and bottom anchors equivalent to some other view whose height changes from cell to cell?
The more fixed heights these views/ controls have the easier it becomes for the table view to layout its cells.
I had the same issue with two table views, one of them had a variable height image embedded into a stack view where I had to implement the estimate. The other didn't had fixed size images and I didn't need to implement the estimate to make it scroll smoothly.
Both table views use pagination.
Last but not least, arrays are structs. structs are value types. So maybe you don't want to store any heights in an array, see how many copies you're making?
calculating the heights inside tableView(_:heightForRowAt:) is quite fast and efficient enough to work out really well.
Because your loop runs from 0 to objects count:
for i in 0...objects.count - 1 {
indexPaths.append(IndexPath(row: i, section: 0))
}
The indexpaths generated counting for row 0 till object's count. and hence the rows are getting added at top of table (i.e. at row 0) and hence causing tableview to jump as you are there at bottom of tableview.
Try changing range as:
let rangeStart = self.objects.count
let rangeEnd = rangeStart + (objects.count - 1)
for i in rangeStart...rangeEnd {
indexPaths.append(IndexPath(row: i, section: 0))
}
Hope it helps..!!!

Tableview is bouncing when reloadRows(at: [indexPath], with: .fade) is called from did select row at index path

I am having this weird issue. when I scroll tableview and update some cell it bounce equivalent to the amount first cell scrolled.
I am having one view controller with tableview in it and based on user select some rows i'm updating only that row using below code
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let index = indexPath.row
print(index)
let service = services[indexPath.row]
if service.isEnabled {
service.isEnabled = false
}
else {
service.isEnabled = true
}
let indexPath = IndexPath(item: index, section: 0)
serviceTableView.reloadRows(at: [indexPath], with: .fade)
}
this is working fine if I don't scroll the table view but if i scroll table view and select some cell its bounce the entire tableview
Things already tried
Removing all constraints of table view, it still bounce
Removing all config methods from cellForRowAt indexPath
Different simulators
also I am not doing anything in section view.
PS: What I observe is that it only happens when first cell is partially visible/hidden.

TableView Auto Scrolling misbehaving after adding cells

View Setup:
My TableView has 3 sections with 4 or 9 cell each. Each Cell has a Label and TextField.
On Starting to edit a cell at index 2 of each section, I reload the section which will now consist of 9 cells(update model to dequeueCell so that 5 more cells will be added).
Problem:
The tableView scrolls as expected(brings textfield to visible part of the screen) for the unexpanded state of the section. But after I add cells by beginning to edit the textfield of cell at index 2 of any section, the tableView scrolls such that it hides the textfield. The weird scrolling occurs for any cells in the tableview once any section has expanded numbers of cells. Also, while weird scroll is happening, the tableView is reloaded(which is leading to lose the focus away from textfield). I have included tableView.reloadSection(_:) in the didBeginEditing(:_) custom delegate of the cell.
I have seen this problem in iOS 9 and 10
Sorry for poor explanation. Thanks
Heres the Github Repo
And Problem is here
P.S. I am using Swift 3 and Xcode 8.3.3 with deployment target iOS 10
Please do not suggest answer in Swift 4 and Xcode 9
You can try another approach: change the height of cells instead of insert / delete.
Change number of cells to always return all items:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of rows
guard let sectionEnum = Sections(rawValue: section) else { return 0 }
return sectionEnum.getRows(forExpanded: true).count
}
Set height of 'hidden' items to 0:
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
guard let sectionEnum = Sections(rawValue: indexPath.section) else { return 0 }
let isExpanded = expandedSectionData[indexPath.section]
if (!isExpanded) {
let object = sectionEnum.getRows(forExpanded: true)[indexPath.row]
if (!sectionEnum.getRows(forExpanded: false).contains(object)) {
return 0;
}
}
return self.tableView.estimatedRowHeight
}
Set cell to clip subviews to its bounds:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
....
cell.clipsToBounds = true;
return cell
}
And change updating code to (remove tableView.reloadSections, change indexPath):
func didBeginEditing(textField: UITextField, cell: UITableViewCell) {
guard let indexPath = tableView.indexPath(for: cell), let section = Sections(rawValue: indexPath.section) else { return }
if indexPath.row == 7 && !expandedSectionData[indexPath.section] {
expandedSectionData[indexPath.section] = true
tableView.beginUpdates()
tableView.endUpdates()
tableView.scrollToRow(at: indexPath, at: UITableViewScrollPosition.none, animated: true)
textField.becomeFirstResponder()
}
}
You need to make textfield as first responder again, after reloading section text field no longer remains first responder.
You might need to change something like -
func didBeginEditing(textField: UITextField, cell: UITableViewCell) {
guard let indexPath = tableView.indexPath(for: cell) else { return }
if indexPath.row == 2 && !expandedSectionData[indexPath.section] {
tableView.beginUpdates()
expandedSectionData[indexPath.section] = true
tableView.reloadSections(IndexSet(integer: indexPath.section), with: .automatic)
tableView.endUpdates()
// after tableview is reloaded, get cell again
let cell = tableView.cellForRow(at: IndexPath(row: 2, section: indexPath.section)) as? TestCell
cell?.textField.becomeFirstResponder()
}
}
I have tried running this, kind of looks fine to me.
This issue has to do with your use of self-sizing tableview cells. To fix the issue, comment out these two lines in your viewDidLoad and consider defining the height of your cells with tableView:heightForRowAtIndexPath:.
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 100
Since the self-sizing tableview documentation states,
To define the cell’s height, you need an unbroken chain of constraints
and views (with defined heights) to fill the area between the content
view’s top edge and its bottom edge
I also tried changing the bottomMargin = textField.bottom constraint from priority 750 to 1000, but this did not fix the issue.

UITableViewCell resize after click, constraints are changing

I have TableViewCell filled with data. I want to show only one label for example, and after user clicks cell will resize and show all data to user. I'm done with resizing part, but when cell is small it automaticly change my constraints. Can somebody help me ? code nd photos below.
Thanks!
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if (i != indexPath.row) {
i = indexPath.row
}
else {
i = -1
}
tableView.reloadRows(at: [indexPath], with: UITableViewRowAnimation.automatic)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if (indexPath.row == i) {
return 250
}
else {
return 50
}
}
When the cell is at a height of 50 your constraints are being broken because there is not enough space to accommodate them all as you wanted them to be laid out. Therefore, the height of the cell is being honored before any other constraints you added to elements within the cell and thus they are squished together.
To eliminate constraint errors, play around with the priority and relation modes on your constraints.
Lastly, when your cells are collapsed, you can hide all other labels except the one you want to display by calling the following on ones that should be hidden:
label.hidden = true
Or animate their disappearance like so:
UIView.animate(withDuration: 0.2, animations: {
label.alpha = 0
}, completion: { completed -> Void in
label.hidden = true
})
And do the opposite to make them reappear when you expand the cells.

How to get height of UITableView when cells are dynamically sized?

I have a UITableView with cells that are dynamically sized. That means I have set:
tableView.estimatedRowHeight = 50.0
tableView.rowHeight = UITableViewAutomaticDimension
Now I want to get the height of the whole table view. I tried getting it through tableView.contentSize.height but that only returns the estimated row height, and not the actual dynamic height of the table view.
How do I get the dynamic height of the whole table view?
I finally hacked out a solution:
tableView.contentSize.height will not work for dynamic cells because they will only return the number of cells * estimatedRowHeight.
Hence, to get the dynamic table view height, you look for all visible cells, and sum up their heights. Note that this only works for table views that are shorter than your screen.
However, before we do the above to look for visible cells, it is important to know that note we need to get the table view on the screen so that we can obtain visible cells. To do so, we can set a height constraint for the table view to some arbitrarily large number just so it appears on the screen:
Set height of table view constraint:
// Class variable heightOfTableViewConstraint set to 1000
heightOfTableViewConstraint = NSLayoutConstraint(item: self.tableView, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .height, multiplier: 0.0, constant: 1000)
containerView.addConstraint(heightOfTableViewConstraint)
Call tableView.layoutIfNeeded(), and when completed, look for the visible cells, sum up their height, and edit the heightOfTableViewConstraint:
UIView.animate(withDuration: 0, animations: {
self.tableView.layoutIfNeeded()
}) { (complete) in
var heightOfTableView: CGFloat = 0.0
// Get visible cells and sum up their heights
let cells = self.tableView.visibleCells
for cell in cells {
heightOfTableView += cell.frame.height
}
// Edit heightOfTableViewConstraint's constant to update height of table view
self.heightOfTableViewConstraint.constant = heightOfTableView
}
Above mentioned solutions are pretty well. I tried a different approach & its smooth & clear to understand. Just Copy Paste the code & it will work.
override func viewDidLoad() {
super.viewDidLoad()
tableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
}
override func viewWillDisappear(_ animated: Bool) {
tableView.removeObserver(self, forKeyPath: "contentSize")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize"{
if object is UITableView{
if let newValue = change?[.newKey]{
let newSize = newValue as! CGSize
heightOfTableViewConstraint.constant = newSize.height
}
}
}
}
it's already been answered, but I want to share my experience as well.
I have the same problem. I've searched answers in many similar questions and tried their answers and doesn't work.
Finally I've found leonardloo answer. Thanks a lot, but it doesn't solved my problem yet. I just want to complete his answer. I put this code from his answer:
UIView.animate(withDuration: 0, animations: {
self.tableView.layoutIfNeeded()
}) { (complete) in
var heightOfTableView: CGFloat = 0.0
// Get visible cells and sum up their heights
let cells = self.tableView.visibleCells
for cell in cells {
heightOfTableView += cell.frame.height
}
// Edit heightOfTableViewConstraint's constant to update height of table view
self.heightOfTableViewConstraint.constant = heightOfTableView
}
in this block of code:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
if indexPath.row == data.count-1{
// Insert the code here
}
return cell
}
It means after all the rows in table have been loaded, we update the height of the row using that piece of code.
It works!!!
Even though #leonardloo's answer is partially correct and it may suit the needs of many people, I wanted to share how I fixed my issue.
I had a large tableView with big rows and had the same issue. When I've applied #leonardloo's answer, I've seen that it draws only number of visible cells it has found in the beginning and it draws 1 row whereas it's supposed to draw 5 (my datasource count).
To fix that, I have found such a simple solution:
UIView.animate(withDuration: 0, animations: {
self.tableView.layoutIfNeeded()
}) { (complete) in
var heightOfTableView: CGFloat = 0.0
// Get visible cells and sum up their heights
let cells = self.tableView.visibleCells
for cell in cells {
heightOfTableView += cell.frame.height
}
// Edit heightOfTableViewConstraint's constant to update height of table view
// Set tableView's constraint to height + 1 so that
// it can still draw the next visible cell it's supposed to.
self.heightOfTableViewConstraint.constant = heightOfTableView + 1
}
The point here is to let it draw the next cell so that it will be counted in visibleCells as well by setting constraint constant to height + 1. I've tested and it seems working like a charm now.
You should calculate the height of tableView in the following delegate method,
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
//Get the height required for the TableView to show all cells
if indexPath.row() == (tableView.indexPathsForVisibleRows.last! as! NSIndexPath).row {
//End of loading all Visible cells
float heightRequiredForTable = tableView.contentSize.height
//If cell's are more than 10 or so that it could not fit on the tableView's visible area then you have to go for other way to check for last cell loaded
}
}
Just maintain an array of type CGFLoat and append UITableViewAutomaticDimension in cellForRowAt indexPath delegate of tableview
var cellHeight = [CGFloat]()
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
cellHeight.append(UITableViewAutomaticDimension)
}
I'll add my answer here since I struggled with this a bit.
My table view was using sections that had headers, so just getting the height of the visible cells wasn't working. What I ended up doing was first set a super large height for the tableView so that the content will all be added. Then on the last cell of the tableView, I resize the tableView to be the same height as its content.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
... // do your normal cell processing
// determine height and change it
if indexPath.section == (self.sectionTitles.count - 1) && indexPath.row == (sectionData.count - 1) {
UIView.animate(withDuration: 0, animations: {
self.tableView.layoutIfNeeded()
}) { complete in
// Edit heightOfTableViewConstraint's constant to update height of table view
self.tableViewHeightConstraint.constant = self.tableView.contentSize.height
}
}
}
you can try this code
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}

Resources