My app uses a UITableView to implement a TikTok-style UX. Each cell is the height of the entire screen. Pagination is enabled, and this works fine for the first batch of 10 records I load. Each UITableViewCell is one "page". The user can "flip" through the pages by swiping, and initially each "page" fully flips as expected. However when I add additional rows by checking to see if the currently visible cell is the last one and then loading 10 more rows, the pagination goes haywire. Swiping results in a partially "flipped" cell -- parts of two cells are visible at the same time. I've tried various things but I'm not even sure what the problem is. The tableView seems to lose track of geometry.
Note: After the pagination goes haywire I can flip all the way back to the first cell. At that point the UITableView seems to regain composure and once again I'm able to flip correctly through all of the loaded rows, including the new ones.
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// Pause the video if the cell is ended displaying
if let cell = cell as? HomeTableViewCell {
cell.pause()
}
if let indices = tableView.indexPathsForVisibleRows {
for index in indices {
if index.row >= self.data.count - 1 {
self.viewModel!.getPosts()
break
}
}
}
}
In order to create a "Tik Tok" style UX, I ended up using the Texture framework together with a cloud video provider (mux.com). Works fine now.
I was facing the same issue and as I couldn't find a solution anywhere else here's how I solved it without using Texture:
I used the UITableViewDataSourcePrefetching protocol to fetch the new data to be inserted
extension TikTokTableView: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
viewModel.prefetchRows(at: indexPaths)
}
}
prefetchRows will execute the request if the visible cell is the last one, as in my case
func prefetchRows(at indexPaths: [IndexPath]) {
if indexPaths.contains(where: isLastCell) {
getPosts(type: typeOfPosts, offset: posts.count, lastPostId: lastPost)
}
}
private func isLastCell(for indexPath: IndexPath) -> Bool {
return indexPath.row == posts.count - 1
}
I have a weak var view delegate type TikTokTableViewDelegate in my view model to have access to a function insertItems implemented by my TikTokTableView. This function is used to inform the UITableView where to insert the incoming posts at
self.posts.append(contentsOf: response.posts)
let indexPathsToReload = self.calculateIndexPathToReload(from: response.posts)
self.view?.insertItems(at: indexPathsToReload)
private func calculateIndexPathToReload(from newPosts: [Post]) -> [IndexPath] {
let startIndex = posts.count - newPosts.count
let endIndex = startIndex + newPosts.count
print(startIndex, endIndex)
return (startIndex..<endIndex).map { IndexPath(row: $0, section: 0) }
}
and this is the insertItems function implemented in TikTokTableView and here is the key: If we try to insert those rows, the pagination of the table will fail and leave that weird offset, we have to store the indexPaths in a local property and insert them once the scroll animation has finished.
extension TikTokTableView: TikTokTableViewDelegate {
func insertItems(at indexPathsToReload: [IndexPath]) {
DispatchQueue.main.async {
// if we try to insert rows in the table, the scroll animation will be stopped and the cell will have a weird offset
// that's why we keep the indexPaths and insert them on scrollViewDidEndDecelerating(:)
self.indexPathsToReload = indexPathsToReload
}
}
}
Since UITableView is a subclass of UIScrollView, we have access to scrollViewDidEndDecelerating, this func is triggered at the end of a user's scroll and this is the time when we insert the new rows
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if !indexPathsToReload.isEmpty {
tableView.insertRows(at: indexPathsToReload, with: .none)
indexPathsToReload = []
}
}
tl;dr
How can I get correct the height of my tableView after cell height changes made due to cell expansion——without using delays?
My tableViewcells, expand and collapse. Because of that I need to pass (using a delegate method) the tableView's height to it's superView. It's superview is a collectionviewcell.
extension ArtistDetailViewController: UITableViewDelegate{
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let cell = tableView.cellForRow(at: indexPath) as? WorkTableViewCell else {
fatalError()
}
//1: create model
var work = works[indexPath.row]
//2: update the model
work.isExpanded = !work.isExpanded
//3: upadte dataSource
works[indexPath.row] = work
//4: update the text of textView
cell.moreInfoTextView.text = work.isExpanded ? work.info : WorkTableViewCell.textViewText
cell.moreInfoTextView.textAlignment = work.isExpanded ? .left : .center
//5: update TableView
tableView.beginUpdates()
tableView.endUpdates()
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
//6: slavishly update cell with everything you got!
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.contentView.setNeedsUpdateConstraints()
cell.contentView.updateConstraintsIfNeeded()
cell.contentView.setNeedsLayout()
cell.contentView.layoutIfNeeded()
print(tableView.contentSize.height) // prints 252
//7: pass tableView height with delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01, execute: {
print(tableView.contentSize.height) // prints 341
self.tableViewHeight = tableView.contentSize.height
self.delegate?.didRender(with: self.tableViewHeight)
})
}
}
my ArtistDetailViewController has a tableView property.
The tableView property subclassed from a tableView which has intrinsicSize.
class AutomaticHeightTableView: UITableView {
override var intrinsicContentSize: CGSize {
return contentSize
}
override func reloadData() {
super.reloadData()
invalidateIntrinsicContentSize()
}
}
The problem I have is if I don't use any delay (step 7) is:
The tableView's height would be enlarged enough ie it would enlarge, but just not enlarge enough.
The tableView won't collapse later.
Even if I do use delay sometimes it won't collapse/expand \o/
Obviously the problem is that these updates are done in an async manner and the the value is calculated too quick! As for proof:
If you saw the prints, The height of the tableView changes from 252 to 341—after the delay.
In my step 6 I tried adding all sorts of updates so that I could avoid adding a delay but that didn't work.
The result with delay:
The result without delay:
I'm using a static table view which contains 3 different cells. And when a button in the first cell is tapped, the height of the first cell should increase. Below is the function called when the button is tapped.
#IBAction func toggleExpandCamera(_ sender: Any) {
self.shouldShowCameraPreview = !self.shouldShowCameraPreview
self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
}
And in the table view's delegate heightForRowAt()
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.row == 0 {
let expandedHeight: CGFloat = 415
let collapsedHeight: CGFloat = 115
if self.shouldShowCameraPreview {
return expandedHeight
}
return collapsedHeight
} else if indexPath.row == 2 {
// If already premium, dont show purchases cell.
if AGTUserDefaultValues.isUserPremium {
return 0
}
// Last cell should be same height as the table view
return self.tableView.frame.height - (self.navigationController?.navigationBar.frame.height ?? 0)
- min(UIApplication.shared.statusBarFrame.height, UIApplication.shared.statusBarFrame.width)
} else {
return super.tableView(tableView, heightForRowAt: indexPath)
}
}
I've verified that heightForRowAt() IS getting called, and it is working fine for the other cell heights when toggleExpandCamera() is called. It's just that the first cell that is behaving quite weird. It seems like it disappeared or something. I've attached screenshots down below, before and after expanding.
On further inspection, it looks like the cell still exist, but still has the same height. The only difference is there's now more space between the two cells. I also found out the alpha value of the cell is 0.
UPDATE
I've tried created a new project, with only the tableview and the function to expand the cell, and still on that project, the same thing happened. If anyone is curious to help I've uploaded the project here.
I've tried your project and found that changing :
self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
into :
self.tableView.reloadData()
Works as expected.
I figured out the answer. In my toggleExpandCamera() function instead of reloading the table view, I replaced that with:
#IBAction func toggleExpandCamera(_ sender: Any) {
self.shouldShowCameraPreview = !self.shouldShowCameraPreview
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
And the cell height animates as expected.
I have a tableView, designed in storyboard, that mimics a chat UI. A cell consists of:
A TextView for the message text
A Profile Image of the sender
Right now, the profile image is displayed in every cell, next to the text bubble. This is fine, but if the same users send two or more messages directly after the other, the profile image should only appear on the last bubble and not on the previous one.
I tried calling cellForRowAtIndexPath to get the previous cell's properties and change the hidden property of the profile image, but this gave me two problems:
I'm calling cellForRowAtIndexPath inside cellForRowAtIndexPath, because that's where I make the cell UI and decide wether the profile image has to be hidden or not. I don't think it's a good idea to call this Method inside itself.
Sometimes (when scrolling up and down very fast) this does not work properly.
I also tried to store all the cells in an dictionary (indexPath.row: Cell), so I can access it faster later, but this gave me the same problem namely that it does not work when scrolling up and down really fast.
This is an illustration of how it should be: http://tinypic.com/view.php?pic=2qavj9w&s=8#.Vfcpi7yJfzI
You need to both look ahead inside of your cellForRowAtIndexPath method and, as Paulw11 recommended, call reloadRowsAtIndexPaths after inserting the cell:
import UIKit
struct MyMessage {
let sender: String
let text: String
}
class MyTableViewCell: UITableViewCell {
var message: MyMessage?
var showProfileImage: Bool = false
}
class MyTableViewController: UITableViewController {
private var _messages: [MyMessage] = []
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self._messages.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let message = self._messages[indexPath.row]
let cell = tableView.dequeueReusableCellWithIdentifier("Cell") as! MyTableViewCell
cell.message = message
if self._messages.count > indexPath.row + 1 {
let nextMessage = self._messages[indexPath.row + 1]
cell.showProfileImage = message.sender != nextMessage.sender
} else {
cell.showProfileImage = true
}
return cell
}
func addMessage(message: MyMessage) {
let lastIndexPath = NSIndexPath(forRow: self._messages.count - 1, inSection: 0)
let indexPath = NSIndexPath(forRow: self._messages.count, inSection: 0)
self._messages.append(message)
self.tableView.beginUpdates()
self.tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Bottom)
self.tableView.reloadRowsAtIndexPaths([lastIndexPath], withRowAnimation: UITableViewRowAnimation.Fade)
self.tableView.endUpdates()
}
}
I have a _TableView with items , and I want to set automatic refresh,and I don't want it to scroll on refresh , lets say user scrolled 2 pages down , and the refresh trigered -> so I want to put the refreshed content to the top of the table without interupting user's scrolling
Assume user was on row 18
and now the _dataSource is refreshed so it fetched lets say 4 items , so I want user to stay on the item he was.
What would be the best approach to achieve it ??
For Swift 3+:
You need to save the current offset of the UITableView, then reload and then set the offset back on the UITableView.
I have created this function for this purpose:
func reload(tableView: UITableView) {
let contentOffset = tableView.contentOffset
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.setContentOffset(contentOffset, animated: false)
}
Simply call it with: reload(tableView: self.tableView)
SWIFT 3
let contentOffset = self.tableView.contentOffset
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
self.tableView.setContentOffset(contentOffset, animated: false)
This is error of iOS8 when using UITableViewAutomatic Dimension. We need store the content offset of table, reload table, force layout and set contenOffset back.
CGPoint contentOffset = self.tableView.contentOffset;
[self.tableView reloadData];
[self.tableView layoutIfNeeded];
[self.tableView setContentOffset:contentOffset];
I am showing if only one row is being added. You can extend it to multiple rows.
// dataArray is your data Source object
[dataArray insertObject:name atIndex:0];
CGPoint contentOffset = self.tableView.contentOffset;
contentOffset.y += [self tableView:self.tableView heightForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
[self.tableView reloadData];
[self.tableView setContentOffset:contentOffset];
But for this to work you need to have defined - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath the method. Or else, you can directly give your tableview row height if it is constant.
Just set estimatedRowHeight to maximum possible value.
self.tableView.estimatedRowHeight = 1000
self.tableView.estimatedSectionFooterHeight = 100.0
self.tableView.estimatedSectionHeaderHeight = 500.0
That's it!!
Note:
Please do not use FLT_MAX, DBL_MAX value. May be it will crash your app.
I'm doing it this way:
messages.insertContentsOf(incomingMsgs.reverse(), at: 0)
table.reloadData()
// This is for the first load, first 20 messages, scroll to bottom
if (messages.count <= 20) {
let indexToScroll = NSIndexPath(forRow: saferSelf.messages.count - 1, inSection: 0)
table.scrollToRowAtIndexPath(indexToScroll, atScrollPosition: .Top , animated: false)
}
// This is to reload older messages on top of tableview
else {
let indexToScroll = NSIndexPath(forRow: incomingMsgs.count, inSection: 0)
table.scrollToRowAtIndexPath(indexToScroll, atScrollPosition: .Top , animated: false)
// Remove the refreshControl.height + tableHeader.height from the offset so the content remain where it was before reload
let theRightOffset = CGPointMake(0, table.contentOffset.y - refreshControl.frame.height - table.headeView.frame.height)
table.setContentOffset(theRightOffset, animated: false)
}
...also, since I use dynamic cell height, to avoid some weirdness, the estimation is cached:
var heightAtIndexPath = [NSIndexPath: CGFloat]()
func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return heightAtIndexPath[indexPath] ?? UITableViewAutomaticDimension
}
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
heightAtIndexPath[indexPath] = cell.frame.height
}
Use Extension
create UITableViewExtensions.swift and add following:
extension UITableView {
func reloadDataWithoutScroll() {
let offset = contentOffset
reloadData()
layoutIfNeeded()
setContentOffset(offset, animated: false)
}
}
In iOS 12.x, using Xcode 10.2.1, an easier option is.
UIView.performWithoutAnimation {
let loc = tableView.contentOffset
tableView.reloadRows(at: [indexPath], with: .none)
tableView.contentOffset = loc
}
This works better than following; it shakes at times when the row is not fully visible.
let contentOffset = self.tableView.contentOffset
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
self.tableView.setContentOffset(contentOffset, animated: false)
Swift 4.2 : Simple Solution
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.estimatedRowHeight = 0
self.tableView.estimatedSectionHeaderHeight = 0
self.tableView.estimatedSectionFooterHeight = 0
}
//And then simply update(insert, reloadSections, delete etc) your tableView or reload
tableView.reloadData()
//or
UIView.performWithoutAnimation {
tableView.beginUpdates()
.....
tableView.endUpdates()
}
This code will prevent unnecessary animation and maintain the scroll view's content offset, it worked fine for me.
let lastScrollOffset = tableView.contentOffset
tableView.beginUpdates()
tableView.reloadData()
tableView.endUpdates()
tableView.layer.removeAllAnimations()
tableView.setContentOffset(lastScrollOffset, animated: false)
When you want to reload you have to
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
and also use this UITableViewDelegate
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 'your maximum cell's height'
}
and your tableView will remain on the previous scroll position without scrolling
try to replace
reloadData with
tableView.reloadRows(at: tableView!.indexPathsForVisibleRows!, with: .none),
but you should be care about no cells, if no cells, this method should cause crash.
i wrote something that works perfect for me:
extension UIScrollView {
func reloadDataAndKeepContentOffsetInPlace(reloadData:(() -> Void)) {
let currentContentHeight = contentSize.height
if currentContentHeight == .zero {
reloadData()
return
}
reloadData()
layoutIfNeeded()
let newContentHeight = self.contentSize.height
DispatchQueue.main.async {
var contentOffset = self.contentOffset
contentOffset.y += newContentHeight - currentContentHeight
self.setContentOffset(contentOffset, animated: false)
}
}
}
use like this:
self.reloadSomeData()
collectionView.reloadDataAndKeepContentOffsetInPlace { [weak self] in
guard let self = self else { return }
self.collectionView.reloadData()
}
Try the following.
tableView.estimatedRowHeight = 0
tableView.estimatedSectionHeaderHeight = 0
tableView.estimatedSectionFooterHeight = 0
Source: https://developer.apple.com/forums/thread/86703