Nested UICollectionView and Responder chain - ios

I have two UICollectionVews
One of them (The parent one) is a full-screen-cell paginated collection view.
The other one (The child one) is a filter inside the "page"
Both have the same scroll direction
My problem is that when I'm scrolling the child one, and it reaches the end, the parent one starts moving. And I would like to avoid that. I tried many things
*ScrollView delegates
*Touchesbegan
Any ideas?
Thanks!

I think it's easy .
To set the parent UICollectionView
collectionView.scrollEnabled = NO;
Or it sounds unreasonable . If the parent UICollectionView could scroll, how could you archive your goal? Because UICollectionView is consisted of the cells (The child one). The cell will affect the parent UICollectionView inevitably.
Maybe The child one is located in part of the cell , you could use the
UIScrollViewDelegate's method: scrollViewDidScroll , to set the parent collectionView's scrollEnabled property.

I think you prefer this answer.
As we know a pan gesture recognizer is built in UIScrollView.
We could add an other coordinated pan gesture.
Because the apple says: 'UIScrollView's built-in pan gesture recognizer must have its
scroll view as its delegate.'
let pan = UIPanGestureRecognizer(target: self, action: #selector(ViewController.panGestureRecognizerAction(recognizer:)))
pan.delegate = self
mainScrollView.addGestureRecognizer(pan)
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
// So we can get the argus of the pan gesture while not affecting the scroll
after the setting.
var mainScrollEnabled = false
var subScrollEnabled = false
// Then we define two BOOL values to identify the scroll of the collectionView
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == mainScrollView {
if scrollView.contentOffset.y >= maxOffsetY {
scrollView.setContentOffset(CGPoint(x: 0, y: maxOffsetY), animated: false)
mainScrollView.isScrollEnabled = false
subScrollView.isScrollEnabled = true
subScrollEnabled = true
mainScrollEnabled = false
}
}else {
if scrollView.contentOffset.y <= 0 {
scrollView.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
subScrollView.isScrollEnabled = false
mainScrollView.isScrollEnabled = true
mainScrollEnabled = true
subScrollEnabled = false
}
}
}
// Then we handle the situation that the collectionView reaches the end , by the pan gesture's recognizer .
var currentPanY: CGFloat = 0
func panGestureRecognizerAction(recognizer: UIPanGestureRecognizer) {
if recognizer.state != .changed{
currentPanY = 0
// clear the data of last time after finishing the scroll
mainScrollEnabled = false
subScrollEnabled = false
}else {
let currentY = recognizer.translation(in: mainScrollView).y
// So the collectionView reaches the end.
if mainScrollEnabled || subScrollEnabled {
if currentPanY == 0 {
currentPanY = currentY //get the y
}
let offsetY = currentPanY - currentY //get the offsetY
if mainScrollEnabled {
let supposeY = maxOffsetY + offsetY
if supposeY >= 0 {
mainScrollView.contentOffset = CGPoint(x: 0, y: supposeY)
}else {
mainScrollView.contentOffset = CGPoint.zero
}
}else {
subScrollView.contentOffset = CGPoint(x: 0, y: offsetY)
}
}
}
}

Related

UIPanGesture recognizer swiping only in one direction

I am working on UIPanGestureRecognizer and to me it is working. but I have some problem here as I am new to iOS and just shifted from Android to iOS.
First take a look at what I want to do:
What I want: I have a UITableView and I want to perform swiping on the Cells. I just want to drag them from left to right side and move/Delete that cell. Pretty same like it is done in android.
But I just want to move the item only in one direction. And that is "LEFT TO RIGHT". But not from right to left. Now here take a look at what I have done so far
What I have Done:
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
// 1
if recognizer.state == .began {
// when the gesture begins, record the current center location
originalCenter = center
print("Center",originalCenter)
}
// 2
if recognizer.state == .changed {
let translation = recognizer.translation(in: self)
center = CGPoint(x: originalCenter.x+translation.x, y: originalCenter.y)
// has the user dragged the item far enough to initiate a delete/complete?
deleteOnDragRelease = frame.origin.x < -frame.size.width / 2.0
completeOnDragRelease = frame.origin.x > frame.size.width / 2.0
// print ("FrameX = ",frame.origin.x , " , ","Width = ",frame.size.width / 2.0 , "Total = ",frame.origin.x < -frame.size.width / 2.0 )
//print ("DelOnDrag = ",deleteOnDragRelease , " , ","CompOnDrag = ",completeOnDragRelease)
}
// 3
if recognizer.state == .ended {
// the frame this cell had before user dragged it
let originalFrame = CGRect(x: 0, y: frame.origin.y,
width: bounds.size.width, height: bounds.size.height)
if deleteOnDragRelease {
if delegate != nil && clickedItem != nil {
// notify the delegate that this item should be deleted
delegate!.toDoItemDeleted(clickedItem: clickedItem!)
}
} else if completeOnDragRelease {
UIView.animate(withDuration: 8.2, animations: {self.frame = originalFrame})
} else {
UIView.animate(withDuration: 8.2, animations: {self.frame = originalFrame})
}
}
}
I know I can make a check on ".changed" , and calculate if the X value is going towards 0 or lesser then 0. But point is for some time it will move item from right to left.
Question: Is there any way I can get the x value of point of contact? or just some how I can get user want to swipe right to left and just stop user from doing that?? Please share your knowledge
your same code just one changes in your UIGestureRecognizer method replace with this code and your problem solve. only left to right side swap work on your tableview cell . any query regrading this just drop comment below.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer {
let translation = panGestureRecognizer.translation(in: superview!)
if translation.x >= 0 {
return true
}
return false
}
return false
}
Good Luck.
Keep coding.
You can do this using below extensions
extension UIPanGestureRecognizer {
enum GestureDirection {
case Up
case Down
case Left
case Right
}
func verticalDirection(target: UIView) -> GestureDirection {
return self.velocity(in: target).y > 0 ? .Down : .Up
}
func horizontalDirection(target: UIView) -> GestureDirection {
return self.velocity(in: target).x > 0 ? .Right : .Left
}
}
And you can get direction like below
gestureRecognizer.horizontalDirection(target: self)

Handling scroll views with (custom, interactive) view controller presentation and dismissal

I have been experimenting with custom interactive view controller presentation and dismissal (using a combination of UIPresentationController, UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning, and UIViewControllerTransitioningDelegate) and have mostly gotten things working well for my needs.
However, there is one common scenario that I've yet to find addressed in any of the tutorials or documentation that I've read, leading me to the following question:
...
What is the proper way of handling custom interactive view controller dismissal, via a pan gesture, when the dismissed view contains a UIScrollView (ie. UITableView, UICollectionView, WKWebView, etc)?
...
Basically, what I'd like is for the following:
View controllers are interactively dismissible by panning them down. This is common UX in many apps.
If the dismissed view controller contains a (vertically-scrolling) scroll view, panning down scrolls that view as expected until the user reaches the top, after which the scrolling ceases and the pan-to-dismiss occurs.
Scroll views should otherwise behave as normal.
I know that this is technically possible - I've seen it in other apps, such as Overcast and Apple's own Music app - but I've not been able to find the key to coordinating the behavior of my pan gesture with that of the scroll view(s).
Most of my own attempts center on trying to conditionally enable/disable the scrollview (or its associated pan gesture recognizer) based on its contentOffset.y while scrolling and having the view controller dismissal's pan gesture recognizer take over from there, but this has been fraught with problems and I fear that I am overthinking it.
I feel like the secret mostly lies in the following pan gesture recognizer delegate method:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// ...
}
I have created a reduced sample project which should demonstrate the scenario more clearly. Any code suggestions are highly welcome!
https://github.com/Darchmare/SlidePanel-iOS
Solution
Make scrollView stop scrolling after it reached top by using UIScrollView's bounces property and scrollViewDidScroll(_:) method.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.bounces = (scrollView.contentOffset.y > 10);
}
Don't forget to set scrollView.delegate = self
Only handle panGestureRecognizer when scrollView reached top - It means when scrollView.contentOffset.y == 0 by using a protocol.
protocol PanelAnimationControllerDelegate {
func shouldHandlePanelInteractionGesture() -> Bool
}
ViewController
func shouldHandlePanelInteractionGesture() -> Bool {
return (scrollView.contentOffset.y == 0);
}
PanelInteractionController
class PanelInteractionController: ... {
var startY:CGFloat = 0
private weak var viewController: (UIViewController & PanelAnimationControllerDelegate)?
#objc func handlePanGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
break
case .changed:
let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
let velocity = gestureRecognizer.velocity(in: gestureRecognizer.view!.superview)
let state = gestureRecognizer.state
// Don't do anything when |scrollView| is scrolling
if !(viewController?.shouldHandlePanelInteractionGesture())! && percentComplete == 0 {
return;
}
var rawProgress = CGFloat(0.0)
rawProgress = ((translation.y - startTransitionY) / gestureRecognizer.view!.bounds.size.height)
let progress = CGFloat(fminf(fmaxf(Float(rawProgress), 0.0), 1.0))
if abs(velocity.x) > abs(velocity.y) && state == .began {
// If the user attempts a pan and it looks like it's going to be mostly horizontal, bail - we don't want it... - JAC
return
}
if !self.interactionInProgress {
// Start to pan |viewController| down
self.interactionInProgress = true
startTransitionY = translation.y;
self.viewController?.dismiss(animated: true, completion: nil)
} else {
// If the user gets to a certain point within the dismissal and releases the panel, allow the dismissal to complete... - JAC
self.shouldCompleteTransition = progress > 0.2
update(progress)
}
case .cancelled:
self.interactionInProgress = false
startTransitionY = 0
cancel()
case .ended:
self.interactionInProgress = false
startTransitionY = 0
if self.shouldCompleteTransition == false {
cancel()
} else {
finish()
}
case .failed:
self.interactionInProgress = false
startTransitionY = 0
cancel()
default:
break;
}
}
}
Result
For more detail, you can take a look at my sample project
For me, this little bit of code answered a lot of my issues and greatly helped my custom transitions in scrollviews, it will hold a negative scrollview offset from moving while trying to start a transition or showing an activity indicator on the top. My guess is that this will solve at least some of your transition/animation hiccups:
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y < -75 {
scrollView.contentInset.top = -scrollView.contentOffset.y
}
// Do animation or transition
}
I believe you don't need an additional pan gesture recognizer to implement this. You can simply hook onto the different delegate methods of the scroll view to achieve the "pan to dismiss" effect. Here is how I went about it
// Set the dragging property to true
func scrollViewWillBeginDragging(_: UIScrollView) {
isDragging = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// If not dragging, we could make an early exit
guard isDragging else {
return
}
let topOffset = scrollView.contentOffset.y + statusBarHeight
// If The dismissal has not already started and the user has scrolled to the top and they are currently scrolling, then initiate the interactive dismissal
if !isDismissing && topOffset <= 0 && scrollView.isTracking {
startInteractiveTransition()
return
}
// If its already being dismissed, then calculate the progress and update the interactive dismissal animator
if isDismissing {
updateInteractiveTransitionProgress()
}
}
// Once the scroll ends, check for a few things
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// Early return
if !isDismissing {
return
}
// Optional check to dismiss the controller, if swiped from the top
checkForFastSwipes()
// If dragged enough, dismiss the controller, otherwise cancel the
transition
if interactor?.shouldFinish ?? false {
interactor?.finish()
} else {
interactor?.cancel()
}
// Finally reset the transition properties
resetTransitionProperties()
}
private func checkForFastSwipes() {
let velocity = scrollView.panGestureRecognizer.velocity(in: view)
let velDiff = velocity.y - velocity.x
if velDiff > 0 && velDiff >= 75 {
interactor?.hasStarted = false
self.dismiss()
interactor?.shouldFinish = true
}
}
private func startInteractiveTransition() {
isDismissing = true
interactor?.hasStarted = true
dismiss()
}
private func updateInteractiveTransitionProgress() {
progress = max(
0.0,
min(1.0, ((-scrollView.contentOffset.y) - statusBarHeight) / 90.0)
)
interactor?.shouldFinish = progress > 0.5
interactor?.update(progress)
}
private func resetTransitionProperties() {
isDismissing = false
isDragging = false
}
The interactor property used for synchronizing the animation with gesture
var interactor: Interactor?
class Interactor: UIPercentDrivenInteractiveTransition {
var hasStarted = false
var shouldFinish = false
}
Inspired by the following Kodeco tutorial
https://www.kodeco.com/books/ios-animations-by-tutorials/v6.0/chapters/25-uiviewpropertyanimator-view-controller-transitions
(Look for the Interactive view controller transitions section)
Edit
After implementing the solution below, I realized that it only works if you have sufficient content to scroll, however, if you have dynamic content wherein the contents are not guaranteed to be scrollable as was my case, you'd be better off adding a pan gesture as mentioned by #trungduc. However, there are a few improvements that we could make to their answer like detecting an upwards scroll and not letting it interfere with our gesture.
Under the changed state add the following code
let isUpwardsScroll = self.velocity(in: target).y < 0
/*
If the user is normally scrolling the view, ignore it. However,
once the interaction starts allow such gestures as they could be
dragging the interactable view back
*/
if isUpwardScroll && !interactor.hasStarted {
return
}

Is it possible to add refresh control below section header

I have a table view with section header. I want to add refresh control below the section header and not at top of tableview.Is this possible??
No.It is not possible.Try adding an activity indicator in the section header.
If u want the activity indicator to be shown only when the user pulls down while tableview content offset is 0, then try adding a pan gesture and implementing it only for this specific condition
var panGesture = UIPanGestureRecognizer(target: self, action: #selector(swipedDown))
self.HometableView.addGestureRecognizer(panGesture)
panGesture.delegate = self as! UIGestureRecognizerDelegate
//The code to handle the pan gesture
func swipedDown(panGesture: UIPanGestureRecognizer)
{
let velocity: CGPoint = panGesture.velocity(in: HometableView)
if velocity.y > 0 {
print("gesture moving Up")
if(HometableView.contentOffset == CGPoint.zero)
{
print("************SWIPED DOWN")
//Do the logic to increase section header height and show the activity indicator
}
}
else {
print("gesture moving Bottom")
}
}
func gestureRecognizerShouldBegin(_ panGestureRecognizer: UIPanGestureRecognizer) -> Bool {
let velocity: CGPoint = panGestureRecognizer.velocity(in: HometableView)
if velocity.y > 0 {
if(HometableView.contentOffset == CGPoint.zero)
{
return true
}
}
return false
}
No it is not possible as the section header is part of tableView.
You can add the view not as a part of section header but at the top of UITableView.

Material "pull to refresh" in Swift?

I am trying to achieve pull to refresh in a WKWebView like in this Material pull to refresh GIF. Because we want to include websites that already have a html navigation bar, we need to keep the web view fix when dragging down. I found pull to refresh (UIRefreshControl) to table views and web views but the views go down as the user drags down.
I set the delegate to scrollView property of the web view and receive notifications. When I drag near to top and get to 0 (vertical scroll view offset) I can disable the scroll view, enable the pan gesture. But to actually move the custom spinner view I need a second touch.
The method func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) doesn't work because I have a scroll view over.
private func gestures() {
self.panGesture = UIPanGestureRecognizer(
target: self,
action: "panGestureCaptured:"
)
self.panGesture!.enabled = false
self.webView.addGestureRecognizer(self.panGesture!)
}
func scrollViewDidScroll(scrollView: UIScrollView) {
if scrollView.contentOffset.y <= 0 {
self.view.layoutIfNeeded()
self.spinnerTopLayoutConstraint?.constant = -scrollView.contentOffset.y
}
}
func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.panGestureRecognizer.translationInView(scrollView.superview).y > 0 { // dragging down
if scrollView.contentOffset.y == 0 {
self.webView!.scrollView.scrollEnabled = false
// self.webView!.scrollView.canCancelContentTouches = true
self.panGesture!.enabled = true
// self.refreshWebView()
}
self.showNavigationItems()
} else { // dragging up
self.hideNavigationItems()
}
}
Thanks to #Darko: the idea was to use the panGesture property of the scroll view (also property of the web view).
private func gestures() {
self.webView.scrollView.panGestureRecognizer.addTarget(
self,
action: "panGestureCaptured:"
)
}
func panGestureCaptured(gesture: UIGestureRecognizer) {
let touchPoint = gesture.locationInView(self.webView)
print("touchPoint: \(touchPoint)")
print("panGestureCaptured scrollView offset \(self.webView!.scrollView.contentOffset.y)")
if self.webView!.scrollView.contentOffset.y == 0 {
if self.webView!.scrollView.panGestureRecognizer.state == UIGestureRecognizerState.Changed {
if touchPoint.y < self.webView!.frame.height * 0.3 {
self.spinnerTopLayoutConstraint?.constant = touchPoint.y
} else {
self.spinnerTopLayoutConstraint?.constant = self.webView!.frame.height * 0.3
}
} else if self.webView!.scrollView.panGestureRecognizer.state == UIGestureRecognizerState.Ended {
self.spinnerTopLayoutConstraint?.constant = UIApplication.sharedApplication().statusBarFrame.height + 20
}
}
}
WebView's scroll view already has a pan recognizer (webView.scrollView.panGestureRecognizer) so you could listen to this one (with addTarget) and disable bouncing on the scroll view. In this way you don't need to disable/enable the pan recognizer.

How to implement UITableView`s swipe to delete for UICollectionView

I just like to ask how can I implement the same behavior of UITableView`s swipe to delete in UICollectionView. I am trying to find a tutorial but I cannot find any.
Also, I am using PSTCollectionView wrapper to support iOS 5.
Thank you!
Edit:
The swipe recognizer is already good.
What I need now is the same functionality as UITableView's when cancelling the Delete mode, e.g. when user taps on a cell or on a blank space in the table view (that is, when user taps outside of the Delete button).
UITapGestureRecognizer won't work, since it only detects taps on release of a touch.
UITableView detects a touch on begin of the gesture (and not on release), and immediately cancels the Delete mode.
There is a simpler solution to your problem that avoids using gesture recognizers. The solution is based on UIScrollView in combination with UIStackView.
First, you need to create 2 container views - one for the visible part of the cell and one for the hidden part. You’ll add these views to a UIStackView. The stackView will act as a content view. Make sure that the views have equal widths with stackView.distribution = .fillEqually.
You’ll embed the stackView inside a UIScrollView that has paging enabled. The scrollView should be constrained to the edges of the cell. Then you’ll set the stackView’s width to be 2 times the scrollView’s width so each of the container views will have the width of the cell.
With this simple implementation, you have created the base cell with a visible and hidden view. Use the visible view to add content to the cell and in the hidden view you can add a delete button. This way you can achieve this:
I've set up an example project on GitHub. You can also read more about this solution here.
The biggest advantage of this solution is the simplicity and that you don't have to deal with constraints and gesture recognizers.
Its very simple..You need to add a customContentView and customBackgroundView behind the customContentView.
After that and you need to shift the customContentViewto the left as user swipes from right to left. Shifting the view makes visible to the customBackgroundView.
Lets Code:
First of all you need to add panGesture to your UICollectionView as
override func viewDidLoad() {
super.viewDidLoad()
self.panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panThisCell))
panGesture.delegate = self
self.collectionView.addGestureRecognizer(panGesture)
}
Now implement the selector as
func panThisCell(_ recognizer:UIPanGestureRecognizer){
if recognizer != panGesture{ return }
let point = recognizer.location(in: self.collectionView)
let indexpath = self.collectionView.indexPathForItem(at: point)
if indexpath == nil{ return }
guard let cell = self.collectionView.cellForItem(at: indexpath!) as? CustomCollectionViewCell else{
return
}
switch recognizer.state {
case .began:
cell.startPoint = self.collectionView.convert(point, to: cell)
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant
if swipeActiveCell != cell && swipeActiveCell != nil{
self.resetConstraintToZero(swipeActiveCell!,animate: true, notifyDelegateDidClose: false)
}
swipeActiveCell = cell
case .changed:
let currentPoint = self.collectionView.convert(point, to: cell)
let deltaX = currentPoint.x - cell.startPoint.x
var panningleft = false
if currentPoint.x < cell.startPoint.x{
panningleft = true
}
if cell.startingRightLayoutConstraintConstant == 0{
if !panningleft{
let constant = max(-deltaX,0)
if constant == 0{
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: false)
}else{
cell.contentViewRightConstraint.constant = constant
}
}else{
let constant = min(-deltaX,self.getButtonTotalWidth(cell))
if constant == self.getButtonTotalWidth(cell){
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: false)
}else{
cell.contentViewRightConstraint.constant = constant
cell.contentViewLeftConstraint.constant = -constant
}
}
}else{
let adjustment = cell.startingRightLayoutConstraintConstant - deltaX;
if (!panningleft) {
let constant = max(adjustment, 0);
if (constant == 0) {
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: false)
} else {
cell.contentViewRightConstraint.constant = constant;
}
} else {
let constant = min(adjustment, self.getButtonTotalWidth(cell));
if (constant == self.getButtonTotalWidth(cell)) {
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: false)
} else {
cell.contentViewRightConstraint.constant = constant;
}
}
cell.contentViewLeftConstraint.constant = -cell.contentViewRightConstraint.constant;
}
cell.layoutIfNeeded()
case .cancelled:
if (cell.startingRightLayoutConstraintConstant == 0) {
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
} else {
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
}
case .ended:
if (cell.startingRightLayoutConstraintConstant == 0) {
//Cell was opening
let halfOfButtonOne = (cell.swipeView.frame).width / 2;
if (cell.contentViewRightConstraint.constant >= halfOfButtonOne) {
//Open all the way
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
} else {
//Re-close
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
}
} else {
//Cell was closing
let buttonOnePlusHalfOfButton2 = (cell.swipeView.frame).width
if (cell.contentViewRightConstraint.constant >= buttonOnePlusHalfOfButton2) {
//Re-open all the way
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
} else {
//Close
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
}
}
default:
print("default")
}
}
Helper methods to update constraints
func getButtonTotalWidth(_ cell:CustomCollectionViewCell)->CGFloat{
let width = cell.frame.width - cell.swipeView.frame.minX
return width
}
func resetConstraintToZero(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidClose:Bool){
if (cell.startingRightLayoutConstraintConstant == 0 &&
cell.contentViewRightConstraint.constant == 0) {
//Already all the way closed, no bounce necessary
return;
}
cell.contentViewRightConstraint.constant = -kBounceValue;
cell.contentViewLeftConstraint.constant = kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewRightConstraint.constant = 0;
cell.contentViewLeftConstraint.constant = 0;
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
cell.startPoint = CGPoint()
swipeActiveCell = nil
}
func setConstraintsToShowAllButtons(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidOpen:Bool){
if (cell.startingRightLayoutConstraintConstant == self.getButtonTotalWidth(cell) &&
cell.contentViewRightConstraint.constant == self.getButtonTotalWidth(cell)) {
return;
}
cell.contentViewLeftConstraint.constant = -self.getButtonTotalWidth(cell) - kBounceValue;
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell) + kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewLeftConstraint.constant = -(self.getButtonTotalWidth(cell))
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell)
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {(check) in
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
}
func setConstraintsAsSwipe(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidOpen:Bool){
if (cell.startingRightLayoutConstraintConstant == self.getButtonTotalWidth(cell) &&
cell.contentViewRightConstraint.constant == self.getButtonTotalWidth(cell)) {
return;
}
cell.contentViewLeftConstraint.constant = -self.getButtonTotalWidth(cell) - kBounceValue;
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell) + kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewLeftConstraint.constant = -(self.getButtonTotalWidth(cell))
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell)
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {(check) in
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
}
func updateConstraintsIfNeeded(_ cell:CustomCollectionViewCell, animated:Bool,completionHandler:#escaping ()->()) {
var duration:Double = 0
if animated{
duration = 0.1
}
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut], animations: {
cell.layoutIfNeeded()
}, completion:{ value in
if value{ completionHandler() }
})
}
I have created a sample project here in Swift 3.
It is a modified version of this tutorial.
In the Collection View Programming Guide for iOS, in the section Incorporating Gesture Support, the docs read:
You should always attach your gesture recognizers to the collection view itself and not to a specific cell or view.
So, I think it's not a good practice to add recognizers to UICollectionViewCell.
I followed a similar approach to #JacekLampart, but decided to add the UISwipeGestureRecognizer in the UICollectionViewCell's awakeFromNib function so it is only added once.
UICollectionViewCell.m
- (void)awakeFromNib {
UISwipeGestureRecognizer* swipeGestureRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:#selector(swipeToDeleteGesture:)];
swipeGestureRecognizer.direction = UISwipeGestureRecognizerDirectionLeft;
[self addGestureRecognizer:swipeGestureRecognizer];
}
- (void)swipeToDeleteGesture:(UISwipeGestureRecognizer *)swipeGestureRecognizer {
if (swipeGestureRecognizer.state == UIGestureRecognizerStateEnded) {
// update cell to display delete functionality
}
}
As for exiting delete mode, I created a custom UIGestureRecognizer with an NSArray of UIViews. I borrowed the idea from #iMS from this question: UITapGestureRecognizer - make it work on touch down, not touch up?
On touchesBegan, if the touch point isn't within any of the UIViews, the gesture succeeds and delete mode is exited.
In this way, I am able to pass the delete button within the cell (and any other views) to the UIGestureRecognizer and, if the touch point is within the button's frame, delete mode will not exit.
TouchDownExcludingViewsGestureRecognizer.h
#import <UIKit/UIKit.h>
#interface TouchDownExcludingViewsGestureRecognizer : UIGestureRecognizer
#property (nonatomic) NSArray *excludeViews;
#end
TouchDownExcludingViewsGestureRecognizer.m
#import "TouchDownExcludingViewsGestureRecognizer.h"
#import <UIKit/UIGestureRecognizerSubclass.h>
#implementation TouchDownExcludingViewsGestureRecognizer
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.state == UIGestureRecognizerStatePossible) {
BOOL touchHandled = NO;
for (UIView *view in self.excludeViews) {
CGPoint touchLocation = [[touches anyObject] locationInView:view];
if (CGRectContainsPoint(view.bounds, touchLocation)) {
touchHandled = YES;
break;
}
}
self.state = (touchHandled ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateRecognized);
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
self.state = UIGestureRecognizerStateFailed;
}
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
self.state = UIGestureRecognizerStateFailed;
}
#end
Implementation (in the UIViewController containing UICollectionView):
#import "TouchDownExcludingViewsGestureRecognizer.h"
TouchDownExcludingViewsGestureRecognizer *touchDownGestureRecognizer = [[TouchDownExcludingViewsGestureRecognizer alloc] initWithTarget:self action:#selector(exitDeleteMode:)];
touchDownGestureRecognizer.excludeViews = #[self.cellInDeleteMode.deleteButton];
[self.view addGestureRecognizer:touchDownGestureRecognizer];
- (void)exitDeleteMode:(TouchDownExcludingViewsGestureRecognizer *)touchDownGestureRecognizer {
// exit delete mode and disable or remove TouchDownExcludingViewsGestureRecognizer
}
You can try adding a UISwipeGestureRecognizer to each collection cell, like this:
-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
CollectionViewCell *cell = ...
UISwipeGestureRecognizer* gestureRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:#selector(userDidSwipe:)];
[gestureRecognizer setDirection:UISwipeGestureRecognizerDirectionRight];
[cell addGestureRecognizer:gestureRecognizer];
}
followed by:
- (void)userDidSwipe:(UIGestureRecognizer *)gestureRecognizer {
if (gestureRecognizer.state == UIGestureRecognizerStateEnded) {
//handle the gesture appropriately
}
}
With iOS 14, you can use UICollectionViewLayoutListConfiguration in conjunction with UICollectionViewCompositionalLayout to get this functionality natively for free, no custom cells or gesture recognizes needed.
If your minimum deploy target is >= iOS 14.x, this is probably the preferred method from now on, and it will also let you to adopt modern cell configuration with UIContentView and UIContentConfiguration to boot.
There is a more standard solution to implement this feature, having a behavior very similar to the one provided by UITableView.
For this, you will use a UIScrollView as the root view of the cell, and then position the cell content and the delete button inside the scroll view. The code in your cell class should be something like this:
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(scrollView)
scrollView.addSubview(viewWithCellContent)
scrollView.addSubview(deleteButton)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
}
In this code we set the property isPagingEnabled to true to make the scroll view to stop scrolling only at the boundaries of its content. The layout subviews for this cell should be something like:
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
// make the view with the content to fill the scroll view
viewWithCellContent.frame = scrollView.bounds
// position the delete button just at the right of the view with the content.
deleteButton.frame = CGRect(
x: label.frame.maxX,
y: 0,
width: 100,
height: scrollView.bounds.height
)
// update the size of the scrolleable content of the scroll view
scrollView.contentSize = CGSize(width: button.frame.maxX, height: scrollView.bounds.height)
}
With this code in place, if you run the app you will see that the swipe to delete is working as expected, however, we lost the ability to select the cell. The problem is that since the scroll view is filling the whole cell, all the touch events are processed by it, so the collection view will never have the opportunity to select the cell (this is similar to when we have a button inside a cell, since touches on that button don't trigger the selection process but are handled directly by the button.)
To fix this problem we just have to indicate the scroll view to ignore the touch events that are processed by it and not by one of its subviews. To achieve this just create a subclass of UIScrollView and override the following function:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
return result != self ? result : nil
}
Now in your cell you should use an instance of this new subclass instead of the standard UIScrollView.
If you run the app now you will see that we have the cell selection back, but this time the swipe isn't working 😳. Since we are ignoring touches that are handled directly by the scroll view, then its pan gesture recognizer won't be able to start recognizing touch events. However, this can be easily fixed by indicating to the scroll view that its pan gesture recognizer will be handled by the cell and not by the scroll. You do this adding the following line at the bottom of your cell's init(frame: CGRect):
addGestureRecognizer(scrollView.panGestureRecognizer)
This may look like a bit hacky, but it isn't. By design, the view that contains a gesture recognizer and the target of that recognizer don't have to be the same object.
After this change all should be working as expected. You can see a full implementation of this idea in this repo

Resources