Toggle select / deselect state of a UICollectionView Cell on tap - Swift - ios

So first of all i've been stuck on this for a few days and spent a full day reading and trying many options on Stack Overflow already but non to my success
What i'm trying to accomplish sounds simple and going over the Apple documentation it seems to me it should work
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UICollectionViewDelegate_protocol/#//apple_ref/occ/intfm/UICollectionViewDelegate/collectionView:shouldHighlightItemAtIndexPath:
Basically what i'm trying to achieve is to toggle the selected state of a UICollectionView Cell on tap.
First tap - Send the cell into a selected state and change background colour to white.
Second tap - Send the cell into a deselected state and change background colour to clear
ViewController -
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
if let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as? CollectionViewCell {
cell.cellImage.image = UIImage(named: images[indexPath.row])
return cell
} else {
return CollectionViewCell()
}
}
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
if let cell = collectionView.cellForItemAtIndexPath(indexPath) as? CollectionViewCell {
cell.toggleSelectedState()
}
}
func collectionView(collectionView: UICollectionView, didDeselectItemAtIndexPath indexPath: NSIndexPath) {
if let cell = collectionView.cellForItemAtIndexPath(indexPath) as? CollectionViewCell {
cell.toggleSelectedState()
}
}
Cell -
func toggleSelectedState() {
if selected {
print("Selected")
backgroundColor = UIColor.whiteColor()
} else {
backgroundColor = UIColor.clearColor()
print("Deselected")
}
}
The problem i'm having is the didDeselectItemAtIndexPath is not being called when tapping on a cell thats already selected, Though if i tap another cell it will get called and selects the new cell...
I have tried checking for selected states in shouldSelectItemAtIndexPath & shouldDeselectItemAtIndexPath, i even tried writing a tapGesture to get around this and still no luck...
Is there something i'm missing?
Or is there any known work arounds to this?
Any help would be greatly appreciated!

Use the shouldSelectItemAt and check the indexPathsForSelectedItems property of the collection view to determine if the cell should be selected or deselected.
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
if let indexPaths = collectionView.indexPathsForSelectedItems, indexPaths.contains(indexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
return false
}
return true
}

Maybe you can create a UIButton() with the same bounds as the cell, and identify the select in the button. Then in the tap action of the button, you can do something to 'disselect' the cell 'selected'.

You may set the UICollectionView's allowsMultipleSelection property to YES(true), then the collection view will not deselect the previous item.

I made this work for a collection view that allows multiple cells to be selected and deselected by using a tap gesture recognizer.
Cell:
class ToggleCollectionViewCell: UICollectionViewCell {
var didChangeSelection: (Bool) -> Void = { _ in }
required init?(coder: NSCoder) {
super.init(coder: coder)
let tap = UITapGestureRecognizer(target: self, action: #selector(didTap))
addGestureRecognizer(tap)
}
#objc private func didTap() {
isSelected.toggle()
didChangeSelection(isSelected)
}
}
Controller:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "toggleCell", for: indexPath)
if let toggleCell = cell as? ToggleCollectionViewCell {
// do your cell setup...
lineCell.didChangeSelection = { isSelected in
guard let self = self else { return }
// ... handle selection/deselection
}
}
return cell
}

Related

Tapping cell in UICollectionView returns wrong index

I really don't know what I am missing. I've got a UICollectionView set up and the contents are set correctly. The cellForItemAt section looks like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "DetailsEventImageCell", for: indexPath) as? DetailsEventImageCell
print("Loaded - IndexPathItem: \(indexPath.item)")
cell?.backgroundColor = .clear
cell?.contentView.layer.cornerRadius = 10
cell?.contentView.layer.masksToBounds = true
cell?.eventImage.image = images[indexPath.row]
return cell ?? UICollectionViewCell()
}
The collectionView shows the cells correctly but here's the problem. When I go into editing Mode and tap through all the cells randomly, sometimes it returns the wrong index and marks the wrong cell (mostly right next to it) as shown in the image below:
The cell I tap should return indexPath.item = 5 but it does return 4 and marks cell 4 for deletion instead of 5.
As the cells are set up correctly I don't know why it occasionally returns the wrong indexPath for the selected cell. If I tap on a cell twice it suddenly returns the correct indexPath. If I deselect all cells and try again, there some cells return the wrong indexPath again.
Here's my didSelectItemAt Code
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print(indexPath.item)
if isEditing {
if let cell = collectionView.cellForItem(at: indexPath) as? DetailsEventImageCell {
cell.isInEditingMode = isEditing
cell.isSelected = true
}
}
}
The following two functions might also be the culprit, but I just don't see why it would set up the collectionView correctly and then on tapping return the wrong value.
This is my custom cell:
class DetailsEventImageCell: UICollectionViewCell {
#IBOutlet weak var eventImage: UIImageView!
#IBOutlet weak var selectedForDeletionImage: UIImageView!
var isInEditingMode: Bool = false {
didSet {
if !isInEditingMode {
//selectedForDeletionImage.isHidden = true
isSelected = false
}
}
}
override var isSelected: Bool {
didSet {
if isInEditingMode {
selectedForDeletionImage.isHidden = isSelected ? false : true
}
}
}
And this is my setEditing code
override func setEditing(_ editing: Bool, animated: Bool) {
super.setEditing(editing, animated: animated)
collectionView.allowsMultipleSelection = editing
//Deselect all selected cells
if !editing {
if let indexPaths = collectionView.indexPathsForSelectedItems {
for indexPath in indexPaths {
self.collectionView.deselectItem(at: indexPath, animated: false)
}
}
}
let indexPaths = collectionView.indexPathsForVisibleItems
for indexPath in indexPaths {
if let cell = collectionView.cellForItem(at: indexPath) as? DetailsEventImageCell {
cell.isInEditingMode = editing
}
}
}
I hope someone can help me with this.
I think the above implementation won't work if u have many cells because cells are reusable.
you can collect selected Indexpaths in Array, reload on every didSelece/didDeselect method.
Do the UI updates in cellforRowAtItem method.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if isEditing {
self.selectedIndexPaths.append(indexPath)
self.collectionView.reloadData()
}
}
In didDeselect method will be like...
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
if let index = self.selectedIndexPaths.firstIndex(of: indexPath) {
self.selectedIndexPaths.remove(at: index)
}
self.collectionView.reloadData()
}
finally cellforRowAtItem will be like...
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "DetailsEventImageCell", for: indexPath) as? DetailsEventImageCell
print("Loaded - IndexPathItem: \(indexPath.item)")
cell?.backgroundColor = .clear
cell?.contentView.layer.cornerRadius = 10
cell?.contentView.layer.masksToBounds = true
cell?.eventImage.image = images[indexPath.row]
if self.selectedIndexpaths.contains(indexPath) {
cell.isSelected = true
} else {
cell.isSelected = false
}
return cell ?? UICollectionViewCell()
}
After hours of trying I found out that this seems to be an iOS Simulator related issue. I don't have access to all device sizes, so I sometimes do use it for quick testing.
Tapping cells "slowly" in the simulator does work as expected, but if I tap inside cells too fast it seems like it's not keeping up and selecting the previous cell again.
On an actual device it's possible to tap on cells at any speed and it works as intended.
Edit: The problem seems to only appear on the iOS-Simulator on my Mac Mini late 2012 (macOS Catalina 10.15.6) on XCode 11.7
My Macbook Pro 15" 2019 and all hardware devices I tried (iPhone 11 Pro, ipad Air, iPhone SE 2nd Gen) do work as expected with the same project and the same OS/XCode Build.

Persistent Collection View Cell Selection

please bear with me as I am new to swift programming.
I have a myCollectionViewController that is a subclass of UICollectionViewController. The cells for the MyCollectionViewController are a class of MyCollectionViewCell, which is a custom UICollectionViewCell.
What I am trying to do is change the background of the MyCollectionViewCell based on the user selection AND have this selection persist when the user scrolls to other cells of the MyCollectionViewController. I have tried two ways to do this, and so far both have failed.
The first way was to write code in the didSelectItemAt method of the MyCollectionViewController:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "myCell", for: indexPath) as! MyCollectionViewCell
cell.contentView.backgroundColor = UIColor.red
}
However, this did not work and the cell colour was not changed.
The other way I tried to do this was by changing the isSelected property of the MyCollectionViewCell.
override var isSelected: Bool {
// Change what happens when the user selects a cell
didSet {
if self.isSelected {
self.contentView.backgroundColor = Colours.primary
} else {
self.contentView.backgroundColor = Colours.secondary
}
}
}
Although this worked, the selection did not persist. That is when the user scrolled to a different cell in the collectionView and then scrolled back, the selection was gone.
Any advice would be appreciated.
Don't use dequeue in didSelectItemAt as it'll return other cell than the clicked
var allInde = [IndexPath]()
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let cell = collectionView.cellForItem(at:indexPath) as! MyCollectionViewCell
cell.contentView.backgroundColor = UIColor.red
if !(allIndex.contains(indexPath)) {
allInde.append(indexPath)
}
}
and in cellForItem check whether indexpath to show is in the array and color it
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "id", for: indexPath as IndexPath) as! MyCollectionViewCell
if allIndex.contains(indexPath) {
cell.contentView.backgroundColor = Colours.primary
}
else {
cell.contentView.backgroundColor = Colours.secondary
}
}
// se here updated code
SPRAIN

Swift CollectionView get first cell

I am trying on my viewDidLoad() to fetch images from my DB and then present the images, and highlight the first one.
My code thus far is:
profile?.fetchImages(onComplete: { (errors, images) in
for error in errors {
print("Error", error)
}
for imageMap in images {
self.photos.append(imageMap.value)
}
if self.photos.count < self.MAX_PHOTOS {
self.photos.append(self.EMPTY_IMAGE)
}
DispatchQueue.main.async {
self.photoCollectionView.reloadData()
}
self.updateSlideshowImages()
let indexPath = IndexPath(row: 0, section: 0)
let cell = self.photoCollectionView.cellForItem(at: indexPath)
if cell != nil {
self.setBorder(cell!)
}
})
However, for me cell is always nil, despite images existing and being fetched, and thus the setBorder is never called. Why is the cell always nil? I just want to set the border on the first cell.
You have placed self.photoCollectionView.reloadData() in async block so the let cell = self.photoCollectionView.cellForItem(at: indexPath) will run immediately before collection view reload, thats the reason you are getting nil for first cell.
You need to make sure that after collection view reload, I mean when all collection view cells are loaded then you can get and do your operations.
Alternatively, you can do this in cellForItemAtIndexPath like below...
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellIdentifier.jobCollectionViewCellIdentifier, for: indexPath) as! <#Your UICollectionViewCell#>
if indexPath.section == 0 && indexPath.row == 0 {
//highlight cell here
}
return cell
}
And if you want to highlight cell after all images load in collection view then you need to check for datasource of collection view.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = ...
if indexPath.row == arrImages.count-1 {
let newIndexPath = IndexPath(row: 0, section: 0)
if let cellImg = self.photoCollectionView.cellForItem(at: newIndexPath) {
self.setBorder(cellImg)
}
}
}
Instead of highlighting the UICollectionViewCell in viewDidLoad() after receiving the response, highlight it in willDisplay delegate method, i.e.
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath)
{
if indexPath.row == 0
{
cell.isHighlighted = true
}
}
And add border of cell in the isHighlighted property of UICollectionViewCell according to the cell highlight status, i.e.
class CustomCell: UICollectionViewCell
{
override var isHighlighted: Bool{
didSet{
self.layer.borderWidth = self.isHighlighted ? 2.0 : 0.0
}
}
}
You can use isSelected property the same way in case you want to change appearance of the cell based on the selection status.
Let me know if you still face any issue.
Set your cell's border in cellForRow method.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellIdentifier", for: indexPath) as! YourCustomCell
if indexPath.row == 0 {
self.setBorder(cell)
}
return cell
}

UICollectionView select and deselect

I have UICollectionView 2 rows 10+ cells.
deselected by default. when I click it becomes selected but when I click again not deselect.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print(indexPath)
let cell = collectionView.cellForItem(at: indexPath)
let collectionActive: UIImageView = {
let image=UIImageView(image: #imageLiteral(resourceName: "collectionActive"))
image.contentMode = .scaleAspectFill
return image
}()
let collectionInactive: UIImageView = {
let image=UIImageView(image: #imageLiteral(resourceName: "collectionInactive"))
image.contentMode = .scaleAspectFill
return image
}()
if cell?.isSelected == true {
cell?.backgroundView = collectionActive
}else{
cell?.backgroundView = collectionInactive
}
}
how fix that problem?
in viewDidLoad()
collectionView.allowsMultipleSelection = true;
afterword I implemented these methods
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
let cell = collectionView.cellForItemAtIndexPath(indexPath) as! MyCell
cell.toggleSelected()
}
func collectionView(collectionView: UICollectionView, didDeselectItemAtIndexPath indexPath: NSIndexPath) {
let cell = collectionView.cellForItemAtIndexPath(indexPath) as! MyCell
cell.toggleSelected()
}
finally in my class
class MyCell : UICollectionViewCell {
func toggleSelected ()
{
if (selected){
backgroundColor = UIColor.redColor()
}else {
backgroundColor = UIColor.whiteColor()
}
}
}
For Swift 5 +
in viewDidLoad()
collectionView.allowsMultipleSelection = true
afterword I implemented these methods
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let cell = collectionView.cellForItem(at: indexPath) as! MovieDetailsDateCollectionViewCell
cell.toggleSelected()
}
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
let cell = collectionView.cellForItem(at: indexPath) as! MovieDetailsDateCollectionViewCell
cell.toggleSelected()
}
In TableView Cell class
class MyCell : UICollectionViewCell {
func toggleSelected ()
{
if (isSelected){
backgroundColor = .red
}else {
backgroundColor = .white
}
}
}
If you don't want to enable multiple selection and only want one cell to be selected at a time, you can use the following delegate instead:
If the cell is selected then this deselects all cells, otherwise if the cell is not selected, it selects it as normal.
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
let cell = collectionView.cellForItem(at: indexPath) as! CustomCell
if cell.isSelected {
collectionView.selectItem(at: nil, animated: true, scrollPosition: [])
return false
}
return true
}
According to UICollectionView class doc, you can use:
var selectedBackgroundView: UIView? { get set }
You can use this view to give the cell a custom appearance when it is selected. When the cell is selected, this view is layered above the backgroundView and behind the contentView.
In your example in the cellForItem(at indexPath: IndexPath) -> UICollectionViewCell? function you can set:
cell.backgroundView = collectionInactive
cell.selectedBackgroundView = collectionActive
If the cell is selected, just set cell.isSelected = false in shouldSelectItemAt delegate and in a DispatchQueue.main.async { } block. So the state is actually changed to false (very) soon after the shouldSelectItemAt has been executed.
It may look like a hack but it actually works.
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
if let cell = collectionView.cellForItem(at: indexPath), cell.isSelected {
DispatchQueue.main.async { // change the isSelected state on next tick of the ui thread clock
cell.isSelected = false
self.collectionView(collectionView, didDeselectItemAt: indexPath)
}
return false
}
return true
}
Please let me know if you find/know any cons to do this. Thanks 🙏
In iOS 14 and newer, you can set the backgroundConfiguration property of a cell. Once set, all necessary visual effects for selecting and deselecting works automatically. You can use one of the preconfigured configurations, like this:
cell.backgroundConfiguration = .listSidebarCell()
…or create a UIBackgroundConfiguration object from scratch. You can also change a preconfigured configuration before applying.
More info here: https://developer.apple.com/documentation/uikit/uibackgroundconfiguration

why are my collectionViewCell's displaying unusual behavior?

For some reason, my collectionViewCell's are unrecognized when they are selected. It's not until another cell is touched afterwards that the previous cell is recognized. To explain how I realized this, I added the following code to my collectionView's didDeselectItemAtIndexPath method: println("user tapped on cell # \(indexPath.row)"). When I run the app and select a cell, my console doesn't respond until I tap another cell, then it reads the println I added. For instance, if I select the first cell, the debugger doesn't print anything until i select another cell, then the console reads "user tapped on thumbnail # 0".
Now I've added an animation to my collectionView that enlarges each cell on selection, so this is how I know it isn't an indexPath issue because the cell indexPath # that is printed in the console is the correct cell that is enlarged in the view, but like i said, the cell isn't animated on its selection, not until i select another cell afterwards.
Here is my collectionView delegate and dataSource logic:
// MARK: UICollectionViewDataSource
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.books.count
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! MyCollectionViewCell
// Configure the cell
let book = self.books[indexPath.row]
let coverImage = book.coverImage
if coverImage == nil {
book.fetchCoverImage({ (image, error) -> Void in
if self.collectionView != nil {
collectionView.reloadItemsAtIndexPaths([indexPath])
}
})
} else {
let imageView = cell.imageView
imageView.image = book.coverImage
}
return cell
}
override func collectionView(collectionView: UICollectionView, didDeselectItemAtIndexPath indexPath: NSIndexPath) {
let cell = collectionView.cellForItemAtIndexPath(indexPath) as! MyCollectionViewCell
let book = self.books[indexPath.row]
self.selectedImageView = cell.imageView
if !isModeModal {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let controller = storyboard.instantiateViewControllerWithIdentifier("DetailViewController") as! DetailViewController
controller.imageSelected = book.coverImage
self.navigationController?.pushViewController(controller, animated: true)
}
println("user tapped on thumbnail # \(indexPath.row)")
}
}
Why is this behavior occurring?
I didn't run your code, but I think, that the problem could be caused by didDeselectItemAtIndexPath. Try to use didSelectItemAtIndexPath instead.
If necessary you can add deselectItemAtIndexPath :
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
collectionView.deselectItemAtIndexPath(indexPath: NSIndexPath, animated: Bool)
}

Resources