Collection View Diffable Data Source cells disappearing and not resizing properly? - ios

I'm having a really weird issue with my collection view. I'm using the Compositional Layout and Diffable Data Source APIs for iOS 13+, but I'm getting some really weird behavior. As seen in the video below, when I update the data source, the first cell that is added to the top section doesn't resize properly, then when I add the second cell both cells disappear, and then when I add a third cell, all load in with the proper sizes and appear. When I unadd all the cells and add them back in a similar fashion a second time, that initial issue doesn't happen again.
Video of Error
I have tried using the following solutions in some fashion:
collectionView.collectionViewLayout.invalidateLayout()
cell.contentView.setNeedsLayout() followed by cell.contentView.layoutIfNeeded()
collectionView.reloadData()
I can't seem to figure out what might be causing this issue. Perhaps it could be that I have two different cells registered with the collection view and dequeueing them improperly or my data types aren't correctly conforming to hashable. I believe I've fixed both of those issues, but I will also provide my code to help. Also the data controller mentioned is a simple class that stores an array of view models for the cells to use for configuration (there shouldn't be any issue there). Thanks!
Collection View Controller
import UIKit
class PartyInvitesViewController: UIViewController {
private var collectionView: UICollectionView!
private lazy var layout = createLayout()
private lazy var dataSource = createDataSource()
private let searchController = UISearchController(searchResultsController: nil)
private let dataController = InvitesDataController()
override func loadView() {
super.loadView()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
override func viewDidLoad() {
super.viewDidLoad()
let backButton = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
backButton.tintColor = UIColor.Fiesta.primary
navigationItem.backBarButtonItem = backButton
let titleView = UILabel()
titleView.text = "invite"
titleView.textColor = .white
titleView.font = UIFont.Fiesta.Black.header
navigationItem.titleView = titleView
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
// definesPresentationContext = true
navigationItem.largeTitleDisplayMode = .never
navigationController?.navigationBar.isTranslucent = true
extendedLayoutIncludesOpaqueBars = true
collectionView.register(InvitesCell.self, forCellWithReuseIdentifier: InvitesCell.reuseIdentifier)
collectionView.register(InvitedCell.self, forCellWithReuseIdentifier: InvitedCell.reuseIdentifier)
collectionView.register(InvitesSectionHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: InvitesSectionHeaderReusableView.reuseIdentifier)
collectionView.delegate = self
collectionView.dataSource = dataSource
dataController.cellPressed = { [weak self] in
self?.update()
}
dataController.start()
update(animate: false)
view.backgroundColor = .secondarySystemBackground
collectionView.backgroundColor = .secondarySystemBackground
}
}
extension PartyInvitesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
// cell.contentView.setNeedsLayout()
// cell.contentView.layoutIfNeeded()
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if indexPath.section == InvitesSection.unselected.rawValue {
let viewModel = dataController.getAll()[indexPath.item]
dataController.didSelect(viewModel, completion: nil)
}
}
}
extension PartyInvitesViewController {
func update(animate: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<InvitesSection, InvitesCellViewModel>()
snapshot.appendSections(InvitesSection.allCases)
snapshot.appendItems(dataController.getTopSelected(), toSection: .selected)
snapshot.appendItems(dataController.getSelected(), toSection: .unselected)
snapshot.appendItems(dataController.getUnselected(), toSection: .unselected)
dataSource.apply(snapshot, animatingDifferences: animate) {
// self.collectionView.reloadData()
// self.collectionView.collectionViewLayout.invalidateLayout()
}
}
}
extension PartyInvitesViewController {
private func createDataSource() -> InvitesCollectionViewDataSource {
let dataSource = InvitesCollectionViewDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, viewModel -> UICollectionViewCell? in
switch indexPath.section {
case InvitesSection.selected.rawValue:
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InvitedCell.reuseIdentifier, for: indexPath) as? InvitedCell else { return nil }
cell.configure(with: viewModel)
cell.onDidCancel = { self.dataController.didSelect(viewModel, completion: nil) }
return cell
case InvitesSection.unselected.rawValue:
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InvitesCell.reuseIdentifier, for: indexPath) as? InvitesCell else { return nil }
cell.configure(with: viewModel)
return cell
default:
return nil
}
})
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath -> UICollectionReusableView? in
guard kind == UICollectionView.elementKindSectionHeader else { return nil }
guard let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: InvitesSectionHeaderReusableView.reuseIdentifier, for: indexPath) as? InvitesSectionHeaderReusableView else { return nil }
switch indexPath.section {
case InvitesSection.selected.rawValue:
view.titleLabel.text = "Inviting"
case InvitesSection.unselected.rawValue:
view.titleLabel.text = "Suggested"
default: return nil
}
return view
}
return dataSource
}
}
extension PartyInvitesViewController {
private func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout { section, _ -> NSCollectionLayoutSection? in
switch section {
case InvitesSection.selected.rawValue:
return self.createSelectedSection()
case InvitesSection.unselected.rawValue:
return self.createUnselectedSection()
default: return nil
}
}
return layout
}
private func createSelectedSection() -> NSCollectionLayoutSection {
let width: CGFloat = 120
let height: CGFloat = 60
let layoutSize = NSCollectionLayoutSize(widthDimension: .estimated(width), heightDimension: .absolute(height))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, subitems: [item])
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [sectionHeader]
section.orthogonalScrollingBehavior = .continuous
// for some reason content insets breaks the estimation process idk why
section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
section.interGroupSpacing = 20
return section
}
private func createUnselectedSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [sectionHeader]
section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
section.interGroupSpacing = 20
return section
}
}
Invites Cell (First Cell Type)
class InvitesCell: FiestaGenericCell {
static let reuseIdentifier = "InvitesCell"
var stackView = UIStackView()
var userStackView = UIStackView()
var userImageView = UIImageView()
var nameStackView = UIStackView()
var usernameLabel = UILabel()
var nameLabel = UILabel()
var inviteButton = UIButton()
override func layoutSubviews() {
super.layoutSubviews()
userImageView.layer.cornerRadius = 28
}
override func arrangeSubviews() {
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
stackView.addArrangedSubview(userStackView)
stackView.addArrangedSubview(inviteButton)
userStackView.addArrangedSubview(userImageView)
userStackView.addArrangedSubview(nameStackView)
nameStackView.addArrangedSubview(usernameLabel)
nameStackView.addArrangedSubview(nameLabel)
setNeedsUpdateConstraints()
}
override func loadConstraints() {
// Stack view constraints
NSLayoutConstraint.activate([
stackView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
stackView.heightAnchor.constraint(equalTo: contentView.heightAnchor)
])
// User image view constraints
NSLayoutConstraint.activate([
userImageView.heightAnchor.constraint(equalToConstant: 56),
userImageView.widthAnchor.constraint(equalToConstant: 56)
])
}
override func configureSubviews() {
// Stack view configuration
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .equalSpacing
// User stack view configuration
userStackView.axis = .horizontal
userStackView.alignment = .center
userStackView.spacing = Constants.inset
// User image view configuration
userImageView.image = UIImage(named: "Image-4")
userImageView.contentMode = .scaleAspectFill
userImageView.clipsToBounds = true
// Name stack view configuration
nameStackView.axis = .vertical
nameStackView.alignment = .leading
nameStackView.spacing = 4
nameStackView.distribution = .fillProportionally
// Username label configuration
usernameLabel.textColor = .white
usernameLabel.font = UIFont.Fiesta.Black.text
// Name label configuration
nameLabel.textColor = .white
nameLabel.font = UIFont.Fiesta.Light.footnote
// Invite button configuration
let configuration = UIImage.SymbolConfiguration(weight: .heavy)
inviteButton.setImage(UIImage(systemName: "circle", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .white
}
}
extension InvitesCell {
func configure(with viewModel: InvitesCellViewModel) {
usernameLabel.text = viewModel.username
nameLabel.text = viewModel.name
let configuration = UIImage.SymbolConfiguration(weight: .heavy)
if viewModel.isSelected {
inviteButton.setImage(UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .green
} else {
inviteButton.setImage(UIImage(systemName: "circle", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .white
}
}
}
Invited Cell (Second Cell Type)
import UIKit
class InvitedCell: FiestaGenericCell {
static let reuseIdentifier = "InvitedCell"
var mainView = UIView()
var usernameLabel = UILabel()
// var cancelButton = UIButton()
var onDidCancel: (() -> Void)?
override func layoutSubviews() {
super.layoutSubviews()
mainView.layer.cornerRadius = 8
}
override func arrangeSubviews() {
mainView.translatesAutoresizingMaskIntoConstraints = false
usernameLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(mainView)
mainView.addSubview(usernameLabel)
}
override func loadConstraints() {
// Main view constraints
NSLayoutConstraint.activate([
mainView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
mainView.heightAnchor.constraint(equalTo: contentView.heightAnchor)
])
// Username label constraints
NSLayoutConstraint.activate([
usernameLabel.topAnchor.constraint(equalTo: mainView.topAnchor, constant: 20),
usernameLabel.leftAnchor.constraint(equalTo: mainView.leftAnchor, constant: 20),
usernameLabel.rightAnchor.constraint(equalTo: mainView.rightAnchor, constant: -20),
usernameLabel.bottomAnchor.constraint(equalTo: mainView.bottomAnchor, constant: -20)
])
}
override func configureSubviews() {
// Main view configuration
mainView.backgroundColor = .tertiarySystemBackground
// Username label configuration
usernameLabel.textColor = .white
usernameLabel.font = UIFont.Fiesta.Black.text
}
}
extension InvitedCell {
func configure(with viewModel: InvitesCellViewModel) {
usernameLabel.text = viewModel.username
}
#objc func cancel() {
onDidCancel?()
}
}
Invites Cell View Model (model for the cells)
import Foundation
struct InvitesCellViewModel {
var id = UUID()
private var model: User
init(_ model: User, selected: Bool) {
self.model = model
self.isSelected = selected
}
var username: String?
var name: String?
var isSelected: Bool
mutating func toggleIsSelected() {
isSelected = !isSelected
}
}
extension InvitesCellViewModel: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(isSelected)
}
static func == (lhs: InvitesCellViewModel, rhs: InvitesCellViewModel) -> Bool {
lhs.id == rhs.id && lhs.isSelected == rhs.isSelected
}
}
If I need to provide anything else to better assist in answering this question, please let me know in the comments!

This may not be a solution for everyone, but I ended up fully switching over to RxSwift. For those who are debating the switch, I now use RxDataSources and the UICollectionViewCompositionalLayout with virtually no problems (outside of the occasional bug or two). I know this may not be the answer most are looking for, but looking back, this issue seems to be on Apple's end, so I figured it was best to find another path. If anybody has found a solution that is simpler than completely jumping over to Rx, please feel free to add your answer as well.

Related

How to display an icon/image on uicollectionviewcell after it has been selected

I have a uiCollectionViewCell which loads image from an api. I want to display another image/icon on the cell when a user clicks on it. In my custom cell I have two images one which display the image from the URL and the second one is the one I would like to show if the user has clicked on it. I'm doing this to alert the user that they have selected that cell. Below is my sample code
protocol ModalDelegate {
func changeValue(userChoice: String, rateMovieID: String, rateImageUrl: String, title: String)
}
class GuestRateMovieView: UIViewController, ModalDelegate {
func changeValue(userChoice: String, rateMovieID: String, rateImageUrl: String, title: String) {
self.userChoice = userChoice
totalRated = totalRated + 1
lblRated.text = "\(totalRated) rated"
if totalRated > 0 {
ratedView.backgroundColor = .ratedGoldColour
}else{
ratedView.backgroundColor = .white
}
if totalRated >= 5 {
btnFloatNext.alpha = 1
}
if totalRated > 5 {
userChoiceMovieImage.sd_setImage(with: URL(string: rateImageUrl), placeholderImage: UIImage(named: "ImagePlaceholder"))
lblUserChoice.text = "Great taste. We love the \(title) too."
}
var rating = 1
if userChoice == "Hate it"{
rating = 1
}else if userChoice == "Good" {
rating = 3
}else{
rating = 5
}
let guestRatingValues = GuestUserRate(id: rateMovieID, imageUrl: rateImageUrl, userRate: rating)
GuestRateMovieView.createUserRating(guestRatingValues) =>", rateImageUrl)
print("Received on movie", totalRated)
}
func getMovieDetails(){
activityLoader.displayActivityLoader(image: activityLoader, view: activityLoaderView)
let urlString = "https://t2fmmm2hfg.execute-api.eu-west-2.amazonaws.com/mobile/media/onboarding-items"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
let postParameters: Dictionary<String, Any> = [
"category": "tv"
]
if let postData = (try? JSONSerialization.data(withJSONObject: postParameters, options: JSONSerialization.WritingOptions.prettyPrinted)){
request.httpBody = postData
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let data = data, error == nil else {
return
}
do {
print("got data")
let jsonResult = try JSONDecoder().decode([Responder].self, from: data)
DispatchQueue.main.async {
self?.movieObj = jsonResult
self?.moviesCollectionView.reloadData()
self?.activityLoader.removeActivityLoader(image: self!.activityLoader, view: self!.activityLoaderView)
}
// jsonResult.forEach { course in print(course.type) }
}catch {
print(error)
}
}
task.resume()
}
}
var movieObj: [Responder] = []
override func viewDidLoad() {
super.viewDidLoad()
getMovieDetails()
}
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return movieObj.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reUseMoviesCellID, for: indexPath) as! MoviesCollectionCell
cell.movieImage.image = nil
cell.configure(with: movieObj[indexPath.row].packShot?.thumbnail ?? ""
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let modalVC = RateSingleMovieView()
modalVC.movieID = movieObj[indexPath.row].id
modalVC.movieTitle = movieObj[indexPath.row].title
modalVC.movieImageURL = movieObj[indexPath.row].packShot?.thumbnail ?? ""
modalVC.delegate = self
modalVC.modalPresentationStyle = .overCurrentContext
modalVC.modalTransitionStyle = .crossDissolve
present(modalVC, animated: true, completion: nil)
}
class MoviesCollectionCell: UICollectionViewCell {
private var movieImages = NSCache<NSString, NSData>()
weak var textLabel: UILabel!
let movieImage: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.layer.cornerRadius = 10
return image
}()
let btnRate: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFit
image.alpha = 0
return image
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(movieImage)
movieImage.addSubview(btnRate)
NSLayoutConstraint.activate([
movieImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
movieImage.topAnchor.constraint(equalTo: contentView.topAnchor),
movieImage.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
movieImage.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
btnRate.centerXAnchor.constraint(equalTo: movieImage.centerXAnchor),
btnRate.centerYAnchor.constraint(equalTo: movieImage.centerYAnchor),
btnRate.widthAnchor.constraint(equalToConstant: 30),
btnRate.heightAnchor.constraint(equalToConstant: 30)
])
btnRate.tintColor = .white
btnRate.layer.shadowColor = UIColor.black.cgColor
btnRate.layer.shadowOffset = CGSize(width: 1.0, height: 2.0)
btnRate.layer.shadowRadius = 2
btnRate.layer.shadowOpacity = 0.8
btnRate.layer.masksToBounds = false
}
override func prepareForReuse() {
super.prepareForReuse()
movieImage.image = nil
btnRate.image = nil
}
func configure(with urlString: String, ratingObj: MovieRating){
movieImage.sd_setImage(with: URL(string: urlString), placeholderImage: UIImage(named: "ImagePlaceholder"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Now in my modal where the user will rate. In my case I'm using swipe gesture for the rating
class RateSingleMovieView: UIViewController, ModalDelegate, ModalSearchDelegate {
func changeValue(userChoice: String, rateMovieID: String, rateImageUrl: String, title: String) {
self.userChoice = userChoice
totalRated = totalRated + 1
}
var delegate: ModalDelegate?
override func viewDidLoad() {
super.viewDidLoad()
redBottomView.addGestureRecognizer(createSwipeGestureRecognizer(for: .up))redBottomView.addGestureRecognizer(createSwipeGestureRecognizer(for: .left))redBottomView.addGestureRecognizer(createSwipeGestureRecognizer(for: .right))
}
#objc private func didSwipe(_ sender: UISwipeGestureRecognizer) {
switch sender.direction {
case .up:
showUserRatingSelection(userChoice: "Good")
case .left:
showUserRatingSelection(userChoice: "Hate it")
case .right:
showUserRatingSelection(userChoice: "Love it")
default:
break
}
}
#objc private func removeModal(){
dismiss(animated: true, completion: nil)
}
private func showUserRatingSelection(userChoice: String){
self.hateItView.alpha = 1
if userChoice == "Hate it"{
userChoiceEmoji.image = UIImage(named: "HateIt")
lblRate.text = "Hate it"
}else if userChoice == "Good" {
userChoiceEmoji.image = UIImage(named: "goodRate")
lblRate.text = "Good"
}else{
userChoiceEmoji.image = UIImage(named: "LoveIt")
lblRate.text = "Love it"
}
userChoiceEmoji.alpha = 1
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
print("hello ", userChoice)
self.delegate?.changeValue(userChoice: userChoice, rateMovieID: self.movieID!, rateImageUrl: self.movieImageURL!, title: self.movieTitle!)
self.removeModal()
}
}
}
I am able to use the delegate here to send info back to GuestRateMovieView Controller and update a label there. Now my only problem is displaying the icon on the selected cell with the user choice.
First note... setup your constraints in init -- Absolutely NOT in layoutSubviews().
Edit -- forget everything else previously here, because it had nothing to do with what you're actually trying to accomplish.
New Answer
To clarify your goal:
display a collection view of objects - in this case, movies
when the user selects a cell, show a "Rate This Movie" view
when the user selects a Rating (hate, good, love), save that rating and update the cell with a "Rating Image"
So, the first thing you need is a data structure that includes a "rating" value. Let's use an enum for the rating itself:
enum MovieRating: Int {
case none, hate, good, love
}
Then we might have a "Movie Object" like this:
struct MovieObject {
var title: String = ""
var urlString: String = ""
var rating: MovieRating = .none
// maybe some other properties
}
For our data, we'll have an Array of MovieObject. When we configure each cell (in cellForItemAt), we need to set the Movie Image and the Rating Image.
So, your cell class may have this:
func configure(with movieObj: MovieObject) {
movieImage.sd_setImage(with: URL(string: movieObj.urlString), placeholderImage: UIImage(named: "ImagePlaceholder"))
switch movieObj.rating {
case .hate:
if let img = UIImage(systemName: "hand.thumbsdown") {
btnRate.image = img
}
case .good:
if let img = UIImage(systemName: "face.smiling") {
btnRate.image = img
}
case .love:
if let img = UIImage(systemName: "hand.thumbsup") {
btnRate.image = img
}
default:
btnRate.image = nil
}
}
and your cellForItemAt would look like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath) as! MoviesCollectionCell
c.configure(with: moviesArray[indexPath.row])
return c
}
When the user selects a cell, we can present a "Rate This Movie" view controller - which will have buttons for Hate / Good / Love.
If the user taps one of those buttons, we can use a closure to update the data and reload that cell:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let vc = RateTheMovieVC()
vc.movieObj = moviesArray[indexPath.item]
vc.callback = { [weak self] rating in
guard let self = self else { return }
// update the data
self.moviesArray[indexPath.item].rating = rating
// reload the cell
self.collectionView.reloadItems(at: [indexPath])
// dismiss the RateTheMovie view controller
self.dismiss(animated: true)
}
// present the RateTheMovie view controller
present(vc, animated: true)
}
Here's a complete example... I don't have your data (movie names, images, etc), so we'll use an array of "Movie Titles" from A to Z, and the cells will look like this:
and so on.
enum and struct
enum MovieRating: Int {
case none, hate, good, love
}
struct MovieObject {
var title: String = ""
var urlString: String = ""
var rating: MovieRating = .none
}
collection view cell
class MoviesCollectionCell: UICollectionViewCell {
let movieImage: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.layer.cornerRadius = 10
image.backgroundColor = .blue
return image
}()
let btnRate: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFit
return image
}()
// we don't have Movie Images for this example, so
// we'll use some labels for the Movie Title
var labels: [UILabel] = []
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(movieImage)
for _ in 0..<4 {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.textAlignment = .center
v.textColor = .cyan
if let f = UIFont(name: "TimesNewRomanPS-BoldMT", size: 60) {
v.font = f
}
contentView.addSubview(v)
labels.append(v)
}
// stack views for the labels
let stTop = UIStackView()
stTop.axis = .horizontal
stTop.distribution = .fillEqually
stTop.addArrangedSubview(labels[0])
stTop.addArrangedSubview(labels[1])
let stBot = UIStackView()
stBot.axis = .horizontal
stBot.distribution = .fillEqually
stBot.addArrangedSubview(labels[2])
stBot.addArrangedSubview(labels[3])
let st = UIStackView()
st.axis = .vertical
st.distribution = .fillEqually
st.addArrangedSubview(stTop)
st.addArrangedSubview(stBot)
st.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(st)
contentView.addSubview(btnRate)
// setup constriaints here
NSLayoutConstraint.activate([
movieImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
movieImage.topAnchor.constraint(equalTo: contentView.topAnchor),
movieImage.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
movieImage.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
st.topAnchor.constraint(equalTo: movieImage.topAnchor),
st.leadingAnchor.constraint(equalTo: movieImage.leadingAnchor),
st.trailingAnchor.constraint(equalTo: movieImage.trailingAnchor),
st.bottomAnchor.constraint(equalTo: movieImage.bottomAnchor),
btnRate.centerXAnchor.constraint(equalTo: movieImage.centerXAnchor),
btnRate.centerYAnchor.constraint(equalTo: movieImage.centerYAnchor),
btnRate.widthAnchor.constraint(equalToConstant: 40),
btnRate.heightAnchor.constraint(equalToConstant: 40)
])
btnRate.tintColor = .white
btnRate.layer.shadowColor = UIColor.black.cgColor
btnRate.layer.shadowOffset = CGSize(width: 1.0, height: 2.0)
btnRate.layer.shadowRadius = 2
btnRate.layer.shadowOpacity = 0.8
btnRate.layer.masksToBounds = false
}
override func prepareForReuse() {
super.prepareForReuse()
movieImage.image = nil
}
func configure(with movieObj: MovieObject) {
// I don't have your cell images, or the "sd_setImage" function
// un-comment the next line to set your images
// movieImage.sd_setImage(with: URL(string: movieObj.urlString), placeholderImage: UIImage(named: "ImagePlaceholder"))
labels.forEach { v in
v.text = movieObj.title
}
switch movieObj.rating {
case .hate:
if let img = UIImage(systemName: "hand.thumbsdown") {
btnRate.image = img
}
case .good:
if let img = UIImage(systemName: "face.smiling") {
btnRate.image = img
}
case .love:
if let img = UIImage(systemName: "hand.thumbsup") {
btnRate.image = img
}
default:
btnRate.image = nil
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
example view controller
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
var collectionView: UICollectionView!
var moviesArray: [MovieObject] = []
override func viewDidLoad() {
super.viewDidLoad()
let fl = UICollectionViewFlowLayout()
fl.itemSize = CGSize(width: 100.0, height: 200.0)
fl.scrollDirection = .vertical
fl.minimumLineSpacing = 8
fl.minimumInteritemSpacing = 8
collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
])
collectionView.register(MoviesCollectionCell.self, forCellWithReuseIdentifier: "c")
collectionView.dataSource = self
collectionView.delegate = self
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// let's change the collection view cell size to fit
// two "columns"
if let fl = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
fl.itemSize = CGSize(width: (collectionView.frame.width - fl.minimumInteritemSpacing) * 0.5, height: 200.0)
}
simulateGettingData()
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return moviesArray.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath) as! MoviesCollectionCell
c.configure(with: moviesArray[indexPath.row])
return c
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let vc = RateTheMovieVC()
vc.movieObj = moviesArray[indexPath.item]
vc.callback = { [weak self] rating in
guard let self = self else { return }
// update the data
self.moviesArray[indexPath.item].rating = rating
// reload the cell
self.collectionView.reloadItems(at: [indexPath])
// dismiss the RateTheMovie view controller
self.dismiss(animated: true)
}
// present the RateTheMovie view controller
present(vc, animated: true)
}
func simulateGettingData() {
// let's just create an array of MovieObject
// where each Title will be a letter from A to Z
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".forEach { c in
let m = MovieObject(title: String(c), urlString: "", rating: .none)
moviesArray.append(m)
}
collectionView.reloadData()
}
}
example "Rate The Movie" view controller
class RateTheMovieVC: UIViewController {
// this will be used to tell the presenting controller
// that a rating button was selected
var callback: ((MovieRating) -> ())?
var movieObj: MovieObject!
let movieImage: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.layer.cornerRadius = 10
image.backgroundColor = .systemBlue
return image
}()
// we don't have Movie Images for this example, so
// we'll use a label for the "Movie Title"
let titleLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.numberOfLines = 0
v.textAlignment = .center
v.textColor = .yellow
v.font = .systemFont(ofSize: 240, weight: .bold)
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .lightGray
// let's add 3 "rate" buttons near the bottom
let btnHate = UIButton()
let btnGood = UIButton()
let btnLove = UIButton()
let btns: [UIButton] = [btnHate, btnGood, btnLove]
let names: [String] = ["hand.thumbsdown", "face.smiling", "hand.thumbsup"]
for (b, s) in zip(btns, names) {
b.backgroundColor = .systemRed
b.layer.cornerRadius = 8
b.layer.masksToBounds = true
if let img = UIImage(systemName: s, withConfiguration: UIImage.SymbolConfiguration(pointSize: 32)) {
b.setImage(img, for: [])
}
b.tintColor = .white
b.heightAnchor.constraint(equalToConstant: 60.0).isActive = true
}
let btnStack = UIStackView()
btnStack.spacing = 20
btnStack.distribution = .fillEqually
btns.forEach { b in
btnStack.addArrangedSubview(b)
}
view.addSubview(movieImage)
view.addSubview(titleLabel)
btnStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btnStack)
// setup constriaints here
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
btnStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
btnStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
btnStack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
titleLabel.topAnchor.constraint(equalTo: movieImage.topAnchor, constant: 8.0),
titleLabel.leadingAnchor.constraint(equalTo: movieImage.leadingAnchor, constant: 12.0),
titleLabel.trailingAnchor.constraint(equalTo: movieImage.trailingAnchor, constant: -12.0),
titleLabel.bottomAnchor.constraint(equalTo: movieImage.bottomAnchor, constant: -8.0),
movieImage.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
movieImage.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
movieImage.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
movieImage.bottomAnchor.constraint(equalTo: btnStack.topAnchor, constant: -20.0),
])
// here we would set the movie image
// set the title label text, since we don't have images right now
titleLabel.text = movieObj.title
btnHate.addTarget(self, action: #selector(hateTap(_:)), for: .touchUpInside)
btnGood.addTarget(self, action: #selector(goodTap(_:)), for: .touchUpInside)
btnLove.addTarget(self, action: #selector(loveTap(_:)), for: .touchUpInside)
}
#objc func hateTap(_ sender: UIButton) {
callback?(.hate)
}
#objc func goodTap(_ sender: UIButton) {
callback?(.good)
}
#objc func loveTap(_ sender: UIButton) {
callback?(.love)
}
}
It will look like this on launch:
Then we select the first cell and we see this:
We select the "Thumbs Up" button, and we see this:
Then scroll down and select-and-rate a few other cells:
You mention in a comment a "cache DB" ... assuming that will be persistent data, it's up to you to store the user-selected Rating.
in your MoviesCollectionCell file put function like this
func loadImageAfterClickingCell() {
// TODO: check this cell is already clicked and already download the image like if yourImageView == nil or not nil so you can add guard like guard yourImageView.image == nil else { return } similar to this
guard let preparedUrl = URL(string: urlString) else { return }
yourImageView.alpha = 1
yourImageView.sd_setImage(with: preparedUrl, placeholderImage: UIImage(named: "ImagePlaceholder"))
}
And after that in didSelectItemAt
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reUseMoviesCellID, for: indexPath) as! MoviesCollectionCell
cell.loadImageAfterClickingCell()
}
A simple method injection like this must save you as you want.

Simple orthogonal collection layout, but has odd behaviour on tvOS. Not sure if it's me?

My ViewController loads and lays out correctly. This is a pretty simple layout! Works as expected on iOS, but I have weird issues on tvOS.
So - at first all looks good. But when the user scrolls down, then scrolls back to the top - the collectionview stays partially under the titlebar. Focus then moves to the barItem, which is correct - but the top section header is under the bar. If the user then scrolls the top section right (to load new cells) - the layout gets fixed. Once this has happened the whole view works as expected.
Some code:
class ViewController: UIViewController {
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Int, String>!
override var preferredFocusEnvironments: [UIFocusEnvironment] {
return [collectionView]
}
override func viewDidLoad() {
super.viewDidLoad()
setup()
makeDataSource()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationItem.title = "Some Title"
navigationItem.leftBarButtonItem = .init(systemItem: .refresh)
applySnapshot()
}
// Hack..
override func viewSafeAreaInsetsDidChange() {
collectionView.contentInset.top = view.safeAreaInsets.top
collectionView.layoutMargins = .init(top: 0, left: view.safeAreaInsets.left, bottom: 0, right: view.safeAreaInsets.right)
}
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: TestLayout.makeSampleLayout() )
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.contentInsetAdjustmentBehavior = .never
collectionView.insetsLayoutMarginsFromSafeArea = false
// Some margins to move headers from sides
collectionView.layoutMargins = .init(top: 0, left: view.safeAreaInsets.left, bottom: 0, right: view.safeAreaInsets.right)
// Some (any) space on top contentInset
//collectionView.contentInset.top = 150
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
self.collectionView = collectionView
}
func applySnapshot() {
var snap = NSDiffableDataSourceSnapshot<Int, String>()
snap.appendSections([1])
snap.appendItems(["item1-1", "item1-2", "item1-3", "item1-4", "item1-5", "item1-6", "item1-7", "item1-8", "item1-9"])
snap.appendSections([2])
snap.appendItems(["item2-1", "item2-2", "item2-3", "item2-4"])
snap.appendSections([3])
snap.appendItems(["item3-1", "item3-2", "item3-3", "item3-4", "item3-5"])
snap.appendSections([4])
snap.appendItems(["item4-1", "item4-2", "item4-3", "item4-4", "item4-5"])
snap.appendSections([5])
snap.appendItems(["item5-1", "item5-2", "item5-3", "item5-4", "item5-5"])
snap.appendSections([6])
snap.appendItems(["item6-1", "item6-2", "item6-3"])
dataSource.apply(snap, animatingDifferences: false)
}
func makeDataSource() {
let textHeader = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader,
handler: { (cell: FixedHeaderView, string: String, indexPath: IndexPath) in
})
let testCell = UICollectionView.CellRegistration(handler: { (cell: TestCell, indexPath: IndexPath, itemIdentifier: String) in
})
// Make dataSource and supplementaryViewProvider
dataSource = UICollectionViewDiffableDataSource<Int, String> (collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
return collectionView.dequeueConfiguredReusableCell(using: testCell, for: indexPath, item: itemIdentifier)
})
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
switch kind {
case UICollectionView.elementKindSectionHeader:
return collectionView.dequeueConfiguredReusableSupplementary(using: textHeader, for: indexPath)
default:
assertionFailure("Unknown SupplementaryView")
return nil
}
}
}
}
class TestLayout {
// Return a standard section header
static func standardSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(78) )
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top)
header.extendsBoundary = true
return header
}
static func orthogonalSectionInsets() -> UIEdgeInsets {
return .init(top: 38, left: 0, bottom: 38, right: 0)
}
static func orthogonalSectionSpacing() -> CGFloat {
return 52
}
static func orthogonalSection() -> NSCollectionLayoutSection? {
let insets = orthogonalSectionInsets()
let itemSpacing = orthogonalSectionSpacing()
let viewItems = 6 + 0.5
let heightDimension = NSCollectionLayoutDimension.estimated(1)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1 / viewItems),
heightDimension: heightDimension)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// iOS15
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitem: item, count: 1)
// iOS16 - weird output?
//let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, repeatingSubitem: item, count: 1)
let section = NSCollectionLayoutSection(group: group)
// Add space to section sides, and between headers
section.contentInsets = NSDirectionalEdgeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right)
section.contentInsetsReference = .layoutMargins
// Add space between horizontal items
section.interGroupSpacing = itemSpacing
section.orthogonalScrollingBehavior = .continuous
// Header
section.boundarySupplementaryItems = [standardSectionHeader() ]
section.supplementaryContentInsetsReference = .layoutMargins
return section
}
static func makeSampleLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionNumber, env)
-> NSCollectionLayoutSection? in
return orthogonalSection()
}
// Top level layout settings
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.scrollDirection = .vertical
layout.configuration = layoutConfig
return layout
}
}
class TestCell: UICollectionViewCell {
let someView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
insetsLayoutMarginsFromSafeArea = false
someView.translatesAutoresizingMaskIntoConstraints = false
someView.backgroundColor = .red
// We shouldn't be able to see this if margins are correct
contentView.backgroundColor = .yellow
someView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
someView.setContentHuggingPriority(.defaultHigh, for: .vertical)
contentView.addSubview(someView)
let bottom: NSLayoutConstraint = someView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
bottom.priority = .defaultHigh // .init(998)
NSLayoutConstraint.activate([
someView.topAnchor.constraint(equalTo: contentView.topAnchor),
someView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
someView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
someView.heightAnchor.constraint(equalTo: someView.widthAnchor),
bottom
])
}
// Some visible focus so we can see what is going on
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if (context.nextFocusedView == self) {
someView.backgroundColor = .green
}
else if (context.previouslyFocusedView == self) {
someView.backgroundColor = .red
}
}
}
class FixedHeaderView: UICollectionReusableView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = .none
label.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
label.setContentHuggingPriority(.defaultHigh, for: .vertical)
label.textColor = .white
label.backgroundColor = .red
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 40, weight: .heavy)
label.text = "Some Header Title"
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor)
])
backgroundColor = .red.withAlphaComponent(0.25)
}
}
AppDelegate:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// Create the window
let newWindow = UIWindow(frame: UIScreen.main.bounds)
let nav = UINavigationController(rootViewController: ViewController() )
newWindow.rootViewController = nav
newWindow.makeKeyAndVisible()
self.window = newWindow
return true
}
I've tried using all sorts of different margin and safeArea layouts, but this issue appears to affect any layout that requires the content to sit off the top of the screen.
Sometimes it loads and works perfectly first time, making me think it's a race condition or something.
Cells are dynamic height (because I need to support a lot of cell types). If I set the height to 'absolute' values, it seems to work - but this would create a lot of work and I'd lose accessibility such as dynamic text sizing.
But I've been at this for 2 days, nudging code and reading docs. I don't do much tvOS and I'm a sole developer (so I don't have any other devs to bounce off) - would really appreciate some input!! Thanks

collectionview global header does not push cells down and stays on top instead

I have a collection view header (blue) and cells (red). I want to be able to show/hide header programmatically, however when I show the header programatically it appears on top of the cell (or makes scrollview go down a bit). I would like the header to push the whole scrollview down, so I wouldn't have to scroll up after clicking "Toggle header".
I tried very hard to reproduce minimal code for the issue which is below. Please share any insights.
struct Section: Hashable {
var items: [Int]
}
class ViewController: UIViewController {
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>?
var showHeader = true
override func viewDidLoad() {
super.viewDidLoad()
collectionView = UICollectionView(frame: view.frame, collectionViewLayout: createCompositionalLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
collectionView.register(CellView.self, forCellWithReuseIdentifier: "cellId")
collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "headerId")
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
createDataSource()
addData()
}
func createCompositionalLayout() -> UICollectionViewLayout {
// layout for cell
let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(200))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
return layoutSection
}
let config = UICollectionViewCompositionalLayoutConfiguration()
if showHeader {
let layoutSectionHeader = createGlobalHeader()
config.boundarySupplementaryItems = [layoutSectionHeader]
}
layout.configuration = config
return layout
}
func createGlobalHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
let layoutSectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50))
let layoutSectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: layoutSectionHeaderSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
layoutSectionHeader.pinToVisibleBounds = true
return layoutSectionHeader
}
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as? CellView else { fatalError("Unable to dequeue ") }
cell.button.addTarget(self, action: #selector(self.onButtonClick), for: .touchUpInside)
return cell
}
dataSource?.supplementaryViewProvider = { (collectionView, kind, indexPath) in
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "headerId", for: indexPath) as? HeaderView else { return nil }
return header
}
}
#objc func onButtonClick() {
print("toggle")
showHeader.toggle()
collectionView.collectionViewLayout = createCompositionalLayout()
collectionView.layoutIfNeeded()
}
func addData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
var sections: [Section] = []
sections.append(Section(items: [0,1,2,3,4,5,6,7,8,9,10,11,12]))
snapshot.appendSections(sections)
for section in sections {
snapshot.appendItems(section.items, toSection: section)
}
dataSource?.apply(snapshot)
}
}
class CellView: UICollectionViewCell {
let button = UIButton()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .red
addSubview(button)
button.setTitle("Toggle header", for: .normal)
button.setTitleColor(.black, for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
button.topAnchor.constraint(equalTo: topAnchor).isActive = true
button.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class HeaderView: UICollectionReusableView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .blue
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
If I got your question right, you can scroll the collection view to top with:
collectionView.setContentOffset(.zero, animated: false)
whenever you want to display header.
It's a work around but I think it should do the trick.
I think you need to embed the collection view and a regular view(that you will use as a header) in a Stack View, and programmatically hide and show the header view. This will make the collectionView expand in the stack view when the header is hidden and vise-versa. I would completely abandon the UICollectionReusableView header view. So the view hierarchy would look something like this:
StackView
HeaderView
CollectionView
I hope I explained myself clear enough.

UICollectionView with Compositional Layout & Diff Datasources disappears on scroll

I currently have a UICollectionView which I'm using compositional layouts and Diffable Data sources. I'm not doing anything crazy just loading 150 cells into it and defining a 4 column layout. I seem to be encountering some weird behaviour whenever I tap the collectionview to scroll the items it seems to be disappearing... Below is my file with the entire code that I'm using so that you can copy & paste this and see this weird behaviour. Does anyone know why this might happening?
import UIKit
class ViewController: UIViewController {
private lazy var myCollectionViewLayout = MyCollectionViewLayout()
override func viewDidLoad() {
super.viewDidLoad()
setup()
// Do any additional setup after loading the view.
}
}
private extension ViewController {
func setup() {
let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: myCollectionViewLayout.createLayout())
collectionVw.translatesAutoresizingMaskIntoConstraints = false
collectionVw.register(MyCustomCell.self, forCellWithReuseIdentifier: MyCustomCell.cellId)
self.view.addSubview(collectionVw)
NSLayoutConstraint.activate([
collectionVw.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
collectionVw.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
collectionVw.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
collectionVw.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
let dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
cell?.configure(at: indexPath.row)
return cell
}
collectionVw.dataSource = dataSource
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
Range(0...150).forEach { item in
snapshot.appendItems([UUID()], toSection: 0)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
class MyCollectionViewLayout {
func createLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnv: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalHeight(1))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(0.25))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitem: item,
count: 4)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 0,
leading: 16,
bottom: 0,
trailing: 16)
return section
}
return layout
}
}
class MyCustomCell: UICollectionViewCell {
static let cellId = "MyCustomCell"
private var lbl: UILabel?
func configure(at index: Int) {
self.contentView.layer.cornerRadius = 8
self.contentView.backgroundColor = .blue
lbl = UILabel()
lbl?.translatesAutoresizingMaskIntoConstraints = false
lbl?.text = index.description
lbl?.textAlignment = .center
lbl?.font = .preferredFont(forTextStyle: .headline)
self.contentView.addSubview(lbl!)
NSLayoutConstraint.activate([
lbl!.topAnchor.constraint(equalTo: self.contentView.topAnchor),
lbl!.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
lbl!.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
lbl!.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)
])
}
override func prepareForReuse() {
super.prepareForReuse()
lbl?.removeFromSuperview()
}
}
The reason is that your data source is a local variable inside your setup method (let dataSource...). Therefore it works just the one time to populate the collection view initially, and then vanishes in a puff of smoke, leaving the collection view with no data.
The data source needs to be a property of the view controller so that it persists! Once you make that change, all will be well.
It's actually a little tricky to do that, because the data source depends on the collection view. So you have to use lazy instantiation. You create the collection view with an empty layout and hook everything together in the first step of the setup.
Here's a minimal rewrite of your code; look for my comments explaining the changes.
import UIKit
class ViewController: UIViewController {
// delete; this is pointless
// private lazy var myCollectionViewLayout = MyCollectionViewLayout()
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
// need persistent pointer to collection view
let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
// need persistent pointer to data source
lazy var dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
cell?.configure(at: indexPath.row)
return cell
}
}
private extension ViewController {
func setup() {
// let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: myCollectionViewLayout.createLayout())
// everything is persistent, now just hook them together
collectionVw.collectionViewLayout = MyCollectionViewLayoutMaker.createLayout()
collectionVw.translatesAutoresizingMaskIntoConstraints = false
collectionVw.register(MyCustomCell.self, forCellWithReuseIdentifier: MyCustomCell.cellId)
self.view.addSubview(collectionVw)
NSLayoutConstraint.activate([
collectionVw.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
collectionVw.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
collectionVw.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
collectionVw.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
// let dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
//
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
// cell?.configure(at: indexPath.row)
// return cell
// }
collectionVw.dataSource = dataSource
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
Range(0...150).forEach { item in
snapshot.appendItems([UUID()], toSection: 0)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
class MyCollectionViewLayoutMaker {
// make this static; the class is not the layout, it serves no purpose except as a factory
static func createLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnv: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalHeight(1))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(0.25))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitem: item,
count: 4)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 0,
leading: 16,
bottom: 0,
trailing: 16)
return section
}
return layout
}
}
class MyCustomCell: UICollectionViewCell {
static let cellId = "MyCustomCell"
private var lbl: UILabel?
func configure(at index: Int) {
self.contentView.layer.cornerRadius = 8
self.contentView.backgroundColor = .blue
lbl = UILabel()
lbl?.translatesAutoresizingMaskIntoConstraints = false
lbl?.text = index.description
lbl?.textAlignment = .center
lbl?.font = .preferredFont(forTextStyle: .headline)
self.contentView.addSubview(lbl!)
NSLayoutConstraint.activate([
lbl!.topAnchor.constraint(equalTo: self.contentView.topAnchor),
lbl!.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
lbl!.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
lbl!.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)
])
}
override func prepareForReuse() {
super.prepareForReuse()
lbl?.removeFromSuperview()
}
}

UICollectionCellView delegate not being fired

A UICollectionView I have created doesn't want to fire a custom cell delegate.
The cells are displaying, and the test button accepts presses.
I have debugged the code and the cell.delegate is being assigned self.
I have researched this on google and I have come up with nothing. I think my code is ok but I must be missing something?
I would really appreciate any help.
//HomeControllerDelegateTesting.swift
import UIKit
class HomeControllerDelegateTesting: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, PostCellOptionsDelegate {
var collectionView: UICollectionView! = nil
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: self.createLayout())
self.collectionView.register(PostCell.self, forCellWithReuseIdentifier: PostCell.reuseIdentifier)
self.collectionView.backgroundColor = .systemBackground
self.view.addSubview(collectionView)
self.collectionView.dataSource = self
self.collectionView.delegate = self
}
private func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let section = NSCollectionLayoutSection(group: group)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PostCell.reuseIdentifier, for: indexPath) as? PostCell else { fatalError("Cannot create cell") }
cell.postTextLabel.text = "Test"
cell.delegate = self
//Test to eliminiate the button accepts events
//cell.optionsButtons.addTarget(self, action: #selector(test), for: .touchUpInside)
return cell
}
#objc func handlePostOptions(cell: PostCell) {
print("123")
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 3
}
}
// PostCell.swift
import UIKit
protocol PostCellOptionsDelegate: class {
func handlePostOptions(cell: PostCell)
}
class PostCell: UICollectionViewCell {
static let reuseIdentifier = "list-cell-reuse-identifier"
var delegate: PostCellOptionsDelegate?
let usernameLabel: UILabel = {
let label = UILabel()
label.text = "Username"
label.font = .boldSystemFont(ofSize: 15)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let postImageView: UIImageView = {
let control = UIImageView()
control.translatesAutoresizingMaskIntoConstraints = false
control.contentMode = .scaleAspectFill
control.clipsToBounds = true
return control
}()
let postTextLabel: UILabel = {
let label = UILabel()
label.text = "Post text spanning multiple lines"
label.font = .systemFont(ofSize: 15)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// private let optionsButtons: UIButton = {
// let control = UIButton.systemButton(with: #imageLiteral(resourceName: "post_options"), target: self, action: #selector(handleOptions))
// control.translatesAutoresizingMaskIntoConstraints = false
// return control
// }()
let optionsButtons: UIButton = {
let control = UIButton(type: .system)
control.setTitle("Test", for: .normal)
control.addTarget(self, action: #selector(handleOptions), for: .touchUpInside)
control.translatesAutoresizingMaskIntoConstraints = false
return control
}()
#objc fileprivate func handleOptions() {
print("Handle options button")
delegate?.handlePostOptions(cell: self)
}
override init(frame: CGRect) {
super.init(frame: frame)
setupComponents()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupComponents() {
self.optionsButtons.heightAnchor.constraint(equalToConstant: 50).isActive = true
let labelStack = UIStackView(arrangedSubviews: [optionsButtons])
labelStack.translatesAutoresizingMaskIntoConstraints = false
labelStack.axis = .horizontal
labelStack.alignment = .fill
labelStack.distribution = .fill
labelStack.isLayoutMarginsRelativeArrangement = true
labelStack.layoutMargins.left = 16
labelStack.layoutMargins.right = 16
labelStack.layoutMargins.top = 16
labelStack.layoutMargins.bottom = 16
let postTextLabelStack = UIStackView(arrangedSubviews: [postTextLabel])
postTextLabelStack.translatesAutoresizingMaskIntoConstraints = false
postTextLabelStack.axis = .vertical
postTextLabelStack.alignment = .fill
postTextLabelStack.distribution = .fill
postTextLabelStack.isLayoutMarginsRelativeArrangement = true
postTextLabelStack.layoutMargins.left = 16
postTextLabelStack.layoutMargins.right = 16
postTextLabelStack.layoutMargins.top = 16
postTextLabelStack.layoutMargins.bottom = 16
let stack = UIStackView(arrangedSubviews: [labelStack, postImageView, postTextLabelStack])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.alignment = .fill
stack.distribution = .fill
stack.backgroundColor = .blue
self.addSubview(stack)
//postImageView.heightAnchor.constraint(equalTo: postImageView.widthAnchor).isActive = true
stack.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
stack.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
stack.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
}
}
Change your button declaration to this:
// make this a lazy var
lazy var optionsButtons: UIButton = {
let control = UIButton(type: .system)
control.setTitle("Test", for: .normal)
// add self. to the selector
control.addTarget(self, action: #selector(self.handleOptions), for: .touchUpInside)
control.translatesAutoresizingMaskIntoConstraints = false
return control
}()
The issue is that when the optionsButtons is created the self is still not initialized. Hence you need to make the button lazy var so that it could be loaded lazily when the optionsButtons is called and the UICollectionViewCell is initialized. Adding the target before self is initialized doesn't work.
To fix your issue, modify your optionsButtons declaration to lazy var, like this:
lazy var optionsButtons: UIButton = {
OR
Add the target after the UICollectionViewCell is initialized, like this:
override init(frame: CGRect) {
super.init(frame: frame)
optionsButtons.addTarget(self, action: #selector(handleOptions), for: .touchUpInside)
setupComponents()
}

Resources