I'm trying to replicate the swipe to delete functionality of iOS. I know it's instantly available on a tableview, but the UI that I need to build benefits from a Collection View. Therefor I need a custom implementation where I would be using a swipe up gesture. Luckily, that's something that I managed to implement myself, however I'm having a hard time figuring out how I need to setup the swipe to delete / tap to delete / ignore functionality.
The UI currently looks like this:
So I'm using the following collectionview:
func buildCollectionView() {
let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = 0;
layout.minimumLineSpacing = 4;
collectionView = UICollectionView(frame: CGRect(x: 0, y: screenSize.midY - 120, width: screenSize.width, height: 180), collectionViewLayout: layout)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(VideoCell.self, forCellWithReuseIdentifier: "videoCell")
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.contentInset = UIEdgeInsetsMake(0, 20, 0, 30)
collectionView.backgroundColor = UIColor.white()
collectionView.alpha = 0.0
//can swipe cells outside collectionview region
collectionView.layer.masksToBounds = false
swipeUpRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.deleteCell))
swipeUpRecognizer.delegate = self
collectionView.addGestureRecognizer(swipeUpRecognizer)
collectionView.isUserInteractionEnabled = true
}
My custom videocell contains one image and below that there is the delete button. So if you swipe the image up the delete button pops up. Not sure if this is the right way on how to do it:
class VideoCell : UICollectionViewCell {
var deleteView: UIButton!
var imageView: UIImageView!
override init(frame: CGRect) {
super.init(frame: frame)
deleteView = UIButton(frame: CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height))
deleteView.contentMode = UIViewContentMode.scaleAspectFit
contentView.addSubview(deleteView)
imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height))
imageView.contentMode = UIViewContentMode.scaleAspectFit
contentView.addSubview(imageView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
And I'm using the following logic:
func deleteCell(sender: UIPanGestureRecognizer) {
let tapLocation = sender.location(in: self.collectionView)
let indexPath = self.collectionView.indexPathForItem(at: tapLocation)
if velocity.y < 0 {
//detect if there is a swipe up and detect it's distance. If the distance is far enough we snap the cells Imageview to the top otherwise we drop it back down. This works fine already.
}
}
But the problem starts there. As soon as my cell is outside the collectionview bounds I can't access it anymore. I still want to swipe it further to remove it. I can only do this by swiping on the delete button, but I want the Imageview above it to be swipeable as well. Or if I tap the image outside the collectionview it should slide back into the line and not delete it.
If I increase the collectionview bounds I can prevent this problem but than I can also swipe to remove outside the cell's visible height. This is caused by the tapLocation that is inside the collectionview and detects an indexPath. Something that I don't want. I want the swipe up only to work on a collectionview's cell.
Also the button and the image interfere with each other because I cannot distinguish them. They are both in the same cell so that's why I'm wondering if I should have the delete button in the cell at all. Or where should I place it otherwise? I could also make two buttons out of it and disable user interaction depending on state, but not sure how that would end up.
So, if you want the swipes gesture recogniser to continue recording movement when they are outside of their collection view, you need to attach it to the parent of the collection view, so it's bounded to the full area where the user can swipe.
That does mean that you will get swipes for things outside the collection view, but you can quite easily ignore those using any number of techniques.
To register delete button taps, you'll need to call addTarget:action:forControlEvents: on the button
I would keep the cell as you have it, with the image and the button together. It will be much easier to manage, and they belong together.
To manage moving the image up and down, I would look at using a transform, or an NSLayoutConstraint. Then you just have to adjust one value to make it move up and down in sync with the user swipes. No messing with frames.
For my own curiosity's sake I tried to make a replicate of what you're trying to do, and got it to work somehow good. It differs from yours in the way I setup the swipe gestures as I didn't use pan, but you said you already had that part, and haven't spend too much time on it. Pan is obviously the more solid solution to make it interactive, but takes a little longer to calculate, but the effect and handling of it, shouldn't differ much from my example.
To resolve the issue not being able to swipe outside the cell I decided to check if the point was in the swiped rect, which is twice the height of the non-swiped rect like this:
let cellFrame = activeCell.frame
let rect = CGRectMake(cellFrame.origin.x, cellFrame.origin.y - cellFrame.height, cellFrame.width, cellFrame.height*2)
if CGRectContainsPoint(rect, point) {
// If swipe point is in the cell delete it
let indexPath = myView.indexPathForCell(activeCell)
cats.removeAtIndex(indexPath!.row)
myView.deleteItemsAtIndexPaths([indexPath!])
}
I created a demonstration with comments: https://github.com/imbue11235/swipeToDeleteCell
I hope it helps you in anyway!
If you want to make it mare generic:
Make a costume Swipeable View:
import UIKit
class SwipeView: UIView {
lazy var label: UILabel = {
let label = UILabel()
label.textColor = .black
label.backgroundColor = .green
return label
}()
let visableView = UIView()
var originalPoint: CGPoint!
var maxSwipe: CGFloat! = 50 {
didSet(newValue) {
maxSwipe = newValue
}
}
#IBInspectable var swipeBufffer: CGFloat = 2.0
#IBInspectable var highVelocity: CGFloat = 300.0
private let originalXCenter: CGFloat = UIScreen.main.bounds.width / 2
private var panGesture: UIPanGestureRecognizer!
public var isPanGestureEnabled: Bool {
get { return panGesture.isEnabled }
set(newValue) {
panGesture.isEnabled = newValue
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
setupGesture()
}
private func setupViews() {
addSubview(visableView)
visableView.addSubview(label)
visableView.edgesToSuperview()
label.edgesToSuperview()
}
private func setupGesture() {
panGesture = UIPanGestureRecognizer(target: self, action: #selector(swipe(_:)))
panGesture.delegate = self
addGestureRecognizer(panGesture)
}
#objc func swipe(_ sender:UIPanGestureRecognizer) {
let translation = sender.translation(in: self)
let newXPosition = center.x + translation.x
let velocity = sender.velocity(in: self)
switch(sender.state) {
case .changed:
let shouldSwipeRight = translation.x > 0 && newXPosition < originalXCenter
let shouldSwipeLeft = translation.x < 0 && newXPosition > originalXCenter - maxSwipe
guard shouldSwipeRight || shouldSwipeLeft else { break }
center.x = newXPosition
case .ended:
if -velocity.x > highVelocity {
center.x = originalXCenter - maxSwipe
break
}
guard center.x > originalXCenter - maxSwipe - swipeBufffer, center.x < originalXCenter - maxSwipe + swipeBufffer, velocity.x < highVelocity else {
center.x = originalXCenter
break
}
default:
break
}
panGesture.setTranslation(.zero, in: self)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension SwipeView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
The embed swappable view in UICollectionViewCell:
import UIKit
import TinyConstraints
protocol DeleteCellDelegate {
func deleteCell(_ sender : UIButton)
}
class SwipeableCell: UICollectionViewCell {
lazy var deleteButton: UIButton = {
let button = UIButton()
button.backgroundColor = .red
button.addTarget(self, action: #selector(didPressedButton(_:)), for: .touchUpInside)
button.titleLabel?.text = "Delete"
return button
}()
var deleteCellDelegate: DeleteCellDelegate?
#objc private func didPressedButton(_ sender: UIButton) {
deleteCellDelegate?.deleteCell(sender)
print("delete")
}
let swipeableview: SwipeView = {
return SwipeView()
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(deleteButton)
addSubview(swipeableview)
swipeableview.edgesToSuperview()
deleteButton.edgesToSuperview(excluding: .left, usingSafeArea: true)
deleteButton.width(bounds.width * 0.3)
swipeableview.maxSwipe = deleteButton.bounds.width
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
A sample ViewController:
import UIKit
import TinyConstraints
class ViewController: UIViewController, DeleteCellDelegate {
func deleteCell(_ sender: UIButton) {
let indexPath = IndexPath(item: sender.tag, section: 0)
items.remove(at: sender.tag)
collectionView.deleteItems(at: [indexPath])
}
lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: view.bounds.width, height: 40)
layout.sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.backgroundColor = .yellow
cv.isPagingEnabled = true
cv.isUserInteractionEnabled = true
return cv
}()
var items = ["1", "2", "3"]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.edgesToSuperview(usingSafeArea: true)
collectionView.register(SwipeableCell.self, forCellWithReuseIdentifier: "cell")
let panGesture = UIPanGestureRecognizer()
view.addGestureRecognizer(panGesture)
panGesture.delegate = self
}
}
extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
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) as! SwipeableCell
cell.backgroundColor = .blue
cell.swipeableview.label.text = items[indexPath.item]
cell.deleteButton.tag = indexPath.item
cell.deleteCellDelegate = self
return cell
}
func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) {
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
}
}
extension ViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
Related
I am trying to achieve a collection view where the cells are aligned at the bottom with a paging effect where the "selected" cell is bigger than the rest. Like this:
As of now, I am able to get the effect to work but the cells are aligned in the middle instead of at the bottom:
I have tried setting the anchorPoint property of the cell to pin the cells at (0, 1) in apply(_ layoutAttributes: UICollectionViewLayoutAttributes), but this causes the cells to move and as as a result they appear cut off. This ends up looking like this:
How do I pin these collection view cells at the bottom left corner, also respecting the CGAffine scale effect that occurs during paging?
Here is my code:
Custom UICollectionViewFlowLayout:
import Foundation
import UIKit
/// The layout used in the cover flow.
class CoverFlowLayout: UICollectionViewFlowLayout {
let activeDistance: CGFloat = 25
let zoomFactor: CGFloat = (CoverFlowCell.selectedSize / CoverFlowCell.unselectedSize) - 1
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
let itemSpace = itemSize.width + minimumInteritemSpacing
var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)
let vX = velocity.x
if vX > 0 {
currentItemIdx += 1
} else if vX < 0 {
currentItemIdx -= 1
}
let nearestPageOffset = currentItemIdx * itemSpace
return CGPoint(x: nearestPageOffset, y: 0)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)
// Make the cells be zoomed when they reach the center of the screen
for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
let distance = (visibleRect.minX + 20) - attributes.frame.minX
let normalizedDistance = distance / activeDistance
if distance.magnitude < activeDistance {
let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
attributes.transform = CGAffineTransform(scaleX: zoom, y: zoom)
}
}
return rectAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// Invalidate layout so that every cell get a chance to be zoomed when it reaches the center of the screen
return true
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
Custom UICollectionView Cell
import Foundation
import UIKit
class CoverFlowCell: UICollectionViewCell {
static let unselectedSize: CGFloat = 185; // The size of the cell when it is not selected in the carousel
static let selectedSize: CGFloat = 200;
private var albumArt: UIImageView = {
let art = UIImageView()
art.backgroundColor = UIColor(hexString: "#ECF0F1")
art.translatesAutoresizingMaskIntoConstraints = false
art.layer.cornerRadius = 2
return art
}()
/// Initializer
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupUIConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
}
private func setupUI() {
contentView.addSubview(albumArt)
}
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
//we must change the anchor point for propper cells positioning and scaling
self.layer.anchorPoint.x = 0
self.layer.anchorPoint.y = 1
}
private func setupUIConstraints() {
NSLayoutConstraint.activate([
self.albumArt.topAnchor.constraint(equalTo: topAnchor),
self.albumArt.bottomAnchor.constraint(equalTo: bottomAnchor),
self.albumArt.leftAnchor.constraint(equalTo: leftAnchor),
self.albumArt.rightAnchor.constraint(equalTo: rightAnchor)
])
}
}
I have tried referring to this thread:
Changing my CALayer's anchorPoint moves the view
But the solution provided did not help align the cells at the bottom.
Thanks
Add another transform to translate the y position, to slide it up after you scale it up:
let y = //set a negative number here, to slide up by that many points
transform = CGAffineTransform(translationX: 0, y: y)
I would also just apply the transform in the "did select item" method, rather than fuss with it in the layout attributes methods. Then when the cell is "deselected", you can just set the transform to .identity to reset it back to the normal layout.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//set transforms
}
func collectionView(_ UICollectionView, didDeselectItemAt: IndexPath) {
//set transform to .identity
}
I have been trying to piece together a project that contains a collection view that scrolls on the side, and when a cell is tapped it will add a new image view to the scene. I would like this new image to be draggable.
My code currently shows the collection view and when tapped adds a new image (which I call stickers).
I haven't quite figured out how to make it place the correct sticker yet, but my first goal is to make sure what appears can be moved.
import UIKit
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
#IBOutlet weak var collectionView: UICollectionView!
let stickers: [UIImage] = [
UIImage(named: "cow")!,
UIImage(named: "chicken")!,
UIImage(named: "pig")!,
UIImage(named: "cow")!,
UIImage(named: "chicken")!,
UIImage(named: "pig")!,
UIImage(named: "cow")!,
UIImage(named: "chicken")!,
UIImage(named: "pig")!,
]
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return stickers.count
}
var activeSticker = UIImage(named: "cow")
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! CollectionViewCell
cell.stickerImage.image = stickers[indexPath.item]
cell.backgroundColor = UIColor(white: 1, alpha: 0.9)
cell.translatesAutoresizingMaskIntoConstraints = false
cell.contentMode = .scaleAspectFill
cell.clipsToBounds = true
cell.layer.cornerRadius = 7
let addSticker = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
addSticker.addTarget(self, action: #selector(addStickerTapped), for: UIControl.Event.touchUpInside)
activeSticker = cell.stickerImage.image
cell.addSubview(addSticker)
return cell
}
#IBAction func addStickerTapped() -> Void {
print("Hello Sticker Button")
let image = activeSticker //UIImage(named: imageName)
let imageView = UIImageView(image: image!)
imageView.frame = CGRect(x: 100, y: 100, width: 100, height: 200)
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
self.view.addSubview(imageView)
//Imageview on Top of View
self.view.bringSubviewToFront(imageView)
}
}
To move the imageView around inside self.view you can use UIPanGestureRecognizer. Add the gesture recognizer, for example, in viewDidLoad:
class ViewController: UIViewController {
var selectedImageView: UIImageView?
override func viewDidLoad() {
super.viewDidLoad()
addPanGestureRecognizer()
}
func addPanGestureRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(moveImageView(_:)))
// set up and optimize pan gesture options here if you need to
self.view.addGestureRecognizer(pan)
}
#objc func moveImageView(_ sender: UIPanGestureRecognizer) {
// assign one of your image views to selectedImageView to ensure you only move one image view at a time
// for example in func addStickerTapped() you could assign selectedImageView = imageView
guard let selectedImageView = selectedImageView else {
return
}
switch sender.state {
case .changed, .ended:
selectedImageView.center = selectedImageView.center.offset(by: sender.translation(in: self.view))
sender.setTranslation(.zero, in: self.view)
default:
break
}
}
}
extension CGPoint {
func offset(by point: CGPoint) -> CGPoint {
return CGPoint(x: self.x + point.x, y: self.y + point.y)
}
}
I have a UIScrollView that I am trying to use as an Image Viewer. For this I have paging enabled and I add "Slides" to the view for each Image, including a UIImageView and multiple labels and buttons. This works perfectly while I only have a few Slides to show, but I will need to have more than 100 of them, and I am running into really bad performance issues.
When I present the ViewController, and therefore set up the ScrollView, I get a good 10-15s of delay. Apparently loading this many views is a little much.
So I was wondering if any of you had an idea how I could make this more efficient.
I have tried making the array of Slides in the previous VC, and passing it, instead of creating it on the spot, that helped a bit, but not enough to make it feel acceptable, especially since changing device orientation will require me to set the ScrollView up again (because the Slides height/width will be off).
Here are the functions to set up the Slides, and to present them on the ScrollView:
func createSlides() -> [Slide] {
print("creating Slides")
let Essence = EssenceModel.Essence
var ImageArray = [Slide]()
var slide: Slide
var count = 0
for img in Essence{
count += 1
slide = Bundle.main.loadNibNamed("Slide", owner: self, options: nil)?.first as! Slide
slide.imageView.image = UIImage(named: img.imageUrl)
slide.isUserInteractionEnabled = true
slide.textLabel.text = img.description
slide.likeButton.imageView?.contentMode = .scaleAspectFit
slide.hero.id = img.heroID
slide.tag = count
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(showOrHide))
slide.imageView.addGestureRecognizer(tapGesture)
let dismissGesture = UITapGestureRecognizer(target: self, action: #selector(dismissVC))
slide.backButton.addGestureRecognizer(dismissGesture)
slide.backButton.isUserInteractionEnabled = true
let swipeUp = UISwipeGestureRecognizer(target: self, action: #selector(swipedUp))
swipeUp.direction = .up
slide.addGestureRecognizer(swipeUp)
let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(swipedDown))
swipeDown.direction = .down
slide.addGestureRecognizer(swipeDown)
let slideRecognizer = UITapGestureRecognizer(target: self, action: #selector(startSlideshow))
slide.slideButton.addGestureRecognizer(slideRecognizer)
slide.likeButton.imageView?.contentMode = .scaleAspectFit
slide.setupZoom()
ImageArray.append(slide)
}
count = 0
print(ImageArray.count)
return ImageArray
}
func setupSlideScrollView(slides : [Slide]) {
scrollView.subviews.forEach { $0.removeFromSuperview() }
scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
scrollView.contentSize = CGSize(width: view.frame.width * CGFloat(slides.count), height: view.frame.height)
scrollView.isPagingEnabled = true
for i in 0 ..< slides.count {
slides[i].frame = CGRect(x: view.frame.width * CGFloat(i), y: 0, width: view.frame.width, height: view.frame.height)
scrollView.addSubview(slides[i])
}
}
As I said, I am looking for ways of making this more efficient in any way so I can actually use it. Preferebly I would probably just load the Slide that I am on, the next and previous one, but I have no clue how I would go about doing that.
Here is also a Screenshot, so you can see what it looks like.
Would be better to do something like that:
1) Add UICollectionView
2) Add UITableViewCell with restorationID
3) Add relations to your controller/view
4) Set horizontal scroll direction: https://www.screencast.com/t/wmPiwVdY
5) And after that create logic something like that:
class ImageCollectionViewVC: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
#IBOutlet weak var collectionView: UICollectionView!
private var images = [Slide]()
override func viewDidLoad() {
super.viewDidLoad()
collectionView.delegate = self
collectionView.dataSource = self
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return images.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let imageCell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageSlideCellRestorationID", for: indexPath) as? ImageSlideCell else {
return ImageSlideCell()
}
imageCell.image.image = UIImage(named: images[indexPath.row].imageUrl)
return imageCell
}
}
class ImageSlideCell: UICollectionViewCell {
#IBOutlet weak var image: UIImageView!
}
Instead using uiscroll and got a performance problems. You should using uicollectionview, scroll horizontal, custom cell with one image and buttons.
The designer wants the following animation from a swipe gesture.
As it can be seen the user can swipe cards and see what each card has. At the same time, the user can see in the right side of the screen the following card and the last one in the left. Also, cards are changing their size while the user is moving the scroll.
I have already worked with page control views but I have no idea if this is possible with a page Control (which actually is the question of this post).
Also, I have already tried with a collectionView but when I swipe (actually is an horizontal scroll) the scroll has an uncomfortable inertia and also, I have no idea how to make the animation.
In this question a scrolled page control is implemented but now I just wondering if and animation like the gif provided is possible.
If the answer is yes, I would really appreciate if you can give tips of how I can make this possible.
Thanks in advance.
Based on the Denislava Shentova comment I found a good library that solves this issue.
For all people in the future and their work hours, I just took code from UPCarouselFlowLayout library and deleted some I didn't need.
Here is the code of a simple viewController that shows the following result:
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
// CollectionView variable:
var collectionView : UICollectionView?
// Variables asociated to collection view:
fileprivate var currentPage: Int = 0
fileprivate var pageSize: CGSize {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
var pageSize = layout.itemSize
pageSize.width += layout.minimumLineSpacing
return pageSize
}
fileprivate var colors: [UIColor] = [UIColor.black, UIColor.red, UIColor.green, UIColor.yellow]
override func viewDidLoad() {
super.viewDidLoad()
self.addCollectionView()
self.setupLayout()
}
func setupLayout(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
self.collectionView?.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
self.collectionView?.topAnchor.constraint(equalTo: self.view.topAnchor, constant: pointEstimator.relativeHeight(multiplier: 0.1754)).isActive = true
self.collectionView?.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
self.collectionView?.heightAnchor.constraint(equalToConstant: pointEstimator.relativeHeight(multiplier: 0.6887)).isActive = true
self.currentPage = 0
}
func addCollectionView(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
// This is where the magic is done. With the flow layout the views are set to make costum movements. See https://github.com/ink-spot/UPCarouselFlowLayout for more info
let layout = UPCarouselFlowLayout()
// This is used for setting the cell size (size of each view in this case)
// Here I'm writting 400 points of height and the 73.33% of the height view frame in points.
layout.itemSize = CGSize(width: pointEstimator.relativeWidth(multiplier: 0.73333), height: 400)
// Setting the scroll direction
layout.scrollDirection = .horizontal
// Collection view initialization, the collectionView must be
// initialized with a layout object.
self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// This line if for able programmatic constrains.
self.collectionView?.translatesAutoresizingMaskIntoConstraints = false
// CollectionView delegates and dataSource:
self.collectionView?.delegate = self
self.collectionView?.dataSource = self
// Registering the class for the collection view cells
self.collectionView?.register(CardCell.self, forCellWithReuseIdentifier: "cellId")
// Spacing between cells:
let spacingLayout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
spacingLayout.spacingMode = UPCarouselFlowLayoutSpacingMode.overlap(visibleOffset: 20)
self.collectionView?.backgroundColor = UIColor.gray
self.view.addSubview(self.collectionView!)
}
// MARK: - Card Collection Delegate & DataSource
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as! CardCell
cell.customView.backgroundColor = colors[indexPath.row]
return cell
}
// MARK: - UIScrollViewDelegate
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
let pageSide = (layout.scrollDirection == .horizontal) ? self.pageSize.width : self.pageSize.height
let offset = (layout.scrollDirection == .horizontal) ? scrollView.contentOffset.x : scrollView.contentOffset.y
currentPage = Int(floor((offset - pageSide / 2) / pageSide) + 1)
}
}
class CardCell: UICollectionViewCell {
let customView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.customView)
self.customView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.customView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
self.customView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
self.customView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
} // End of CardCell
class RelativeLayoutUtilityClass {
var heightFrame: CGFloat?
var widthFrame: CGFloat?
init(referenceFrameSize: CGSize){
heightFrame = referenceFrameSize.height
widthFrame = referenceFrameSize.width
}
func relativeHeight(multiplier: CGFloat) -> CGFloat{
return multiplier * self.heightFrame!
}
func relativeWidth(multiplier: CGFloat) -> CGFloat{
return multiplier * self.widthFrame!
}
}
Note that there are some other clases in this code but temporarily you can run the whole code in the ViewController.swift file. After you test, please split them into different files.
In order tu run this code, you need the following module. Make a file called UPCarouselFlowLayout.swift and paste all this code:
import UIKit
public enum UPCarouselFlowLayoutSpacingMode {
case fixed(spacing: CGFloat)
case overlap(visibleOffset: CGFloat)
}
open class UPCarouselFlowLayout: UICollectionViewFlowLayout {
fileprivate struct LayoutState {
var size: CGSize
var direction: UICollectionViewScrollDirection
func isEqual(_ otherState: LayoutState) -> Bool {
return self.size.equalTo(otherState.size) && self.direction == otherState.direction
}
}
#IBInspectable open var sideItemScale: CGFloat = 0.6
#IBInspectable open var sideItemAlpha: CGFloat = 0.6
open var spacingMode = UPCarouselFlowLayoutSpacingMode.fixed(spacing: 40)
fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal)
override open func prepare() {
super.prepare()
let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection)
if !self.state.isEqual(currentState) {
self.setupCollectionView()
self.updateLayout()
self.state = currentState
}
}
fileprivate func setupCollectionView() {
guard let collectionView = self.collectionView else { return }
if collectionView.decelerationRate != UIScrollViewDecelerationRateFast {
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
}
}
fileprivate func updateLayout() {
guard let collectionView = self.collectionView else { return }
let collectionSize = collectionView.bounds.size
let isHorizontal = (self.scrollDirection == .horizontal)
let yInset = (collectionSize.height - self.itemSize.height) / 2
let xInset = (collectionSize.width - self.itemSize.width) / 2
self.sectionInset = UIEdgeInsetsMake(yInset, xInset, yInset, xInset)
let side = isHorizontal ? self.itemSize.width : self.itemSize.height
let scaledItemOffset = (side - side*self.sideItemScale) / 2
switch self.spacingMode {
case .fixed(let spacing):
self.minimumLineSpacing = spacing - scaledItemOffset
case .overlap(let visibleOffset):
let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset
let inset = isHorizontal ? xInset : yInset
self.minimumLineSpacing = inset - fullSizeSideItemOverlap
}
}
override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let superAttributes = super.layoutAttributesForElements(in: rect),
let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes]
else { return nil }
return attributes.map({ self.transformLayoutAttributes($0) })
}
fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let collectionView = self.collectionView else { return attributes }
let isHorizontal = (self.scrollDirection == .horizontal)
let collectionCenter = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2
let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y
let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset
let maxDistance = (isHorizontal ? self.itemSize.width : self.itemSize.height) + self.minimumLineSpacing
let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
let ratio = (maxDistance - distance)/maxDistance
let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
attributes.alpha = alpha
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
return attributes
}
override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView , !collectionView.isPagingEnabled,
let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let isHorizontal = (self.scrollDirection == .horizontal)
let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2
let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide
var targetContentOffset: CGPoint
if isHorizontal {
let closest = layoutAttributes.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
}
else {
let closest = layoutAttributes.sorted { abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide))
}
return targetContentOffset
}
}
Again, this module was made by Paul Ulric, you can installed with cocoa.
I have a TableViewController that has one custom prototype cell with the identifier "regularCell" set in Storyboard. The TableViewController is of the class TimelineTableViewController.swift and the cell is of the class TimelineTableViewCell.swift.swift both set in Storyboard.
In TimelineTableViewController.swift:
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.events.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = self.tableView.dequeueReusableCellWithIdentifier("regularCell", forIndexPath: indexPath) as! TimelineTableViewCell
cell.selectionStyle = .None
let event = events[indexPath.row]
cell.content.text = event.content
cell.name.text = event.name
cell.metadata.text = event.metadata
return cell
}
}
And in TimelineTableViewCell.swift I try to make the cell swipe-able but nothing happens and the pan gesture recognizer isn't being called.
import UIKit
class TimelineTableViewCell: UITableViewCell {
var originalCenter = CGPoint()
var deleteOnDragRelease = false
#IBOutlet weak var name: UILabel!
#IBOutlet weak var content: UILabel!
#IBOutlet weak var metadata: UILabel!
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
var recognizer = UIPanGestureRecognizer(target: self, action: "handlePan:")
recognizer.delegate = self
addGestureRecognizer(recognizer)
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func handlePan(recognizer: UIPanGestureRecognizer) {
// 1
if recognizer.state == .Began {
// when the gesture begins, record the current center location
originalCenter = center
}
// 2
if recognizer.state == .Changed {
let translation = recognizer.translationInView(self)
center = CGPointMake(originalCenter.x + translation.x, originalCenter.y)
// has the user dragged the item far enough to initiate a delete/complete?
deleteOnDragRelease = frame.origin.x < -frame.size.width / 2.0
}
// 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 the item is not being deleted, snap back to the original location
UIView.animateWithDuration(0.2, animations: {self.frame = originalFrame})
}
}
}
override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer {
let translation = panGestureRecognizer.translationInView(superview!)
if fabs(translation.x) > fabs(translation.y) {
return true
}
return false
}
return false
}
}
Would anyone happen to know what I am doing wrong? The cells in the table load perfectly but they just aren't swipeable as if I never added anything. Any help or hints are greatly appreciated
Finally figured it out! When adding the gesture recognizer do so in awakeFromNib.
Here's the working code:
override func awakeFromNib() {
super.awakeFromNib()
var panGestureRecognizer = UIPanGestureRecognizer(target: self, action: "handlePan:")
panGestureRecognizer.delegate = self
addGestureRecognizer(panGestureRecognizer)
}
func handlePan(recognizer: UIPanGestureRecognizer) {
// 1
if recognizer.state == .Began {
// when the gesture begins, record the current center location
originalCenter = center
}
// 2
if recognizer.state == .Changed {
let translation = recognizer.translationInView(self)
center = CGPointMake(originalCenter.x + translation.x, originalCenter.y)
// has the user dragged the item far enough to initiate a delete/complete?
deleteOnDragRelease = frame.origin.x < -frame.size.width / 2.0
}
// 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 the item is not being deleted, snap back to the original location
UIView.animateWithDuration(0.2, animations: {self.frame = originalFrame})
}
}
}
override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer {
let translation = panGestureRecognizer.translationInView(superview!)
if fabs(translation.x) > fabs(translation.y) {
return true
}
return false
}
return false
}
Do not bother with overriding init. Another alternative solution as suggested by a friend, would be to add a scrollview to the cell content view and work from there. Hope this helps!
And thanks to all who helped!