iOS: How to know reloadData() was completed its task? - ios

I want to scroll to a given index (self.boldRowPath), but when I debug scrollToRow is performed before reloadData().
How to know reloadData has finished ?
func getAllTimeEvent() {
self.arrAllTimeEvent = ModelManager.getInstance().getAllTimeEvent(from: self.apportmentDateFrom, to: self.apportmentDateTo)
self.tblTimeEvent.reloadData()
self.tblTimeEvent.scrollToRow(at: self.boldRowPath ?? [0,0], at: .top, animated: true)
}
Any help will be much appreciated.

Try doing this, it should work:
self.tblTimeEvent.reloadData()
DispatchQueue.main.async(execute: {
self.tblTimeEvent.scrollToRow(at: self.boldRowPath ?? [0,0], at: .top, animated: true)
})
This will execute the scrollToRow on the main thread, that means after the reloadData is done (because it is on the main thread)

As explained in this answer, the reload of the UITableView happens on the next layout run (usually, when you return control to the run loop).
So, you can schedule your code after the next layout by using the main dispatch queue. In your case:
func getAllTimeEvent() {
self.arrAllTimeEvent = ModelManager.getInstance().getAllTimeEvent(from: self.apportmentDateFrom, to: self.apportmentDateTo)
self.tblTimeEvent.reloadData()
DispatchQueue.main.async {
self.tblTimeEvent.scrollToRow(at: self.boldRowPath ?? [0,0], at: .top, animated: true)
}
}
You can also force the layout by manually calling layoutIfNeeded. But this is generally not a good idea (the previous option is the best):
func getAllTimeEvent() {
self.arrAllTimeEvent = ModelManager.getInstance().getAllTimeEvent(from: self.apportmentDateFrom, to: self.apportmentDateTo)
self.tblTimeEvent.reloadData()
self.tblTimeEvent.layoutIfNeeded()
self.tblTimeEvent.scrollToRow(at: self.boldRowPath ?? [0,0], at: .top, animated: true)
}

you can make sure reload is done using...
In Swift 3.0 + we can create a an extension for UITableView with a escaped Closure like below :
extension UITableView {
func reloadData(completion: #escaping () -> ()) {
UIView.animate(withDuration: 0, animations: { self.reloadData()})
{_ in completion() }
}
}
And Use it like Below where ever you want :
Your_Table_View.reloadData {
print("reload done")
}
hope this will help to someone. cheers!

You can make a pretty solid assumption that a UITableView is done reloading when the last time tableView(_:willDisplay:forRowAt:) is called for the visible sections on the screen.
So try something like this (for a tableView where the rows in section 0 take up the available space on the screen):
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let lastRowIndex = tableView.numberOfRows(inSection: 0)
if indexPath.row == lastRowIndex - 1 {
// tableView done reloading
self.tblTimeEvent.scrollToRow(at: self.boldRowPath ?? [0,0], at: .top, animated: true)
}
}

You can use CATransaction for that
CATransaction.begin()
CATransaction.setCompletionBlock {
// Completion
}
tableView.reloadData()
CATransaction.commit()

tableView.reloadData { [weak self] in
self?.doSomething()
}

Related

UITableView UIRefreshControl with Large Title navigation does not stick to top - it moves

I have used UITableView.refreshControl with large titles. I am trying to mimic the way the native Mail app works with pull to refresh. The refresh control, for me, moves and doesn't stay stuck at the top of the screen like the Mail app does. Here is a playground:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
class ViewController: UIViewController {
public init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private lazy var refresh: UIRefreshControl = {
let refreshControl = UIRefreshControl()
refreshControl.backgroundColor = .clear
refreshControl.tintColor = .black
refreshControl.addTarget(self, action: #selector(refreshIt), for: .valueChanged)
return refreshControl
}()
private lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.refreshControl = refresh
tableView.delegate = self
tableView.dataSource = self
return tableView
}()
private var data: [Int] = [1,2,3,4,5]
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Add tableview
addTableView()
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.title = "View Controller"
}
private func addTableView() {
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
#objc func refreshIt(_ sender: Any) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.refresh.endRefreshing()
self.data = [6,7,8,9,10]
self.tableView.reloadData()
}
}
}
extension ViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = "Row \(data[indexPath.row])"
return cell
}
}
let vc = ViewController()
let nav = UINavigationController(rootViewController: vc)
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = nav
How can I get the spinner to stick to the top, and have it behave like the Mail app does? Also the navigation title does this weird thing where it moves back slower than the table data and causes some overlap to happen... Any help is appreciated!
EDIT JUNE 7, 2020
I switched to using a diffable datasource which fixes the overlap animation, however, there is still this weird glitch right when the haptic feedback occurs and causes the spinner to shoot up to the top and back down, so my original question still remains - how do we get the spinner to stay pinned at the top like the mail app. Thanks for taking a look at this unique issue :)!!!
The reason is in used reloadData which synchronously drops & rebuilds everything thus breaking end refreshing animation.
The possible solution is to give time to end animation and only then perform reload data.
Tested with Xcode 11.4 / Playground
Modified code:
#objc func refreshIt(_ sender: Any) {
// simulate loading
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.refresh.endRefreshing() // << animating
// simulate data update
self.data = [6,7,8,9,10]
// forcefully synchronous, so delay a bit
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.tableView.reloadData()
}
}
}
Alternate approach, if you known modified data structure, would be to use beginUpdate/endUpdate pair
#objc func refreshIt(_ sender: Any) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.refresh.endRefreshing()
self.data = [6,7,8,9,10]
// more fluent, but requires knowledge of what is modified,
// in this demo it is simple - first section
self.tableView.beginUpdates()
self.tableView.reloadSections(IndexSet([0]), with: .automatic)
self.tableView.endUpdates()
}
}
I don't really know how to answer the first question, but since you asked 2 questions I'll go ahead and try to help you with the second in the meanwhile.
The problem with the rows getting to the top too fast is due to the use of tableView.reloadData().
That is always done without animation, so what is happening is to be expected.
What you could do is to use other methods on the table view, like insert or delete rows like this:
#objc func refreshIt(_ sender: Any) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.refresh.endRefreshing()
let newData = [6,7,8,9,10]
let newIndexPaths = newData.enumerated().map{IndexPath(row: self.data.count+$0.offset, section: 0)}
self.data.append(contentsOf: newData)
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
}
In this way the rows will be added with an animation and the scroll down animation won't be interrupted by any reloadData().
If you don't have only rows to add (or delete) and maybe have to do some sort of combination of both, then you can use the performBatchUpdates method to do both add and delete in the same animation block. (iOS 11+)
That's, of course, only viable if you are able to calculate the indexes of the items to add and delete. Keep in mind that the amount of rows you add and delete should always be reflected by the actual change in your data structure, or you'll have a nasty exception thrown by the table view.
Anyway that should always be feasible, but if you want it out of the box you could use iOS 13 diffable data source or, if you want it even before iOS 13, you could use instagram's IGListKit.
Both pretty much use the same concept of diffing your array of input and automatically detect deletions and inserts. So changing the array will automatically reflect in the animated change in your tableView rows. Of course both of them are very different ways of implementing a tableView, so you really can't just change a little piece of your code to make it work.
Hope this can help.
I think the problems is tableView.reloadData(). You can change it to tableView.beginUpdates() and tableView.endUpdates()
In this demo, I will create an extension for easier for tableView to reload with beginUpdate and enUpdates.
extension UITableView {
func reloadWithNewData<T:Equatable>(_ newDatas : [T], _ oldDatas : [T],_ animation : UITableView.RowAnimation) {
var deleteOnes : [IndexPath] = []
var appendOnes : [IndexPath] = []
for i in 0 ..< oldDatas.count {
for j in 0 ..< newDatas.count {
if newDatas[j] == oldDatas[i] {
break
}
if j == newDatas.count - 1 {
deleteOnes.append(IndexPath(row: i, section: 0))
}
}
}
for i in 0 ..< newDatas.count {
for j in 0 ..< oldDatas.count {
if newDatas[i] == oldDatas[j] {
break
}
if j == oldDatas.count - 1 {
appendOnes.append(IndexPath(row: i, section: 0))
}
}
}
self.beginUpdates()
self.deleteRows(at: deleteOnes, with: animation)
self.insertRows(at: appendOnes, with: animation)
self.endUpdates()
}
}
Then you just need to change for function refreshIt()
#objc func refreshIt(_ sender: Any) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.refresh.endRefreshing()
let oldData = self.data
self.data = [6,7,8,9,10]
self.tableView.reloadWithNewData(self.data, oldData, .fade)
}
}
Happy coding

Access a UIView's properties on a background thread

I am trying to have my CollectionView scroll it's first cell after the view appears, and then again whenever a button is pressed. The problem is that the collectionView hasn't generated all it's cells at any of the view lifecycle functions.
My solution is to beging a while loop on a background thread that checks to see if collectionView.visibleCells.count > 0, and when it is, return to the main thread and scroll to the first cell. However, I get an error, telling me that I shouldn't access visibleCells off the main thread, and the app chugs when I do.
How can I achieve this functionality on the main thread, or check the number of cells in the background thread?
private func scrollToFirst() {
DispatchQueue.global(qos: .background).async { [weak self] in
if (self != nil) {
while(self!.collectionView.visibleCells.count != 0) {
DispatchQueue.main.async { [weak self] in
self!.collectionView.scrollToItem(at: IndexPath(item: 0, section: 0), at: .centeredHorizontally, animated: true)
}
}
}
}
}
There is a delegate method willDisplay that gets called right before a collectionViewCell gets displayed. If you previously had no cells and this gets called, then you know you are about to go from zero to more than zero cells.
Yeah, don't do that. UIKit is not thread-safe, so the data structures of view objects may change out from under you when you try to view them from a background threads.
It seems like there should be a better way to deal with this than waiting for cells to appear.
If you can't figure out a cleaner way to do it, you could use a Timer object, which runs on the main thread. That code might look something like this:
private func scrollToFirst(afterDelay delay: Double = 0.2) {
_ = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) {
timer, [weak self] in
guard strongSelf = self else {
return
}
if strongSelf.collectionView.visibleCells.count != 0 {
self!.collectionView.scrollToItem(at: IndexPath(item: 0, section: 0), at: .centeredHorizontally, animated: true)
}
}
That code would fire once, and scroll to the beginning of the collection view if there are once the timer fires.

How to scroll to the last message sent when viewdidload? in swift 4

Currently, I have tried
func scrollToBottom(){
DispatchQueue.global(qos: .background).async {
let indexPath = IndexPath(item: self,messageArrat.count-1, section: 0)
self.messageTableView.scrollToRow(at: indexPath,at:.bottom,animated:true)
}
}
However it does not seem to work, outputting
Thread 16: ESC_BAD_ACCESS (code=1, address=0xfffffffffffffffc)
UITableView.scrollToRow(at:at:animated:) must be used from main thread only
I call the function right after setting delegate and datasource in viewdidload.
Sounds like a timing issue, you said you are calling this from the viewDidLoad, try moving it to the viewDidAppear or viewDidLayoutSubviews instead. Also remove from background thread as you are manipulating the UI and that needs to happen on the main thread.
First of all: please provide your code as text not as Image.
Now to your problem, you are trying to scroll to the last row in a global queue, but all UI-Updates must happen in the Main Thread, so simply remove the DispatchQueue, or if you are not in the main thread, you can write DispatchQueue.main instead of global
E.g.:
DispatchQueue.main.async {
//code Here
}
But i think you doesnt need the DispatchQueue at all so try to omit it completely.
Writing code in ".async" closure you starting a new thread, and the error message literally tells you do not do that.
Delete DispatchQueue stuff and that's it.
And please post text, description and all that stuff to help community.
You don't need to add DispatchQueue:
let indexPath = IndexPath(item: self.messageArrat.count-1, section: 0)
self.messageTableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
And if you want DispatchQueue then add main queue like that:
DispatchQueue.main.async {
let indexPath = IndexPath(item: self.messageArrat.count-1, section: 0)
self.messageTableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
May be index path must be out of bound, try checking number of rows before scrolling . Also do this all oprations on main thread.
It likes to work on this main thread and not in the background because it is related to the UI
try this code:
func scrollToBottom() {
DispatchQueue.main.async {
let indexPath = IndexPath(item: self.messageArrat.count-1, section: 0)
self.messageTableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}

How to scroll to a particluar index in collection view in swift

We have a collection view. I have made a calendar which shows dates horizontally & I scroll dates horizontally. Now on screen I see only 3-4 dates. Now I want to auto scroll to a particular selected date when calendar screen I shown.So the date I want to scroll to is not visible yet.
For that I got the indexpath for particular cell. Now I am trying to scroll it to particular indexpath.
func scrollCollectionView(indexpath:IndexPath)
{
// collectionView.scrollToItem(at: indexpath, at: .left, animated: true)
//collectionView.selectItem(at: indexpath, animated: true, scrollPosition: .left)
collectionView.scrollToItem(at: indexpath, at: .centeredHorizontally, animated: true)
_ = collectionView.dequeueReusableCell(withReuseIdentifier: "DayCell", for: indexpath) as? DayCell
}
Please tell how can I implement it?
In viewDidLoad of your controller, you can write the code for scrolling to a particular index path of collection view.
self.collectionView.scrollToItem(at:IndexPath(item: indexNumber, section: sectionNumber), at: .right, animated: false)
In Swift 4, this worked for me:
override func viewDidLayoutSubviews() {
collectionView.scrollToItem(at:IndexPath(item: 5, section: 0), at: .right, animated: false)
}
placing it anywhere else just resulted in weird visuals.
Note - an edit was made to my original post just now which wanted to change viewDidLayoutSubviews() to viewDidAppear(). I rejected the edit because placing the function's code anywhere else (including viewDidAppear) didn't work at the time, and the post has had 10 upvots which means it must have been working for others too. I mention it here in case anyone wants to try viewDidAppear, but I'm pretty sure I would have tried that and wouldn't have written "placing it anywhere else just resulted in weird visuals" otherwise. It could be an XCode update or whatever has since resolved this; too long ago for me to remember the details.
You can use this
self.collectionView.scrollToItem(at:IndexPath(item: index, section: 0), at: .right, animated: false)
I used your answer #PGDev but I put it in viewWillAppear and it works well for me.
self.collectionView?.scrollToItem(at:IndexPath(item: yourIndex, section: yourSection), at: .left, animated: false)
tested this
func scrollToIndex(index:Int) {
let rect = self.collectionView.layoutAttributesForItem(at:IndexPath(row: index, section: 0))?.frame
self.collectionView.scrollRectToVisible(rect!, animated: true)
}
In viewDidLoad of your controller, you can write the code
self.myCollectionView.performBatchUpdates(nil) { (isLoded) in
if isLoded{
self.myCollectionView.scrollToItem(at: self.passedContentOffset, at: [.centeredVertically,.centeredHorizontally], animated: false)
}
}
I have tried all, but for me, it only works with adding one extra line
self.collectionView.scrollToItem(at:IndexPath(item: index, section: 0), at: .right, animated: false)
collectionView.layoutSubviews()
you can call it anywhere.
Just add this:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
yourcollectionView.scrollToItem(at:IndexPath(item: passedIndex ?? 0, section: 0), at: .centeredVertically, animated: false)
}

Dynamically scroll through tableView with scrollToRowAtIndexPath

I'd like to visually scroll through my whole tableView. I tried the following, but it doesn't seem to perform the scrolling. Instead it just runs through the loops. I inserted a dispatch_async(dispatch_get_main_queue()) statement, thinking that that would ensure the view is refreshed before proceeding, but no luck.
What am I doing wrong?
func scrollThroughTable() {
for sectionNum in 0..<tableView.numberOfSections() {
for rowNum in 0..<tableView.numberOfRowsInSection(sectionNum) {
let indexPath = NSIndexPath(forRow: rowNum, inSection: sectionNum)
var cellTemp = self.tableView.cellForRowAtIndexPath(indexPath)
if cellTemp == nil {
dispatch_async(dispatch_get_main_queue()) {
self.tableView.scrollToRowAtIndexPath(indexPath!, atScrollPosition: .Top, animated: true)
self.tableView.reloadData()
}
}
}
}
}
I found a solution. Simply use scrollToRowAtIndexPath() with animation. To do so I had to create a getIndexPath() function to figure out where I want to scroll. Has more or less the same effect as scrolling through the whole table if I pass it the last element of my tableView.
If you want it to happen slower with more scrolling effect, wrap it inside UIView.animateWithDuration() and play with 'duration'. You can even do more animation if you want in its completion block. (No need to set an unreliable sleep timer, etc.)
func animateReminderInserted(toDoItem: ReminderWrapper) {
if let definiteIndexPath = indexPathDelegate.getIndexPath(toDoItem) {
self.tableView.scrollToRowAtIndexPath(definiteIndexPath, atScrollPosition: .Middle, animated: true)
}
}

Resources