3

I'm trying to implement a smoother way for a use to add new collectionViewCells for myCollectionView (which only shows one cell at a time). I want it to be something like when the user swipes left and if the user is on the last cell myCollectionView inserts a new cell when the user is swiping so that the user swipes left "into" the cell. And also I only allow the user to scroll one cell at a time.

EDIT: So I think it is a bit hard to describe it in words so here is a gif to show what I mean

enter image description here

So in the past few weeks I have been trying to implement this in a number of different ways, and the one that I have found the most success with is by using the scrollViewWillEndDragging delegate method and I have implemented it like this:

 func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    // Getting the size of the cells
    let flowLayout = myCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
    let cellWidth = flowLayout.itemSize.width
    let cellPadding = 10.0 as! CGFloat

    // Calculating which page "card" we should be on
    let currentOffset = scrollView.contentOffset.x - cellWidth/2
    print("currentOffset is \(currentOffset)")
    let cardWidth = cellWidth + cellPadding
    var page = Int(round((currentOffset)/(cardWidth) + 1))

    print("current page number is: \(page)")
    if (velocity.x < 0) {
        page -= 1

    }
    if (velocity.x > 0) {
        page += 1
    }
    print("Updated page number is: \(page)")
    print("Previous page number is: \(self.previousPage)")

    // Only allowing the user to scroll for one page!
    if(page > self.previousPage) {
        page = self.previousPage + 1
        self.previousPage = page

    }
    else if (page == self.previousPage) {
        page = self.previousPage
    }
    else {
        page = self.previousPage - 1
        self.previousPage = page
    }

    print("new page number is: " + String(page))
    print("addedCards.count + 1 is: " + String(addedCards.count + 1))
    if (page == addedCards.count) {
        print("reloading data")

        // Update data source
        addedCards.append("card")

        // Method 1
        cardCollectionView.reloadData()

        // Method 2
        //            let newIndexPath = NSIndexPath(forItem: addedCards.count - 1, inSection: 0)
        //            cardCollectionView.insertItemsAtIndexPaths([newIndexPath])

    }
    //        print("Centering on new cell")
    // Center the cardCollectionView on the new page
    let newOffset = CGFloat(page * Int((cellWidth + cellPadding)))
    print("newOffset is: \(newOffset)")
    targetContentOffset.memory.x = newOffset

}

Although I think I have nearly got the desired result there are still some concerns and also bugs that I have found.

My main concern is the fact that instead of inserting a single cell at the end of myCollectionView I'm reloading the whole table. The reason that I do this is because if I didn't then myCollectionView.contentOffset wouldn't be changed and as a result when a new cell is created, myCollectionView isn't centered on the newly created cell.

1. If the user scrolls very slowly and then stops, the new cell gets created but then myCollectionView gets stuck in between two cells it doesn't center in on the newly created cell.

2. When myCollectionView is in between the second last cell and the last cell as a result of 1., the next time the user swipes right, instead of creating one single cell, two cells are created.

I've also used different ways to implement this behaviour such as using scrollViewDidScroll, and various other but to no avail. Can anyone point me in the right direction as I am kind of lost.

Here is a link to download my project if you want to see the interaction: My Example Project

These the old methods if you're interested:

To do this I have tried 2 ways,

The first being:

func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, 
    targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        // page is the current cell that the user is on 
        // addedCards is the array that the data source works with
        if (page == addedCards.count + 1) {
            let placeholderFlashCardProxy = FlashCardProxy(phrase: nil, pronunciation: nil, definition: nil)
            addedCards.append(placeholderFlashCardProxy)   
            let newIndexPath = NSIndexPath(forItem: addedCards.count, inSection: 0)
            cardCollectionView.insertItemsAtIndexPaths([newIndexPath])
            cardCollectionView.reloadData()

        }
}

The problem with using this method is that:

  1. Sometimes I will get a crash as a result of: NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0.
  2. When a new collectionCell is added it will sometimes display the input that the user has written from the previous cell (this might have sometimes to do with the dequeuing and re-use of cell, although again, I'm not sure and I would be grateful if someone answered it)
  3. The inserting isn't smooth, I want the user to be able to swipe left at the last cell and "into" a new cell. As in if I am currently on the last cell, swiping left would put me automatically at the new cell, because right now when I swipe left a new cell is created by it doesn't center on the newly created cell

The second method that I am using is:

let swipeLeftGestureRecognizer = UISwipeGestureRecognizer(target: self, action: "swipedLeftOnCell:")
swipeLeftGestureRecognizer.direction = .Left
myCollectionView.addGestureRecognizer(swipeLeftGestureRecognizer)
swipeLeftGestureRecognizer.delegate = self

Although the swipe gesture very rarely responds, but if I use a tap gesture, myCollectionView always responds which is very weird (I know again this is a question on its own)

My question is which is the better way to implement what I have described above? And if none are good what should I work with to create the desired results, I've been trying to do this for two days now and I was wondering if someone could point me in the right direction. Thanks!

YellowPillow
  • 4,100
  • 6
  • 31
  • 57
  • Did you try allowing simultaneous gestures. - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{ return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{ return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch{ return YES; } – UIResponder Apr 01 '16 at 06:14
  • Could you elaborate on it a bit? – YellowPillow Apr 01 '16 at 06:36
  • In the comments, above - I have pasted code. Please try and see if it works – UIResponder Apr 01 '16 at 06:41
  • Just tried it, there is still no response – YellowPillow Apr 01 '16 at 12:49

2 Answers2

1

I hope this helps out in some way :)

UPDATE

I updated the code to fix the issue scrolling in either direction. The updated gist can be found here

New Updated Gist

Old Gist

First I'm gonna define some model to for a Card

class Card {
    var someCardData : String?
}

Next, create a collection view cell, with a card view inside that we will apply the transform to

class CollectionViewCell : UICollectionViewCell {

     override init(frame: CGRect) {
         super.init(frame: frame)
         self.addSubview(cardView)
         self.addSubview(cardlabel)
     }

     override func prepareForReuse() {
         super.prepareForReuse()
         cardView.alpha = 1.0
         cardView.layer.transform = CATransform3DIdentity
     }

     override func layoutSubviews() {
         super.layoutSubviews()
         cardView.frame = CGRectMake(contentPadding,
                                contentPadding,
                                contentView.bounds.width - (contentPadding * 2.0),
                                contentView.bounds.height - (contentPadding * 2.0))

         cardlabel.frame = cardView.frame
     }

     required init?(coder aDecoder: NSCoder) {
         super.init(coder: aDecoder)
     }

     lazy var cardView : UIView = {
         [unowned self] in
         var view = UIView(frame: CGRectZero)
         view.backgroundColor = UIColor.whiteColor()
         return view
     }()

     lazy var cardlabel : UILabel = {
          [unowned self] in
          var label = UILabel(frame: CGRectZero)
          label.backgroundColor = UIColor.whiteColor()
          label.textAlignment = .Center
          return label
     }()
}

Next setup the view controller with a collection view. As you will see there is a CustomCollectionView class, which I will define near the end.

class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {

    var cards = [Card(), Card()]

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        collectionView.frame = CGRectMake(0, 0, self.view.bounds.width, tableViewHeight)
        collectionView.contentInset = UIEdgeInsetsMake(0, 0, 0, contentPadding)
    }

    lazy var collectionView : CollectionView = {
        [unowned self] in

        // MARK: Custom Flow Layout defined below
        var layout = CustomCollectionViewFlowLayout()
        layout.contentDelegate = self

        var collectionView = CollectionView(frame: CGRectZero, collectionViewLayout : layout)
        collectionView.clipsToBounds = true
        collectionView.showsVerticalScrollIndicator = false
        collectionView.registerClass(CollectionViewCell.self, forCellWithReuseIdentifier: "CollectionViewCell")
        collectionView.delegate = self
        collectionView.dataSource = self
        return collectionView
        }()

    // MARK: UICollectionViewDelegate, UICollectionViewDataSource

    func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return cards.count
    }

    func collectionView(collectionView : UICollectionView, layout collectionViewLayout:UICollectionViewLayout, sizeForItemAtIndexPath indexPath:NSIndexPath) -> CGSize {
        return CGSizeMake(collectionView.bounds.width, tableViewHeight)
    }

    func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cellIdentifier = "CollectionViewCell"
        let cell =  collectionView.dequeueReusableCellWithReuseIdentifier(cellIdentifier, forIndexPath: indexPath) as! CollectionViewCell
        cell.contentView.backgroundColor = UIColor.blueColor()

        // UPDATE If the cell is not the initial index, and is equal the to animating index
        // Prepare it's initial state
        if flowLayout.animatingIndex == indexPath.row  && indexPath.row != 0{
            cell.cardView.alpha = 0.0
            cell.cardView.layer.transform = CATransform3DScale(CATransform3DIdentity, 0.0, 0.0, 0.0)
        }
        return cell
    }
}

UPDATED - Now For the really tricky part. I'm gonna define the CustomCollectionViewFlowLayout. The protocol callback returns the next insert index calculated by the flow layout

protocol CollectionViewFlowLayoutDelegate : class {
    func flowLayout(flowLayout : CustomCollectionViewFlowLayout, insertIndex index : NSIndexPath)
}

/**
*  Custom FlowLayout
*  Tracks the currently visible index and updates the proposed content offset
*/
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {

    weak var contentDelegate: CollectionViewFlowLayoutDelegate?

    // Tracks the card to be animated
    // TODO: - Adjusted if cards are deleted by one if cards are deleted
    private var animatingIndex : Int = 0

    // Tracks thje currently visible index
    private var visibleIndex : Int = 0 {
        didSet {
            if visibleIndex > oldValue  {

                if visibleIndex > animatingIndex {
                    // Only increment the animating index forward
                    animatingIndex = visibleIndex
                }

                if visibleIndex + 1 > self.collectionView!.numberOfItemsInSection(0) - 1 {
                    let currentEntryIndex =  NSIndexPath(forRow: visibleIndex + 1, inSection: 0)
                    contentDelegate?.flowLayout(self, insertIndex: currentEntryIndex)
                }

            } else if visibleIndex < oldValue && animatingIndex == oldValue {
                // if we start panning to the left, and the animating index is the old value
                // let set the animating index to the last card.
                animatingIndex = oldValue + 1
            }
        }
    }

    override init() {
        super.init()
        self.minimumInteritemSpacing = 0.0
        self.minimumLineSpacing = 0.0
        self.scrollDirection = .Horizontal
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // The width offset threshold percentage from 0 - 1
    let thresholdOffsetPrecentage : CGFloat = 0.5

    // This is the flick velocity threshold
    let velocityThreshold : CGFloat = 0.4

    override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

        let leftThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) - 0.5))
        let rightThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) + 0.5))

        let currentHorizontalOffset = collectionView!.contentOffset.x

        // If you either traverse far enought in either direction,
        // or flicked the scrollview over the horizontal velocity in either direction,
        // adjust the visible index accordingly

        if currentHorizontalOffset < leftThreshold || velocity.x < -velocityThreshold {
            visibleIndex = max(0 , (visibleIndex - 1))
        } else if currentHorizontalOffset > rightThreshold || velocity.x > velocityThreshold {
            visibleIndex += 1
        }

        var _proposedContentOffset = proposedContentOffset
        _proposedContentOffset.x = CGFloat(collectionView!.bounds.width) * CGFloat(visibleIndex)

        return _proposedContentOffset
    }
}

And define the delegate methods in your view controller to insert a new card when the delegate tell it that it needs a new index

extension ViewController : CollectionViewFlowLayoutDelegate {
    func flowLayout(flowLayout : CustomCollectionViewFlowLayout, insertIndex index : NSIndexPath) {
        cards.append(Card())
        collectionView.performBatchUpdates({
            self.collectionView.insertItemsAtIndexPaths([index])
        }) { (complete) in

    }
}

And below is the custom Collection view that applies the animation while scrolling accordingly :)

class CollectionView : UICollectionView {

    override var contentOffset: CGPoint {
        didSet {
            if self.tracking {
                // When you are tracking the CustomCollectionViewFlowLayout does not update it's visible index until you let go
                // So you should be adjusting the second to last cell on the screen
                self.adjustTransitionForOffset(NSIndexPath(forRow: self.numberOfItemsInSection(0) - 1, inSection: 0))
            } else {

                // Once the CollectionView is not tracking, the CustomCollectionViewFlowLayout calls
                // targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:), and updates the visible index
                // by adding 1, thus we need to continue the trasition on the second the last cell
                self.adjustTransitionForOffset(NSIndexPath(forRow:  self.numberOfItemsInSection(0) - 2, inSection: 0))
            }
        }
    }

    /**
     This method applies the transform accordingly to the cell at a specified index
     - parameter atIndex: index of the cell to adjust
     */
    func adjustTransitionForOffset(atIndex : NSIndexPath) {
        if let lastCell = self.cellForItemAtIndexPath(atIndex) as? CollectionViewCell {
            let progress = 1.0 - (lastCell.frame.minX - self.contentOffset.x) / lastCell.frame.width
            lastCell.cardView.alpha = progress
            lastCell.cardView.layer.transform = CATransform3DScale(CATransform3DIdentity, progress, progress, 0.0)
        }
    }
}
AntonTheDev
  • 899
  • 6
  • 13
  • Hello this looks promising I will look at it later today :) – YellowPillow May 25 '16 at 01:14
  • @YellowPillow Just let me know if I can help out in any way, or make the answer better :) – AntonTheDev May 25 '16 at 17:13
  • so I just implemented everything works fine when creating the cells, but when swiping back the cells that were created aren't shown. Hard to explain but to reproduce this, first create a few cells by swiping left, then after they are created swipe right. Hopefully you will see what I mean, I'm going to try to fix it but just thought you might want to know :) – YellowPillow May 27 '16 at 06:02
  • @YellowPillow I think you just need set the transform to identity and the alpha to 1 by overriding prepareForReuse. I'll take a look in the office here – AntonTheDev May 27 '16 at 16:07
  • @YellowPillow I updated the code and appended a new gist. It was definitely an interesting problem to solve, threw me for a loop, but finally figured it out :) – AntonTheDev May 28 '16 at 03:10
  • I'll look at it soon sorry I'm a bit busy now! You're a magician though please teach me your ways! Thanks a lot :) – YellowPillow May 28 '16 at 16:29
  • @YellowPillow check out Nike Tech Book :P I got an animation engine called FlightAnimator in the works right now, all it needs is documentation. I haven't made public yet, but when I do it's gonna rival Facebook's POP – AntonTheDev May 28 '16 at 16:33
  • The app looks amazing! If you need with help documenting the code in the future I would be honored to help and read the code :) – YellowPillow May 28 '16 at 17:36
  • @YellowPillow check this thing out, it's slowly coming along :) https://github.com/AntonTheDev/FlightAnimator/blob/dev/README.md – AntonTheDev Jun 22 '16 at 21:34
0

I think u missed one point here in

let newIndexPath = NSIndexPath(forItem: addedCards.count, inSection: 0)

the indexPath should be

NSIndexPath(forItem : addedCards.count - 1, inSection : 0) not addedCards.count

Thats why you were getting the error

NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0
Shreesha Kedlaya
  • 340
  • 4
  • 19