What are the ways in which a UICollectionViewDiffableDataSource header can be reloaded?
I have a collection view with a header that displays user details, and rows that display posts, the model is
struct PostUser {
var user: User
var post: Post
}
when I change a property via the snapshot
var postUsers = [PostUser]() {
didSet {
self.applySnapshot(postUsers)
}
}
fileprivate func applySnapshot(_ postsUser: [PostUser]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, PostUser>()
snapshot.appendSections([.main])
snapshot.appendItems(postsUser)
self.datasource.apply(snapshot, animatingDifferences: true)
}
the rows reload, however the supplementary header does not. The only way I can get the header to change is if I make the Section part of the model, so:
struct Section: Hashable {
var User: User
}
my apply snapshot now becomes
fileprivate func applySnapshot(_ postsUser: [PostUser]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, PostUser>()
snapshot.appendSections([Section(User: self.user)])
snapshot.appendItems(postsUser)
self.datasource.apply(snapshot, animatingDifferences: true)
}
I then set the user separately
var user: User! = nil {
didSet {
self.applySnapshot(self.postUsers)
}
}
and the header reloads.
I don't quite understand why when I change something in postUsers, the rows reload, but the header does not - until I implement a model as part of the section?
I do understand that diffable works on hashing, and so when I change a property, the table reloads, but it feels like the header should also reload, but it is treated separately?
Headers are only reloaded when a change in the section itself is detected. If only the items change, the supplementary views will remain unchanged. Another thing you could look into depending on your case would be to set animatingDifferences to false when you want to reload a header since, as of the latest iOS 14 beta, that will trigger reloadData. No diffing will be done.
Related
Say I have a collectionView, using a UICollectionViewDiffableDataSource<Section, Row> where Section is
enum Section {
case info
}
and Row is
enum Row {
case name(name: String)
case description(description: String)
}
I then define a configuration for these cells
let textRegistration = UICollectionView.CellRegistration<TextInputCollectionViewCell, Row> { (cell, indexPath, item) in
cell.item = item
}
I have a delegate method setup to be notified when the content of these cells change from user input,
func nameDidChange(to newName: String?) {
self.navigationItem.title = newTitle
//Update my model
}
func descriptionDidChange(to newDescription: String?) {
//Update my model
}
Now here is my issue, if I initially add this data to my dataSource like this:
var infoSnapshot = self.dataSource.snapshot()
infoSnapshot.appendSections([.info])
infoSnapshot.appendItems([
.name(name: self.myModel.name),
.description(description: self.myModel.description)
], toSection: .info)
self.dataSource.apply(infoSnapshot, animatingDifferences: false)
As soon as this cell is offscreen and reset, once it comes back it uses the old data initially set in the method above.
How do I reset the data, if doing so would change the hash, messing with my diffable datasource? Should I set the data elsewhere, not tying it to the dataSource?
Before Editing
During Editing
After Editing
I'm working with a UICollectionView as UIViewRepresentable. It should be updated when either current item is changed (to scroll to the current item and highlight it), or the source data was updated (it can add/remove one item). The first case works perfectly, but the second one can cause lags and even crash the app with this exception: "Attempted to scroll the collection view to an out-of-bounds item (9) when there are only 9 items in section 0". It's caused when scrolling after updating the source data (adding/removing item).
Here is the code for updateUIView function:
func updateUIView(_ uiView: UICollectionView, context: Context) {
uiView.reloadData()
if !sections.isEmpty, currentSectionId != context.coordinator.currentSectionIndex && uiView.numberOfItems(inSection: 0) == sections.count {
context.coordinator.currentSectionIndex = currentSectionId
guard let currentSection = sections.firstIndex(where: { $0.id == currentSectionId }) else { return }
let indexPath = IndexPath(row: currentSection, section: 0)
uiView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
And here are the variables of my UIViewRepresentable:
struct SectionScrollView: UIViewRepresentable {
#Binding var sections: [MenuSection]
#Binding var currentSectionId: Int
It's a menu that can be navigated through categories so scrolling main menu (also UIViewRepresentable UICollectionView) triggers currentSectionId that scrolls section collection view to the current section. And pressing current section in section collection view triggers main collection view to scroll to the beginning current section.
Also, making an item favorite adds additional section (if there is no favorites) and removes it (if user removes the last item from favorites). And here is when the exception appear. If the user scrolls main collection view after making item favorite, the app could crash (or could not).
It seems that reloadData() doesn't always work or work as expected.
So, what could be wrong here?
P.S. When I add the code below to updateUIView, the scrolling sometimes stops until another favorite dish isn't added/removed:
uiView.reloadData() // This was already there
if sections.count != uiView.numberOfItems(inSection: 0) {
uiView.reloadData()
}
In my mobile application I would like to update the tableView datasource by a pull to refresh request, but I don't know how to insert the new items on top of the tableview datasource.
I see that there is a a method of insertRows such as : self.tableView?.insertRows(at: [indexPath], with: .top) but how do I add the newItems here according to my methods I have?
I have a function called initializedTableView() that initializes the tableView with PublishSubject observable items.
func initializeTableView() {
viewModel
.items
.subscribe(onNext: { items in
self.tableView?.delegate = nil
self.tableView?.dataSource = nil
Observable.just(items)
.bind(to:(self.tableView?.rx.items(cellIdentifier:
itemCell.Identifier, cellType: itemCell.self))!) {
(index, element, cell) in
cell.itemModel = element
}.disposed(by: self.disposeBag)
})
.disposed(by: disposeBag)
}
This function is called once a pull to refresh is requested by user:
func refreshTableView() {
// get new items
viewModel
.newItems
.subscribe(onNext: { newItems in
//new
let new = newItems.filter({ item in
// items.new == true
})
//old
var old = newItems.filter({ item -> Bool in
// items.new == false
})
new.forEach({item in
// how to update tableView.rx.datasource here???
})
}).disposed(by: disposeBag)
}
struct ViewModel {
let items: BehaviorRelay<[Item]>
init() {
self.items = BehaviorRelay(value: [])
}
func fetchNewItems() {
// This assumes you are properly distinguishing which items are new
// and `newItems` does not contain existing items
let newItems: [Item] = /* However you get new items */
// Get a copy of the current items
var updatedItems = self.items.value
// Insert new items at the beginning of currentItems
updatedItems.insert(contentsOf: newItems, at: 0)
// For simplicity this answer assumes you are using a single cell and are okay with a reload
// rather than the insert animations.
// This will reload your tableView since 'items' is bound to the tableView items
//
// Alternatively, you could use RxDataSources and use the `RxTableViewSectionedAnimatedDataSource`
// This will require a section model that conforms to `AnimatableSectionModelType` and some
// overall reworking of this example
items.accept(updatedItems)
}
}
final class CustomViewController: UIViewController {
deinit {
disposeBag = DisposeBag()
}
#IBOutlet weak var tableView: UITableView!
private var disposeBag = DisposeBag()
private let viewModel = ViewModel()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CustomTableCell.self, forCellReuseIdentifier: "ReuseID")
tableView.refreshControl = UIRefreshControl()
viewModel.items
.bind(to: tableView.rx.items(cellIdentifier: "ReuseID", cellType: CustomTableCell.self)) { row, item, cell in
// Configure cell with item
cell.configure(with: item)
}
.disposed(by: disposeBag)
tableView.refreshControl?.rx.controlEvent(.valueChanged)
.subscribe(onNext: { [weak self] in
self?.viewModel.fetchNewItems()
})
.disposed(by: disposeBag)
}
}
Alternative answer using BehaviorRelay and bindings. This way, you are only updating the items relay and it will automatically update the tableView. It also provides a more "Rx" way of handling pull to refresh.
As mentioned in the code comments, this assumes you are determining which items are new and that newItems does not contain any existing items. Either way this should provide a starting point.
struct ViewModel {
let items: Observable<[Item]>
init(trigger: Observable<Void>, newItems: #escaping () -> Observable<[Item]>) {
items = trigger
.flatMapLatest(newItems)
.scan([], accumulator: { $1 + $0 })
}
}
The above doesn't handle errors, nor does it handle resets, but the scan will put the new items at the top of the list.
The situation doesn't feel right though. Normally, the API call returns all the items, how can it possibly know which items are "new"?
I did something similar with my app since I had issues with tableView.insertRows.
Here is the code:
func loadMoreComments() {
// call to backend to get more comments
getMoreComments { (newComments) in
// combine the new data and your existing data source array
self.comments = newComments + self.comments
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
// calculate the total height of the newly added cells
var addedHeight: CGFloat = 0
for i in 0...result.count {
let indexRow = i
let tempIndexPath = IndexPath(row: Int(indexRow), section: 0)
addedHeight = addedHeight + self.tableView.rectForRow(at: tempIndexPath).height
}
// adjust the content offset by how much height was added to the start so that it looks the same to the user
self.tableView.contentOffset.y = self.tableView.contentOffset.y + addedHeight
}
}
So, by calculating the heights of the new cells being added to the start and then adding this calculated height to the tableView.contentOffset.y, I was able to add cells to the top of the tableView seamlessly without reworking my tableView. This may look like a jerky workaround, but the shift in tableView.contentOffset isn't noticeable if you calculate the height properly.
Quick question, I am using a UISearchController its working perfectly.
But I was wondering if it was possible to show a new view when I select the search bar?
Because when I am searching I do not want to see my tableView/background.
What you are referring to is the presentation context of the UISearchController.
Here is a link to Apple's documentation on definesPresentationContext and the relevant piece of information we care about
this property controls which existing view controller in your view
controller hierarchy is actually covered by the new content
If you are still working off this example UISearchController from before, you are already almost done and just need to look at the following line of code inside of viewDidLoad():
self.definesPresentationContext = true
The default value for this is false. Since it's set to true, we are telling the UITableViewController that it will be covered when the view controller or one of its descendants presents a view controller. In our case, we are covering the UITableViewController with the UISearchController.
To address your question, hiding the tableView/background is as simple as clearing or switching the table's data source when the search bar is active. This is handled in the following bit of code.
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if (self.userSearchController.active) {
return self.searchUsers.count
} else {
// return normal data source count
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCellWithIdentifier("userCell") as! UserCell
if (self.userSearchController.active && self.searchUsers.count > indexPath.row) {
// bind data to the search data source
} else {
// bind data to the normal data source
}
return cell
}
When the search bar is dismissed, we want to reload the normal data source which is done with the following:
func searchBarCancelButtonClicked(searchBar: UISearchBar) {
// Clear any search criteria
searchBar.text = ""
// Force reload of table data from normal data source
}
Here's a link to a great article on UISearchControllers and also gives a brief overview of their inner workings and view hierarchy.
For future posts on SO, you should always try to include the relevant code samples so people are able to give the best feedback possible :)
EDIT
I think I misinterpreted your question a bit but the above is still relevant towards the answer. To display a special view when the search results are empty or nothing is typed in, do the following:
1) Add a new UIView as a child of the TableView of your UITableViewController in the storyboard with the desired labels/images. This will be next to any prototype cells you may have.
2) Create and wire up the outlets in your UITableViewController
#IBOutlet var emptyView: UIView!
#IBOutlet weak var emptyViewLabel: UILabel!
3) Hide the view initially in viewDidLoad()
self.emptyView?.hidden = true
4) Create a helper function to update the view
func updateEmptyView() {
if (self.userSearchController.active) {
self.emptyViewLabel.text = "Empty search data source text"
self.emptyView?.hidden = (self.searchUsers.count > 0)
} else {
// Keep the emptyView hidden or update it to use along with the normal data source
//self.emptyViewLabel.text = "Empty normal data source text"
//self.emptyView?.hidden = (self.normalDataSource.count > 0)
}
}
5) Call the updateEmptyView() after you've finished querying
func loadSearchUsers(searchString: String) {
var query = PFUser.query()
// Filter by search string
query.whereKey("username", containsString: searchString)
self.searchActive = true
query.findObjectsInBackgroundWithBlock { (objects: [AnyObject]?, error: NSError?) -> Void in
if (error == nil) {
self.searchUsers.removeAll(keepCapacity: false)
self.searchUsers += objects as! [PFUser]
self.tableView.reloadData()
self.updateEmptyView()
} else {
// Log details of the failure
println("search query error: \(error) \(error!.userInfo!)")
}
self.searchActive = false
}
}
Hope that helps!
This is my viewController and as you can see when it loads up only three header views are visible.
why am I not getting the headerView textlabel text for those headers located below the initial view. I got 6 sections in total which corresponds to 6 headers views in total.
This is my code:
//
// FillinTheBlanksTableViewController.swift
// GetJobbed
//
// Created by Rustam Allakov on 9/22/15.
// Copyright (c) 2015 Rustam Allakov. All rights reserved.
//
import UIKit
class FillinTheBlanksTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
//
// print("the first section name is: ")
// println(tableView.headerViewForSection(0)!.textLabel.text!)
let headerNames = getSectionNames()
println(headerNames)
print("the number of sections in a tableview ")
println(tableView.numberOfSections())
}
//get all the sections you got
func getVisibleSectionNames () -> [String] {
var headerNames = [String]()
if let indexPaths = tableView.indexPathsForVisibleRows() as? [NSIndexPath] {
headerNames = indexPaths.map { [unowned self] indexPath -> String in
let section = indexPath.section
if let headerText = self.tableView.headerViewForSection(section) {
return headerText.textLabel.text!
} else {
return ""
}
}
}
return headerNames
}
///array of strings with names of the headerViews in a tableview
//why I am not getting the not visible part of my table view?
func getSectionNames() -> [String] {
var sectionNames = [String]()
//how many section do my table view got ?
for i in 0..<tableView.numberOfSections() {
// if let headerView = tableView.headerViewForSection(i) {
// println("the header number \(i)")
// sectionNames.append(headerView.textLabel.text!)
// } else {
// println("i am not getting these \(i)")
//
// }
let headerView = tableView.headerViewForSection(i)
sectionNames.append(headerView?.textLabel.text ?? "not retreived header")
}
return sectionNames
}
}
the print to the console:
HEADER INFORMATION
EDUCATION
WORK EXPERIENCE
not retreived header
not retrieved header
not retreived header
I can only retrieve 3 of the 6 section header titles. What am I missing?
This is expected behavior.
An off-screen header view won't exist, so headerViewForSection will return nil for that section.
From the UITableView class reference:
Return Value
The header view associated with the section, or nil if the section does not have a header view.
If you scroll your tableView, you'll see that the tableView creates header views as they appear on-screen, and deallocates them once they scroll off-screen.
You can determine this by logging the address of the view that is returned to you. The sections that have scrolled off-screen now return nil, while the ones that have now scrolled on-screen now return a view.
If you scroll a section header off-screen, then back on screen, the tableView actually returns a different view for that section, since the original one was deallocated, and a new one was created.
If you need the titles for off-screen headers, you will have to retrieve them from your model (or dataSource), as no headers will exist for those sections.
You may want to consider implementing tableView:titleForHeaderInSection: to avoid having to access a headerView's textLabel to get the title.