Nested Controllers using UICollectionViewController reordering - ios

I have a UIViewController with a UICollectionViewController nested inside. The collection view controller also has the moveItemAt method implemented, since I want the cells to be reorderable. So the cells have a UILongPressGestureRecognizer attached. However, long press on the cells aren't happening. I can't seem to figure out if the nested controller is causing the gesture to be ignored. Maybe the parent controller is capturing the long press but that wouldn't make sense since AFAIK, gestures go up the view hierarchy, not the other way around.
For some context, I've used this method to nest my controllers
func add(_ child: UIViewController) {
addChildViewController(child)
child.view.frame = view.bounds
view.addSubview(child.view)
child.didMove(toParentViewController: self)
}

I think this is a better approach...
Assuming you have a UICollectionViewController in your storyboard, and you've assigned the cell prototype to DragMeCell and set its Identifier to "DragMeCell" (and added a label connected to the IBOutlet), this should run and allow you to long-press drag-drop to reorder.
//
// DragReorderCollectionViewController.swift
// SW4Temp
//
// Created by Don Mag on 7/25/18.
//
import UIKit
private let reuseIdentifier = "DragMeCell"
class DragMeCell: UICollectionViewCell {
#IBOutlet var theLabel: UILabel!
}
class DragReorderCollectionViewController: UICollectionViewController {
fileprivate var longPressGesture: UILongPressGestureRecognizer!
fileprivate var dataArray = Array(0 ..< 25)
override func viewDidLoad() {
super.viewDidLoad()
longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongGesture(gesture:)))
if let cv = self.collectionView {
cv.addGestureRecognizer(longPressGesture)
}
}
#objc func handleLongGesture(gesture: UILongPressGestureRecognizer) {
if let cv = self.collectionView,
let gestureView = gesture.view {
switch(gesture.state) {
case .began:
guard let selectedIndexPath = cv.indexPathForItem(at: gesture.location(in: cv)) else {
break
}
cv.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
cv.updateInteractiveMovementTargetPosition(gesture.location(in: gestureView))
case .ended:
cv.endInteractiveMovement()
default:
cv.cancelInteractiveMovement()
}
}
}
// MARK: UICollectionViewDelegate
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let i = dataArray[sourceIndexPath.item]
dataArray.remove(at: sourceIndexPath.item)
dataArray.insert(i, at: destinationIndexPath.item)
}
// MARK: UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArray.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! DragMeCell
// Configure the cell
cell.theLabel.text = "\(dataArray[indexPath.item])"
return cell
}
}
Then you can add it as a child view controller, using the code snippet you posted in your question.

Related

UICollectionViewDropDelegate breaks reordering cell mechanics in UICollectionView

I'm trying to implement two collectionViews with drag and drop cells between them. But i faced strange behaviour with reordering cells inside collectionView. Here is minimal code that replicate that behaviour.
Provided code works as expected but then i uncomment collectionView.dropDelegate = self it doesn't work anymore. I tried to find which method of UICollectionViewDropDelegate is called but none of them is called.
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDropDelegate {
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
print("in drop")
}
#IBOutlet weak var collectionView: UICollectionView!
var items = [UIColor.red, UIColor.green, UIColor.blue]
override func viewDidLoad() {
super.viewDidLoad()
// collectionView.dropDelegate = self
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentView.layer.backgroundColor = items[indexPath.item].cgColor
return cell
}
func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let temp = items.remove(at: sourceIndexPath.item)
items.insert(temp, at: destinationIndexPath.item)
}
#IBAction func handleLongGesture(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
guard let selectedIndexPath = self.collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
break
}
self.collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
self.collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view!))
case .ended:
self.collectionView.endInteractiveMovement()
default:
self.collectionView.cancelInteractiveMovement()
}
}
}
example without dragDelegate
example with dragDelegate
So this it happens and what i should do to get normal behaviour with dragDelegate?
It's a bit ugly, but I had this problem and the simplest thing I could find that works is to disable the dropDelegate when your gesture recognizer is invoked.
#IBAction func handleLongGesture(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
guard let selectedIndexPath = self.collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
break
}
self.collectionView.dropDelegate = nil // Drop delegate behaves VERY badly with beginInteractiveMovementForItem
self.collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
self.collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view!))
case .ended:
self.collectionView.endInteractiveMovement()
self.collectionView.dropDelegate = self
default:
self.collectionView.cancelInteractiveMovement()
self.collectionView.dropDelegate = self
}
}

Get indexPath from UICollectionViewController to UICollectionViewCell subclass

in my view controller I Am loading a custom CollectionViewCell with subclass. Based on the position of a cell's indexpath I want to format the text labels differently. I.e. first row has only one cell with bigger text, whereas the second has two cell with smaller text.
How can I access the indexpath from my UICollectionView in my UICollectionViewCell subclass? I tried a delegate protocol but this always returns nil.
Code below and Thanks so much!
Markus
UICollectionViewController:
import UIKit
protocol WorkoutDataViewControllerCVDataSource: AnyObject {
func workoutType(for workoutDataViewControllerCV: WorkoutDataViewControllerCV) -> WorkoutType
func workoutDistance(for workoutDataViewControllerCV: WorkoutDataViewControllerCV) -> Double
func workoutDuration(for workoutDataViewControllerCV: WorkoutDataViewControllerCV) -> Double
func workoutInstantVelocity(for workoutDataViewControllerCV: WorkoutDataViewControllerCV) -> Double
}
final class WorkoutDataViewControllerCV: UIViewController {
#IBOutlet weak var collectionView: UICollectionView!
weak var dataSource: WorkoutDataViewControllerCVDataSource!
private lazy var velocityFormatter = VelocityFormatter(dataSource: self, delegate: self)
private lazy var averageVelocityFormatter = VelocityFormatter(dataSource: self, delegate: self)
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView.register(MeasurementCollectionViewCell.preferredNib, forCellWithReuseIdentifier: MeasurementCollectionViewCell.preferredReuseIdentifier)
}
}
// MARK: - Managing UICollectionView
extension WorkoutDataViewControllerCV: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 4
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Measurement Cell", for: indexPath)
return cell
}
}
extension WorkoutDataViewControllerCV: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let availableWidth = self.view.frame.width
switch indexPath.row {
case 0: return CGSize(width: availableWidth, height: 150)
case 1: return CGSize(width: availableWidth/2.1, height: 150)
case 2: return CGSize(width: availableWidth/2.1, height: 150)
case 3: return CGSize(width: availableWidth, height: 150)
default:
return CGSize(width: availableWidth/2.1, height: 150)
}
}
}
// MARK: - Managing VelocityFormatter
extension WorkoutDataViewControllerCV: VelocityFormatterDataSource {
func duration(for velocityFormatter: VelocityFormatter) -> Double {
return dataSource.workoutDuration(for: self)
}
func distance(for velocityFormatter: VelocityFormatter) -> Double {
return dataSource.workoutDistance(for: self)
}
func instantVelocity(for velocityFormatter: VelocityFormatter) -> Double {
return dataSource.workoutInstantVelocity(for: self)
}
}
UICollectionViewCell.swift
import UIKit
final class MeasurementCollectionViewCell: UICollectionViewCell {
#IBOutlet private var measurementPropertyLabel: UILabel!
#IBOutlet private var measurementValueLabel: UILabel!
#IBOutlet private var measurementUnitLabel: UILabel!
static let preferredReuseIdentifier = "Measurement Cell"
static let preferredNib = UINib(nibName: "MeasurementCollectionViewCell", bundle: nil)
override func awakeFromNib() {
super.awakeFromNib()
updateMeasurement(property: "Speed", value: "100", unit: "km/h")
//measurementValueLabel.font = measurementValueLabel.font.monospacedDigitFont
}
func updateMeasurement(property: String, value: String, unit: String?) {
measurementPropertyLabel.text = property
measurementValueLabel.text = value
measurementUnitLabel.text = unit
}
}
Get the instance of cell in UICollectionView delegate method collectionView(_, didSelectItemAt _).
extension WorkoutDataViewControllerCV: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let cell = collectionView.cellForItem(at: indexPath) as? MeasurementCollectionViewCell {
cell.selectedIndexPath(indexPath)
}
}
}
The indexPath will be passed as an argument in method selectedIndexPath to MeasurementCollectionViewCell from above method.
class MeasurementCollectionViewCell: UICollectionViewCell {
......
func selectedIndexPath(_ indexPath: IndexPath) {
//Do your business here.
}
}
You can use the responder chain to get the collection view of a cell with which you can get the index path. Just add these extensions in a new file called UICollectionViewCell+IndexPath.swift.
extension UIResponder {
func next<T: UIResponder>(_ type: T.Type) -> T? {
return next as? T ?? next?.next(type)
}
}
extension UICollectionViewCell {
var indexPath: IndexPath? {
return next(UICollectionView.self)?.indexPath(for: self)
}
}
Now inside your cell, you can use self.indexPath
Pretty straight forward way would be storing the indexPath into the subclass of UICollectionViewCell class. Assign it while returning from cellForRow at: index path. So now the subclassed collectionviewcell has access to the indexpath of it's own

Make sure only 1 cell has an active state in a UICollectionView

I have an UICollectionView in which I want only want 1 cell to be active. With active I mean: the last cell that has been clicked (or the very first cell when to collection view lays out). When a user clicks a non-active cell, I want to reset the old active cell to a non-active state. I am having trouble doing this. This is because visibleCells, a property of collection view, only returns the cells on screen but not the cells in memory. This is my current way to locate an active cell and reset the state to non active.
This scenario can happen, causing multiple active cells: A user scroll slightly down so that the current active cell is not visible anymore, taps on a random cell and scroll up. The problem is that the old active cell stays in memory, although it is not visible: cellForItemAt(_:) does not gets called for that cell. Bad news is that visibleCells also do not find the old active cell. How can I find it? The function willDisplay cell also does not work.
An example project can be cloned directly into xCode: https://github.com/Jasperav/CollectionViewActiveIndex.
This is the code in the example project:
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var collectionView: CollectionView!
static var activeIndex = 0
override func viewDidLoad() {
super.viewDidLoad()
collectionView.go()
}
}
class Cell: UICollectionViewCell {
#IBOutlet weak var button: MyButton!
}
class CollectionView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
func go() {
delegate = self
dataSource = self
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 500
}
internal func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! Cell
if indexPath.row == ViewController.activeIndex {
cell.button.setTitle("active", for: .normal)
} else {
cell.button.setTitle("not active", for: .normal)
}
cell.button.addTarget(self, action: #selector(touchUpInside(_:)), for: .touchUpInside)
return cell
}
#objc private func touchUpInside(_ sender: UIButton){
let hitPoint = sender.convert(CGPoint.zero, to: self)
guard let indexPath = indexPathForItem(at: hitPoint), let cell = cellForItem(at: indexPath) as? Cell else { return }
// This is the problem. It does not finds the current active cell
// if it is just out of bounds. Because it is in memory, cellForItemAt: does not gets called
if let oldCell = (visibleCells as! [Cell]).first(where: { $0.button.titleLabel!.text == "active" }) {
oldCell.button.setTitle("not active", for: .normal)
}
cell.button.setTitle("active", for: .normal)
ViewController.activeIndex = indexPath.row
}
}
To recover from this glitch you can try in cellForRowAt
cell.button.tag = indexPath.row
when the button is clicked set
ViewController.activeIndex = sender.tag
self.reloadData()
You can use the isSelected property of the UIColectionViewCell. You can set an active layout to your cell if it is selected. The selection mechanism is implemented by default in the UIColectionViewCell. If you want to select/activate more than one cell you can set the property allowsMultipleSelection to true.
Basically this approach will look like this:
class ViewController: UIViewController {
#IBOutlet weak var collectionView: CollectionView!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.go()
}
func activeIndex()->Int?{
if let selectedItems = self.collectionView.indexPathsForSelectedItems {
if selectedItems.count > 0{
return selectedItems[0].row
}
}
return nil
}
}
class Cell: UICollectionViewCell {
#IBOutlet weak var myLabel: UILabel!
override var isSelected: Bool{
didSet{
if self.isSelected
{
myLabel.text = "active"
}
else
{
myLabel.text = "not active"
}
}
}
}
class CollectionView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
func go() {
delegate = self
dataSource = self
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 500
}
internal func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! Cell
return cell
}
}

How to Override ScrollViewDidEndDecelerating and find current visible cell ?

I have a view controller which contains a collectionView:
class TagViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
#IBOutlet weak var collectionView: UICollectionView!
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return posts.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let post = posts[indexPath.row]
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! SNPostViewCell
cell.isVideo = post.isVideo
cell.mediaURL = URL(string: post.mediaURL)
cell.backgroundColor = UIColor.black
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let post = posts[indexPath.row]
if post.isVideo == true {
self.performSegue(withIdentifier: "playVideo", sender: nil)
} else {
print("is image. might go full screen one day here")
}
let bin = self.bins[post.timeStamp]
print(bin)
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { //Error: method cannot override from its superclass
<#code#>
}
}
Although I can override those collectionView functions, it seems that to override scrollViewDidEndDecelerating I specifically need to have an instance of collectionView (and not do it in a view controller)
OK so I made a custom collectionView which inherits from collectionViewController. This overrides scrollViewDidEndDecelerating correctly (the method now DOES override from its superclass), and I am using code inside of it to find the index of the current cell,
class PagingCollectionViewController: UICollectionViewController {
...
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if (self.collectionView.contentOffset.y < 0) {
self.collectionView.contentOffset = CGPointMake(self.collectionView.contentOffset.x, 0.0);
}
for cell in yourCollectionViewname.visibleCells() as [UICollectionViewCell] {
let indexPath = yourCollectionViewname.indexPathForCell(cell as UICollectionViewCell)
}
}
...
}
However, as you can see this needs to reference the current index of the collectionView which I wouldn't have defined in the class right? Only in the view Controller where the instance is created and populated...
So I don't really understand where to put this override scrollViewDidEndDecelerating function and how to access the data within it successfully - to find the index.
This is wrong:
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
<#code#>
}
... because scrollViewDidEndDecelerating is not an override method. It is a delegate method.
https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619417-scrollviewdidenddecelerating
So delete the word override, and set self as the delegate of the collectionView, and you're all set.

Reorder Collection View Cells buggy

I'm able to get the cells to move around but it's buggy. When you move them around the pictures change and they don't stay where you move them. When you scroll down and scroll back, they move back.
//
// CollectionViewController.swift
// 1.7 Task: Displaying Sets of Data: Collection View
//
// Created by Eric Andersen on 3/26/18.
// Copyright © 2018 Eric Andersen. All rights reserved.
//
import UIKit
class CollectionViewController: UICollectionViewController {
var batmanDataItems = [DataItem]()
var jokerDataItems = [DataItem]()
var allItems = [[DataItem]]()
var longPressGesture: UILongPressGestureRecognizer!
override func viewDidLoad() {
for i in 1...29 {
if i > 0 {
batmanDataItems.append(DataItem(title: "Title #\(i)", kind: Kind.Batman, imageName: "bat\(i).jpg"))
} else {
batmanDataItems.append(DataItem(title: "Title #0\(i)", kind: Kind.Batman, imageName: "bat0\(i).jpg"))
}
}
for i in 1...8 {
if i > 0 {
jokerDataItems.append(DataItem(title: "Another Title #\(i)", kind: Kind.Joker, imageName: "jok\(i).jpg"))
} else {
jokerDataItems.append(DataItem(title: "Another Title #0\(i)", kind: Kind.Joker, imageName: "jok0\(i).jpg"))
}
}
allItems.append(batmanDataItems)
allItems.append(jokerDataItems)
super.viewDidLoad()
let width = collectionView!.frame.width / 3
let layout = collectionViewLayout as! UICollectionViewFlowLayout
layout.itemSize = CGSize(width: width, height: width)
longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongGesture(gesture:)))
collectionView?.addGestureRecognizer(longPressGesture)
// Uncomment the following line to preserve selection between presentations
// self.clearsSelectionOnViewWillAppear = false
// Register cell classes
// Do any additional setup after loading the view.
}
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using [segue destinationViewController].
// Pass the selected object to the new view controller.
}
*/
// MARK: UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 2
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return allItems[section].count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! DataItemCell
let dataItem = allItems[indexPath.section][indexPath.item]
cell.dataItem = dataItem
return cell
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "SectionHeader", for: indexPath) as! DataItemHeader
var title = ""
if let kind = Kind(rawValue: indexPath.section) {
title = kind.description()
}
sectionHeader.title = title
return sectionHeader
}
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
// print("Starting Index: \(sourceIndexPath.item)")
// print("Ending Index: \(destinationIndexPath.item)")
}
// MARK: UICollectionViewDelegate
// Uncomment this method to specify if the specified item should be highlighted during tracking
override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
return true
}
// Uncomment this method to specify if the specified item should be selected
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
allItems[indexPath.section].remove(at: indexPath.row)
self.collectionView?.performBatchUpdates({
self.collectionView?.deleteItems(at: [indexPath])
}) { (finished) in
self.collectionView?.reloadItems(at: (self.collectionView?.indexPathsForVisibleItems)!)
}
}
#objc func handleLongGesture(gesture: UILongPressGestureRecognizer) {
switch(gesture.state) {
case .began:
guard let selectedIndexPath = collectionView?.indexPathForItem(at: gesture.location(in: collectionView)) else {
break
}
collectionView?.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
collectionView?.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view!))
case .ended:
collectionView?.endInteractiveMovement()
default:
collectionView?.cancelInteractiveMovement()
}
}
}
enter image description here
I'm new. I know this is easy but I'm still getting the hang of this. Thanks for your patience!
Moving the row only updates the screen, but it doesn't change your model (the array that supplies the data to your collectionView). Then when cells go off screen and back on, they are loaded from your array which hasn't changed, which is why the cells go back to where they were.
You need to override func collectionView(_:moveItemAt:to:) and update your data array to reflect the row that was moved.
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
// Remove the source item from the array and store it in item
let item = allItems[sourceIndexPath.section].remove(at: sourceIndexPath.item)
// insert the item into the destination location
allItems[destinationIndexPath.section].insert(item, at: destinationIndexPath.item)
}

Resources