Error when searching in the new UITableViewDiffableDataSource [Snapshotting] - ios

I know that this is a common error and has been discussed many times, but hear me out. I've read a LOT of those posts and none of them mentions my specific case and I haven't been able to figure the solution out by myself.
This is the error I'm getting:
[Snapshotting] Snapshotting a view (0x7fab10c300b0, _UIReplicantView) that has not been rendered at least once requires afterScreenUpdates:YES.
According to my observation it appears everytime I begin searching in the searchController. It seems to me, that the error pops up when the keyboard changes (from capital to small letters, vice versa...).
I've tried:
putting .layoutIfNeeded() everywhere I possibly could (tableView, cells, view, searchBar...)
all UI changes are happening on main thread
toying with different searchController.searchBar.searchTextField.becomeFirstResponder()
Needless to say, none of the above solved my issue.
Here is my code for searching:
func updateSearchResults(for searchController: UISearchController) {
let added = DBManager.shared.getAddedCurrencies()
DispatchQueue.main.async { searchController.searchBar.searchTextField.becomeFirstResponder() }
guard let filter = searchController.searchBar.text, !filter.isEmpty else {
filtering = false
filtered = []
updateDiffable(with: added)
return
}
filtering = true
filtered = added.filter({ (currency) -> Bool in
return currency.name.lowercased().contains(filter.lowercased()) || currency.code.lowercased().contains(filter.lowercased())
})
updateDiffable(with: filtered)
}
Updating diffable data source
func updateDiffable(with list: [Currency], animate: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Currency>()
snapshot.appendSections([.main])
snapshot.appendItems(list)
DispatchQueue.main.async {
self.diffableDataSource.apply(snapshot, animatingDifferences: animate)
}
}
As I said, error pops up precisely when I put first letter into search bar or when I remove it. Otherwise everything works just fine.
Any help or tips would be much appreciated!

Related

how to prevent button showing up for split second when view loads

So my goal is to smoothly load the viewController with no split second bugs. I have a function that is used to determine what buttons to show when the view loads based off a field in a Firestore document. Here is the function:
func determinePurchasedStatusVerification() {
db.collection("student_users/\(user?.uid)/events_bought").whereField("event_name", isEqualTo: selectedEventName!).whereField("isEventPurchased", isEqualTo: true).getDocuments { (querySnapshot, error) in
if let error = error {
print("\(error)")
} else {
guard let querySnap = querySnapshot?.isEmpty else { return }
if querySnap == true {
self.purchaseTicketButton.isHidden = false
self.viewPurchaseButton.isHidden = true
self.cancelPurchaseButton.isHidden = true
} else {
self.purchaseTicketButton.isHidden = true
self.viewPurchaseButton.isHidden = false
self.cancelPurchaseButton.isHidden = false
}
}
}
}
I call this function in the viewWillAppear() of the vc but when I instantiate to that vc, this is the result...
The extra purchase ticket button shows up for a split second. Even though it's very quick, you can still see it and it's just not something a user would need to see. It's also the other way around when you click on a cell that's not purchased, the two bottom buttons show up for a split second. I just want to know how I can prevent this quick bug and be able to display a smooth segue with no delays in the button hiding. Thanks.
getDocuments is an asynchronous function, meaning it doesn't call its callback function immediately -- it calls it when it gets data back from the server. It may seem like a split second just because your internet connection is fast and the Firebase servers are definitely fast, but it's a non-zero time for sure. And, someone with a slower connection might experience much more of a delay.
Unless your callback is getting called twice with different results (which seems doubtful), the only solution here is to make sure that your initial state has all of the buttons hidden (and maybe a loading indicator) and then show the buttons that you want once you get the data back (as you are right now). My guess is, though, that you have an initial state where the buttons are visible, which causes the flicker.

Stop Diffable Data Source scrolling to top after refresh

How can I stop a diffable data source scrolling the view to the top after applying the snapshot. I currently have this...
fileprivate func configureDataSource() {
self.datasource = UICollectionViewDiffableDataSource<Section, PostDetail>(collectionView: self.collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, userComment: PostDetail) -> UICollectionViewCell? in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PostDetailCell.reuseIdentifier, for: indexPath) as? PostDetailCell else { fatalError("Cannot create cell")}
cell.user = self.user
cell.postDetail = userComment
cell.likeCommentDelegate = self
return cell
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PostDetail>()
snapshot.appendSections([.main])
snapshot.appendItems(self.userComments)
self.datasource.apply(snapshot, animatingDifferences: true)
}
fileprivate func applySnapshot() {
//let contentOffset = self.collectionView.contentOffset
var snapshot = NSDiffableDataSourceSnapshot<Section, PostDetail>()
snapshot.appendSections([.main])
snapshot.appendItems(self.userComments)
self.datasource.apply(snapshot, animatingDifferences: false)
//self.collectionView.contentOffset = contentOffset
}
store the offset, then reapply it. Sometimes it works perfectly and sometimes the view jumps. Is there a better way of doing this?
The source of this problem is probably your Item identifier type - the UserComment.
Diffable data source uses the hash of your item identifier type to detect if it is a new instance or an old one which is represented currently.
If you implement Hashable protocol manually, and you use a UUID which is generated whenever a new instance of the type is initialized, this misguides the Diffable data source and tells it this is a new instance of item identifier. So the previous ones must be deleted and the new ones should be represented. This causes the table or collection view to scroll after applying snapshot.
To solve that replace the uuid with one of the properties of the type that you know is unique or more generally use a technique to generate the same hash value for identical instances.
So to summarize, the general idea is to pass instances of the item identifiers with the same hash values to the snapshot to tell the Diffable data source that these items are not new and there is no need to delete previous ones and insert these ones. In this case you will not encounter unnecessary scrolls.
Starting from iOS 15
dataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
Resets the UI to reflect the state of the data in the snapshot without computing a diff or animating the changes
First up: in most cases #Amirrezas answer will be the correct reason for the problem. In my case it was not the item, but the section identifier that caused the problem. That was Hashable and Identifiable with correct values, but it was a class, and therefore the hash functions were never called. Took me a while to spot that problem. Changing to a struct (and therefore adopting some things ;) ) helped in my case.
For reference here's a link to the topic on the Apple-Dev forums: https://developer.apple.com/forums/thread/657499
Hope my answer helps somebody :)
You'd think that any of these methods would work:
https://developer.apple.com/documentation/uikit/uicollectionviewdelegate/1618007-collectionview
https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617724-targetcontentoffset
But (in my case) they did not. You might get more mileage out of them, I am doing some crazy stuff with a custom UICollectionViewCompositionalLayout
What I did get to work is manually setting the offset in my custom layout class:
override func finalizeCollectionViewUpdates() {
if let offset = collectionView?.contentOffset {
collectionView?.contentOffset = targetContentOffset(forProposedContentOffset: offset)
}
super.finalizeCollectionViewUpdates()
}
where I have targetContentOffset also overridden and defined (I tried that first, didn't work, figured it was cleanest to just use that here. I suspect if you define targetContentOffset on the delegate without overriding it in the layout the above will also work, but you already need a custom layout to get this far so it's all the same.)

WKWebView has incorrect pageCount; Can this be corrected or completely removed?

I have a pdf that is poorly loaded into a WKWebView. What complicates things further is that the WKWebView sits inside a tableView whose reloads and other updates seem to upset the WKWebView and makes the page count a mess.
I have other WKWEbView in project that don't live inside a tableView and the page counts are correct there.
I found this bit of code on SO that attempts to remove the page count; it helped, but not totally.
func getSubviewsOfView(v:UIView) -> [UIView] {
var viewArray = [UIView]()
for subview in v.subviews {
viewArray += getSubviewsOfView(v: subview)
viewArray.append(subview)
}
return viewArray
}
In the webView didFinish navigation method I have:
let webViewSubviews = self.getSubviewsOfView(v: webView)
for v in webViewSubviews {
if v.isKind(of: UILabel.self) || v.isKind(of: UIImageView.self) || v.isKind(of: UIVisualEffectView.self) {
v.superview?.superview?.superview?.superview?.removeFromSuperview()
}
}
This bit of code does remove the PDFPageLabelView, that is, the superView I believe holds the label that has the page number, but the problem with it is that it doesn't get removed until after a series of reloads of the WKWebView and/or tableView and it the page count is visible to the user until it's finally removed.
Is there a way to correct the erroneous page count on the WKWebView?The UIWebView never had this problem ;(
I don't know what exactly that you want, but I think you need all views in a linear array, maybe this can help you:
func getSubviewsOfView(v: UIView) -> [UIView] {
guard !v.subviews.isEmpty else { return [v] }
var v1 = v.subviews.flatMap({ (view) -> [UIView] in
return getSubviewsOfView(v: view)
})
v1.append(v)
return v1
}

iOS label does not update text even from main thread

I've spent a fun couple of hours trying all sorts of different combinations to have a label properly update its title after a Firebase async download. It's the same issue raised here and here. Seems like a clear fix, but I'm doing something wrong and would appreciate any help pointing me in the right direction.
The basic flow is view loads, data is downloaded from Firebase, some labels are updated accordingly with downloaded data. One representative iteration I have tried is as follows:
// Query Firebase.
let detailsRef = self.ref.child("eventDetails")
detailsRef.queryOrdered(byChild: "UNIQUE_ID_EVENT_NUMBER").queryEqual(toValue: eventID).observeSingleEvent(of: .value, with: { snapshot in
if (snapshot.value is NSNull) {
print("error")
}
else {
var tempDict = [NSDictionary]()
for child in snapshot.children {
let data = child as! FIRDataSnapshot
let dict = data.value as! NSDictionary as! [String:Any]
tempDict.append(dict as NSDictionary)
}
self.dictionaryOfRecoDetails = tempDict
self.ParseFirebaseData()
DispatchQueue.main.async {
// This is the function that updates labels and button text in format like self.websiteLabel.titleLabel?.text = "Appropriate String"
self.loadDataForView()
}
}
})
func loadDataForView() {
// Example of the label update that happens within this function.
// Do not show website button if there is no website.
if self.recommendation.recommendationWebsiteUrl == "" || self.recommendation.recommendationWebsiteUrl == nil || self.recommendation.recommendationWebsiteUrl == "NA" {
self.websiteLabel.titleLabel?.text = ""
self.websiteHeight.constant = 0
self.websiteBottom.constant = 0
}
else {
self.websiteLabel.titleLabel?.text = "Go to Website"
}
}
EDIT UPDATE: The call to the code above is coming from viewDidAppear(). It doesn't update if I call it from viewDidLayoutSubviews() either.
From debugging I know the label update is getting called, but nothing is changing. Feels like something simple I'm missing, but I'm stuck. Thanks for your ideas.
I'm pretty sure you don't need the DispatchQueue.main.async bit. Just try calling self.loadDataFromView() and see if that helps.
This ended up being a lesson in mis-labeling causing confusion. The label being changed actually isn't a label, but a button. Shouldn't have been named websiteLabel! Once the title was changed with self.websiteLabel.setTitle("Go to Website", for: .normal) then everything worked as expected.

Wrong cells count for collection view in UI Tests

I have a test for a collection view that works like this:
func testDeleteItem() {
app.collectionViews.staticTexts["Item"].tap()
app.buttons["Delete"].tap()
XCTAssertEqual(app.collectionViews.cells.count, 2)
XCTAssertFalse(app.collectionViews.cells.staticTexts["Item"].exists)
}
After the tap, there is a new screen with the delete button. When the button is tapped, the screen dismisses itself and reloads the collection view. Everything goes as expected in the UI, but I get both asserts failing. In the first count it is still 3 and in the second item it still exists.
I have found the solution, but it's a workaround for wrong API behavior. Collection view is caching cells, that's probably why I have 3 cells, even if I have removed one. Deleted cell is offscreen, so you can test if it is hittable:
XCTAssertFalse(app.cells.staticTexts["Item"].hittable)
To find a count, I have created extension:
extension XCUIElementQuery {
var countForHittables: UInt {
return UInt(allElementsBoundByIndex.filter { $0.hittable }.count)
}
}
and my test looks like this:
func testDeleteItem() {
app.collectionViews.staticTexts["Item"].tap()
app.buttons["Delete"].tap()
XCTAssertEqual(app.collectionViews.cells.countForHittables, 2)
XCTAssertFalse(app.collectionViews.cells.staticTexts["Item"].hittable)
}
I also came across this issue, but in my case, .cells query wasn't evaluating correctly. Instead of .cells, using
XCUIApplication().collectionViews.element.childrenMatchingType(.Cell).count
worked for me and returned the correct count.
Update:
I also found that scrolling the view so that all the cells are dequeued before getting the count fixed the issue. It seems the accessibility framework does not find the other cells until they have been dequeued (I guess that makes sense).
XCUIApplication().collectionViews.element.swipeUp()
I think cells query returns all cells from all the tables currently in view hierarchy. Try to do this app.tables.firstMatch.cells.count, it worked for me.
I ran into this question when I was looking for the same answer, but in Objective-C. For those like me, I adapted #Tomasz's method to count Collection View cells in UI tests:
-(NSInteger)countForHittables:(NSArray<XCUIElement*>*)collectionView{
__block int hittables = 0;
[collectionView enumerateObjectsUsingBlock:^(XCUIElement * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.hittable){
hittables++;
}
}];
return hittables;
}
To call it: [self countForHittables:app.collectionViews.cells.allElementsBoundByIndex];.
I had the same issue. Even if the collection hasn't been populated because it was waiting for the response of an API, cells.count >= 1 was always true.
What I did, based on Tomasz Bąk's answer I created an extension to wait for the collection to be populated:
extension XCTestCase {
func waitForCollectionToBePopulated(_ element: XCUIElement, timeout: TimeInterval) {
let query = element.cells
let p = NSPredicate(format: "countForHittables >= 1")
let e = expectation(for: p, evaluatedWith: query, handler: nil)
wait(for: [e], timeout: timeout)
}
}
And on the caller site will look:
waitForCollectionToBePopulated(collection, timeout: {timeOut})

Resources