I have a tableView where I insert 20 rows with delay using DispatchQueue method. First 10 rows appear fine. Problem starts with 11th one when Xcode begins to dequeue reusable rows. In simulator it looks like it starts to insert by 2 rows nearly at the same time (11th+12th, then 13th+14th).
I wonder why is that. Do DispatchQueue and tableView.dequeueReusableCell methods conflict? And if yes how to organize things properly?
var numberOfCells = 0
//My TableView
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TextCell")! as UITableViewCell
return cell
}
//Function that inserts rows
func updateTableView(nextPassageID: Int) {
for i in 0...numberOfCells - 1 {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(i)) {
self.numberOfCells += 1
let indexPath = IndexPath(row: i, section: 0)
self.tableView.insertRows(at: [indexPath], with: .fade)
}
}
}
I think using a Timer is the better solution in your use case:
private var cellCount = 0
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return cellCount
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "Cell \(indexPath.row)"
return cell
}
func addCells(count: Int) {
guard count > 0 else { return }
var alreadyAdded = 0
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] t in
guard let self = self else {
t.invalidate()
return
}
self.cellCount += 1
let indexPath = IndexPath(row: self.cellCount - 1, section: 0)
self.tableView.insertRows(at: [indexPath], with: .fade)
alreadyAdded += 1
if alreadyAdded == count {
t.invalidate()
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
addCells(count: 20)
}
Your code didn't work for me. May be you missed something to mention in your question. But with the information that I understood, I did some modification and now it runs (tested in iPhone X) as expected. Following is the working full source code.
import UIKit
class InsertCellViewController: UIViewController, UITableViewDataSource {
var dataArray:Array<String> = []
let reusableCellId = "AnimationCellId"
var timer = Timer()
var index = -1
#IBOutlet weak var tableView: UITableView!
// UIViewController lifecycle
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: reusableCellId)
tableView.separatorStyle = .none
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateTableView()
}
// MARK : UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataArray.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: reusableCellId)!
cell.textLabel?.text = dataArray[indexPath.row]
return cell
}
// Supportive methods
func updateTableView() {
timer = Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(updateCounting), userInfo: nil, repeats: true)
}
#objc func updateCounting(){
if index == 19 {
timer.invalidate()
}
index += 1
let indexPath = IndexPath(row: index, section: 0)
self.tableView.beginUpdates()
self.dataArray.append(String(index))
self.tableView.insertRows(at: [indexPath], with: .fade)
self.tableView.endUpdates()
}
}
Try to place code inside DispatchQueue.main.asyncAfter in
self.tableView.beginUpdates()
self.tableView.endUpdates()
Related
I have a UITableView that implements a type of 'infinite scrolling'.
This is done by calculating the IndexPath of the new data and then passing to tableView.insertRows(at: indexPaths, with: .none).
My data is returned from an api in pages of 50. What I am seeing however is when a user scrolls very quickly to the bottom, the table will insert rows and then jump to a section further up, essentially losing their place.
My table is created using this snippet
private func addTableView() {
tableView = UITableView(frame: .zero)
tableView.showsVerticalScrollIndicator = false
tableView.showsHorizontalScrollIndicator = false
tableView.rowHeight = UITableView.automaticDimension
tableView.bounces = true
tableView.estimatedRowHeight = 200
tableView.separatorStyle = .none
tableView.isHidden = true
tableView.backgroundColor = .clear
tableView.tableFooterView = UIView()
addSubview(tableView)
tableView.position(top: topAnchor, leading: leadingAnchor, bottom: bottomAnchor, trailing: trailingAnchor)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.reuseID)
}
And the reload is triggered using
func insertRows(_ indexPaths: [IndexPath]) {
UIView.performWithoutAnimation {
tableView.insertRows(at: indexPaths, with: .none)
self.tableView.isHidden = false
}
}
I am using performWithoutAnimation as I did not like any of the animations for inserting rows.
In my view model I inject a FeedTableViewProvider conforming to UITableViewDataSource, UITableViewDelegate and has the following methods
protocol FeedTableViewProviderType: class {
var data: Feed? { get set }
var feed: [FeedItem] { get }
var insertRows: (([IndexPath]) -> Void)? { get set }
var didRequestMoreData: ((Int) -> Void)? { get set }
}
class FeedTableViewProvider: NSObject, FeedTableViewProviderType {
var insertRows: (([IndexPath]) -> Void)?
var didRequestMoreData: ((Int) -> Void)?
var data: Feed? {
didSet {
guard let data = data else { return }
self.addMoreRows(data.feed)
}
}
private(set) var feed = [FeedItem]() {
didSet {
isPaginating = false
}
}
private var isPaginating = false
private func addMoreRows(_ data: [FeedItem]) {
var indexPaths = [IndexPath]()
data.indices.forEach { indexPaths.append(IndexPath(row: feed.count + $0, section: 0)) }
feed.append(contentsOf: data.sorted(by: { $0.props.createdDate > $1.props.createdDate }))
insertRows?(indexPaths)
}
private func requestNextPage() {
guard let currentPage = data?.currentPage, let totalPages = data?.totalPages, currentPage < totalPages else { return }
didRequestMoreData?(currentPage + 1)
}
}
extension FeedTableViewProvider: TableViewProvider {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return feed.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.reuseID, for: indexPath)
cell.textLabel?.text = "Cell # \(indexPath.row)"
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if indexPath.item == feed.count - 1 && !isPaginating {
isPaginating = true
requestNextPage()
}
}
}
I suspect the cause of this is actually to do with using
tableView.rowHeight = UITableView.automaticDimension
....
tableView.estimatedRowHeight = 200
The position changes as the offset is changing when new cells are inserted.
I would start by keeping some sort of cache containing your cell heights
private var sizeCache: [IndexPath: CGFloat] = [IndexPath: CGFloat]()
You can then capture that as the cell is scrolled off screen
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
sizeCache[indexPath] = cell.frame.size.height
}
Now make sure to apply that size from the cache
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return sizeCache[indexPath] ?? UITableView.automaticDimension
}
Now as cells are inserted and they jump with the new offset, they should render with their correct height, meaning the view should essentially stay on position.
use this code while adding new data into tableview.
UIView.setAnimationsEnabled(false)
self.tableView.beginUpdates()
self.tableView.reloadSections(NSIndexSet(index: 0) as IndexSet, with: UITableViewRowAnimation.none)
self.tableView.endUpdates()
You can use this function on every new row data insertion in Tableview to prevent scrolling to the bottom.
func scrollToTop(){
DispatchQueue.main.async {
let indexPath = IndexPath(row: self.dataArray.count-1, section: 0)
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
}
}
I want to make tableView and after tap on cell to change text in cell and to do it inside one function. In one of first versions of Swift it worked and now it just changed after finishing
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
this is a sample of code:
import UIKit
class TableViewController: UITableViewController {
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell")
cell?.textLabel?.text = "cell \(indexPath.row + 1)"
return cell!
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cell = tableView.cellForRow(at: indexPath)
cell?.textLabel?.text = "hello"
sleep(2)
cell?.textLabel?.text = "bye"
sleep(2)
}
}
I want it to work like this:
tap on cell -> cell text changed to "hello", waits for 2 seconds, then changed to "bye", waits another 2 seconds and continues
But now it works like this:
tap on cell -> waits for 4 seconds, changed to "bye" and continues
You can use DispatchQueue to get the better result:
cell?.textLabel?.text = "hello"
tableView.isUserInteractionEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// your code here
cell?.textLabel?.text = "bye"
tableView.isUserInteractionEnabled = true
}
This is actually a more complicated task than it seems.
First off, never sleep on the main queue. This locks up the main thread and makes your app appear unresponsive.
You need to have a data model to keep track of the title for each row and in this case, a timer so known when to update a row.
You also need to deal with the user tapping a row and then scrolling that row off screen and back.
It would also help to allow a user to tap several rows and have each update as needed. And you need to deal with the user leaving the table view during the delay between row updates.
Here is one solution that supports all of these issues.
class TableViewController: UITableViewController {
struct RowData {
var title: String
var timer: Timer?
init(_ title: String) {
self.title = title
}
}
var rowData = [RowData]()
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return rowData.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell") ?? UITableViewCell(style: .default, reuseIdentifier: "cell")
let data = rowData[indexPath.row]
cell.textLabel?.text = data.title
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
var data = rowData[indexPath.row]
if data.timer == nil {
// No timer for this row - set to "hello" and start a timer
let timer = Timer(timeInterval: 2, target: self, selector: #selector(timerTick), userInfo: indexPath, repeats: false)
RunLoop.main.add(timer, forMode: .commonModes) // Allow timer to work while scrolling
data.title = "hello"
data.timer = timer
rowData[indexPath.row] = data
tableView.reloadRows(at: [ indexPath ], with: .fade)
}
}
#objc func timerTick(_ timer: Timer) {
// Update the row to "bye" and reset the timer
let indexPath = timer.userInfo as! IndexPath
var data = rowData[indexPath.row]
data.timer = nil
data.title = "bye"
rowData[indexPath.row] = data
tableView.reloadRows(at: [ indexPath ], with: .fade)
}
override func viewDidLoad() {
super.viewDidLoad()
// Load test data
for i in 1...50 {
rowData.append(RowData("Row \(i)"))
}
}
override func viewWillDisappear(_ animated: Bool) {
if isMovingFromParentViewController || isBeingDismissed {
// Cancel all timers
for data in rowData {
if let timer = data.timer {
timer.invalidate()
}
}
}
}
}
With a few changes you can also support the user tapping a row before it reaches the value of "bye" and have the row reset back to the original value. That is left as an exercise.
I recently started working on a small iOS Project where I have a Game Score Leaderboard represented by a UITableView, whenever the Player gets a new Highscore the Leaderboard gets visible and his Score Entry (Including Picture, Name and Score) represented by a UITableViewCell is supposed to move to the new right Spot in the TableView Leaderboard it now belongs to. The calculation of the new index is working fine but the cell is not moving at all.
Some info:
The Leaderboard is populated succesful,
I set the func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool to true
Also implemented the func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath)
correctly.
I am thinking that maybe I am missing something but the problem could also lie somewhere else, I don't know and would really appreciate some help.
Delegate Mehods for Editing
func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
return true
}
// Handles reordering of Cells
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let player = players[sourceIndexPath.row]
players.remove(at: sourceIndexPath.row)
players.insert(player, at: destinationIndexPath.row)
}
Code where I try to move Row
func newHighscore(highscore: Int) {
friendsTableView.reloadData()
let myNewIndex = Player.recalculateMyIndex(players: players, score: highscore)
friendsTableView.moveRow(at: [0,Player.myIndex], to: [0,myNewIndex])
}
This code works, but it only example. adapt it for you. create new file and put this code in it, build it and check.
class ViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
var scores = ["1", "2", "3", "4", "5"]
override func viewDidLoad() {
super.viewDidLoad()
}
func move(from: IndexPath, to: IndexPath) {
UIView.animate(withDuration: 1, animations: {
self.tableView.moveRow(at: from, to: to)
}) { (true) in
// write here code to remove score from array at position "at" and insert at position "to" and after reloadData()
}
}
#IBAction func buttonPressed(_ sender: UIButton) {
let fromIndexPath = IndexPath(row: 4, section: 0)
let toIndexPath = IndexPath(row: 1, section: 0)
move(from: fromIndexPath, to: toIndexPath)
}
}
extension ViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return scores.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as? TableViewCell
if let cell = cell {
cell.setText(text: scores[indexPath.row])
}
return cell ?? UITableViewCell()
}
func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
return true
}
}
For correctly use, you need to insert new element in array at last, reload data and after you can call method move() and put in it current indexPath and indexPath to insert you need.
Try this code for move row Up/Down
var longPress = UILongPressGestureRecognizer (target: self, action:#selector(self.longPressGestureRecognized(_:)))
ToDoTableView.addGestureRecognizer(longPress)
//MARK: -longPressGestureRecognized
func longPressGestureRecognized(_ gestureRecognizer: UIGestureRecognizer) {
let longPress = gestureRecognizer as! UILongPressGestureRecognizer
let state = longPress.state
let locationInView = longPress.location(in: ToDoTableView)
let indexPath = ToDoTableView.indexPathForRow(at: locationInView)
struct Path {
static var initialIndexPath : IndexPath? = nil
}
switch state {
case UIGestureRecognizerState.began:
if indexPath != nil {
let cell = ToDoTableView.cellForRow(at: indexPath!) as UITableViewCell!
UIView.animate(withDuration: 0.25, animations: { () -> Void in
cell?.alpha = 0.0
}, completion: { (finished) -> Void in
if finished {
UIView.animate(withDuration: 0.25, animations: { () -> Void in
cell?.alpha = 1
})
} else {
cell?.isHidden = true
}
})
}
case UIGestureRecognizerState.changed:
if ((indexPath != nil) && (indexPath != Path.initialIndexPath)) {
itemsArray.insert(itemsArray.remove(at: Path.initialIndexPath!.row), at: indexPath!.row)
ToDoTableView.moveRow(at: Path.initialIndexPath!, to: indexPath!)
Path.initialIndexPath = indexPath
}
default:
print("default:")
}
}
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
let indexPath = IndexPath(row: 12, section: 0)
let theCell:UITableViewCell? = tableView.cellForRow(at: indexPath as IndexPath)
if let theCell = theCell {
var tableViewCenter:CGPoint = tableView.contentOffset
tableViewCenter.y += tableView.frame.size.height/2
tableView.contentOffset = CGPoint(x:0, y:theCell.center.y-(theCell.frame.size.height))
tableView.reloadData()
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return jo.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = jo[indexPath.row]
return cell
}
}
When I go to this View I Want The Message to be Automatically Scrolled to the indexpath that i will give and the user should not be able to see the animation of scrolling it should appear before view did appear ?
Use this method on your table view:
Apple doc
func scrollToRow(at indexPath: IndexPath,
at scrollPosition: UITableViewScrollPosition,
animated: Bool)
Usage:
tableView.scrollToRow(at: indexPath, at: UITableViewScrollPosition.middle, animated: false)
In your case:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.reloadData()
let indexPath = IndexPath(row: 12, section: 0)
tableView.scrollToRow(at: indexPath, at: UITableViewScrollPosition.middle, animated: false)
}
So I want to change the default animation duration of UITableView's beginUpdates() & endUpdates(), which I think is 0.3s.
I tried placing them inside a UIView animation block, then I got abrupt animation.
To see what I am talking about, create a new iOS "Single View Application" Project, and replace ViewController.swift with the following code:
class ViewController: UIViewController {
var tableView = UITableView()
var isExpanded = false
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.frame = view.bounds
tableView.dataSource = self
tableView.delegate = self
let changeHeightButton = UIBarButtonItem(title: "Change Height", style: .plain, target: self, action: #selector(changeHeight))
navigationItem.rightBarButtonItem = changeHeightButton
}
func changeHeight() {
isExpanded = !isExpanded
UIView.animate(withDuration: 0.5, animations: {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}, completion: nil)
}
}
extension ViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell") ?? UITableViewCell(style: .default, reuseIdentifier: "cell")
cell.textLabel?.text = "Section \(indexPath.section), Row \(indexPath.row)"
return cell
}
func numberOfSections(in tableView: UITableView) -> Int{
return 4
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 3
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return "Section: \(String(section))"
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if isExpanded && indexPath == IndexPath(row: 0, section: 0) { return 300 }
else { return 44 }
}
}