Making a Tinder-esque Card Swiping interface using Swift

 

Tinder – we all know that dating app where you can just reject or accept someone by swiping right or left. BAM! The whole card swiping idea is now used in tons of apps. It’s a way to show data if you have grown tired of using table views and collection views. There are tons of tutorial on this and this project took me good bit of time.

You can check out the full project on my Github.

First of all, I would like to give credit to Phill Farrugia’s medium post on this and then Big Mountain Studio’s YouTube series on a similar topic. So how do we go about making this interface. I got quite a lot of help from Phill’s medium post on this front. Basically the idea is to create UIViews and insert them as subviews in a container view. Then using the index we will give each UIView some horizontal and vertical inset and tweak its width a bit. Then when we swipe away one card, all the views frames will be rearranged according to the new index value.

We will start by creating a container view in a simple ViewController.

class ViewController: UIViewController {

    //MARK: - Properties
    var viewModelData = [CardsDataModel(bgColor: UIColor(red:0.96, green:0.81, blue:0.46, alpha:1.0), text: "Hamburger", image: "hamburger"),
                         CardsDataModel(bgColor: UIColor(red:0.29, green:0.64, blue:0.96, alpha:1.0), text: "Puppy", image: "puppy"),
                         CardsDataModel(bgColor: UIColor(red:0.29, green:0.63, blue:0.49, alpha:1.0), text: "Poop", image: "poop"),
                         CardsDataModel(bgColor: UIColor(red:0.69, green:0.52, blue:0.38, alpha:1.0), text: "Panda", image: "panda"),
                         CardsDataModel(bgColor: UIColor(red:0.90, green:0.99, blue:0.97, alpha:1.0), text: "Subway", image: "subway"),
                         CardsDataModel(bgColor: UIColor(red:0.83, green:0.82, blue:0.69, alpha:1.0), text: "Robot", image: "robot")]
    var stackContainer : StackContainerView!
  
    
    //MARK: - Init
    
    override func loadView() {
        view = UIView()
        view.backgroundColor = UIColor(red:0.93, green:0.93, blue:0.93, alpha:1.0)
        stackContainer = StackContainerView()
        view.addSubview(stackContainer)
        configureStackContainer()
        stackContainer.translatesAutoresizingMaskIntoConstraints = false
        configureNavigationBarButtonItem()
    }
 
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Expense Tracker"
        stackContainer.dataSource = self
    }
    
 
    //MARK: - Configurations
    func configureStackContainer() {
        stackContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        stackContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60).isActive = true
        stackContainer.widthAnchor.constraint(equalToConstant: 300).isActive = true
        stackContainer.heightAnchor.constraint(equalToConstant: 400).isActive = true
    }

As you can see I have created a custom class called SwipeContainerView and just configuring the stackViewContainer using the auto layout constraints. Nothing too bad. The SwipeContainerView will be 300 x 400 in size and it will be centered in X axis and just 60 pixels above the middle of Y axis.

Now that we have set up the stackContainer, we will head over to the StackContainerView subclass and load all the card views in it. Before doing that we will create a protocol that will have three methods:

protocol SwipeCardsDataSource {
    func numberOfCardsToShow() -> Int
    func card(at index: Int) -> SwipeCardView
    func emptyView() -> UIView? 
}

Think of this protocol as a TableViewDataSource. Conforming our ViewController class to this protocol will allow us to transfer information of our data to the SwipeCardContainer class. The three methods we have in this are:

  1. numberOfCardsToShow() -> Int : Returns the number of cards we need to show. This is just the count of the data array.
  2. card(at index: Int) -> SwipeCardView : Returns the SwipeCardView(we will create this class in just a moment)
  3. EmptyView -> Not going to do anything with this but once all the cards have been swiped out, call to this delegate method will return an empty view with some sort of message (I will not implement this in this particular tutorial, give it a try! )

Conform the view controller to this protocol:

extension ViewController : SwipeCardsDataSource {
    func numberOfCardsToShow() -> Int {
        return viewModelData.count
    }
    
    func card(at index: Int) -> SwipeCardView {
        let card = SwipeCardView()
        card.dataSource = viewModelData[index]
        return card
    }
    
    func emptyView() -> UIView? {
        return nil
    }   
}

In the first method, return the number of items in the data array. In the second method, create a new instance of SwipeCardView() and send the array data for that index and then return the SwipeCardView instance.

SwipeCardView is a subclass of UIView on which there is a UIImage, UILabel, and a gesture recognizer. More on this later. We will use this protocol to communicate with the container view.

stackContainer.dataSource = self

When the above code is triggered, this will trigger a function of reloadData that will then call these datasource functions.

Class StackViewContainer: UIView {
.
.

  var dataSource: SwipeCardsDataSource? {
        didSet {
            reloadData()
         }
    }

....

The reloadData function:

   func reloadData() {
        guard let datasource = dataSource else { return }
        setNeedsLayout()
        layoutIfNeeded()
        numberOfCardsToShow = datasource.numberOfCardsToShow()
        remainingcards = numberOfCardsToShow
        
        for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) {
            addCardView(cardView: datasource.card(at: i), atIndex: i )
            
        }
    }

In this reloadData function, we are first getting the number of cards and storing it in numberOfCardsToShow variable. Then we are assigning that to another variable called remainingCards. In the for loop, we are creating a card which is instance of SwipeCardView using the index value.

 for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) {
            addCardView(cardView: datasource.card(at: i), atIndex: i )
         }

We essentially want less than 3 cards to appear at any one time. Therefore we are using min function. CardsToBeVisible is a constant at 3. If the numberOfToShow is more than 3 then only three cards will be rendered. We are creating these cards from protocol method of:

func card(at index: Int) -> SwipeCardView

The addCardView() function is just used to insert the cards as subviews.

  private func addCardView(cardView: SwipeCardView, atIndex index: Int) {
        cardView.delegate = self
        addCardFrame(index: index, cardView: cardView)
        cardViews.append(cardView)
        insertSubview(cardView, at: 0)
        remainingcards -= 1
    }

In this function we are adding the cardView to the view hierarchy and as we are adding the cards as subview we are decrementing the remaining cards by 1. Once we have added the cardView as the subview, we then set the frame of those cards. For this we are using another function of addCardFrame():

 func addCardFrame(index: Int, cardView: SwipeCardView) {
        var cardViewFrame = bounds
        let horizontalInset = (CGFloat(index) * self.horizontalInset)
        let verticalInset = CGFloat(index) * self.verticalInset
        
        cardViewFrame.size.width -= 2 * horizontalInset
        cardViewFrame.origin.x += horizontalInset
        cardViewFrame.origin.y += verticalInset
        
        cardView.frame = cardViewFrame
    }

This addCardFrame() logic is taken directly from Phill’s post. Here we are setting frame of the card according to the index of that card. The first card with index 0 will have the frame directly as that of the container view. Then we are changing the origin of the frame and width of the card according to the inset. So essentially we add the card slightly to the right of the card above, decrease it’s width, also make sure to pull the cards downwards to get that feeling of the cards being stacked on top of each other.

Once this is done, what you will see is cards being stacked on top of each other. Pretty good so far!

However now we need to add the swipe gesture to this card view. Let’s now bring our attention to the SwipeCardView class.

SwipeCardView

The swipeCardView class is just a normal UIView subclass. However, for reasons only known to Apple engineers, it is incredibly difficult to add shadows to a UIView with rounded corner. In order to add shadows to the card views, I am creating two UIViews. One is a shadowView and then on top of that there is a swipeView. The shadowView essentially has the shadow and all. The swipeViewhas rounded corners. On the swipeView I have added UIImageView, a UILabel to showcase the data and pictures.

 var swipeView : UIView!
 var shadowView : UIView!

Configuring shadowView and swipeView

    func configureShadowView() {
        shadowView = UIView()
        shadowView.backgroundColor = .clear
        shadowView.layer.shadowColor = UIColor.black.cgColor
        shadowView.layer.shadowOffset = CGSize(width: 0, height: 0)
        shadowView.layer.shadowOpacity = 0.8
        shadowView.layer.shadowRadius = 4.0
        addSubview(shadowView)
        
        shadowView.translatesAutoresizingMaskIntoConstraints = false
        shadowView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
        shadowView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
        shadowView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        shadowView.topAnchor.constraint(equalTo: topAnchor).isActive = true
    }
    
    func configureSwipeView() {
        swipeView = UIView()
        swipeView.layer.cornerRadius = 15
        swipeView.clipsToBounds = true
        shadowView.addSubview(swipeView)
        
        swipeView.translatesAutoresizingMaskIntoConstraints = false
        swipeView.leftAnchor.constraint(equalTo: shadowView.leftAnchor).isActive = true
        swipeView.rightAnchor.constraint(equalTo: shadowView.rightAnchor).isActive = true
        swipeView.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor).isActive = true
        swipeView.topAnchor.constraint(equalTo: shadowView.topAnchor).isActive = true
    }

Then I have added a pan gesture recognizer to this card view and on recognition a selector function is called. This selector function has tons of logic behind swipe, tilting etc. Let’s take a look:

 

    @objc func handlePanGesture(sender: UIPanGestureRecognizer){
        let card = sender.view as! SwipeCardView
        let point = sender.translation(in: self)
        let centerOfParentContainer = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2)
        card.center = CGPoint(x: centerOfParentContainer.x + point.x, y: centerOfParentContainer.y + point.y)
       switch sender.state {
        case .ended:
            if (card.center.x) > 400 {
                delegate?.swipeDidEnd(on: card)
                UIView.animate(withDuration: 0.2) {
                    card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75)
                    card.alpha = 0
                    self.layoutIfNeeded()
                }
                return
            }else if card.center.x < -65 {
                delegate?.swipeDidEnd(on: card)
                UIView.animate(withDuration: 0.2) {
                    card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75)
                    card.alpha = 0
                    self.layoutIfNeeded()
                }
                return
            }
            UIView.animate(withDuration: 0.2) {
                card.transform = .identity
                card.center = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2)
                self.layoutIfNeeded()
            }
        case .changed:
             let rotation = tan(point.x / (self.frame.width * 2.0))
            card.transform = CGAffineTransform(rotationAngle: rotation)
            
        default:
            break
        }
    }

The first four lines in the above code:

 let card = sender.view as! SwipeCardView
 let point = sender.translation(in: self)
 let centerOfParentContainer = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2)
 card.center = CGPoint(x: centerOfParentContainer.x + point.x, y: centerOfParentContainer.y + point.y)

First we get the view on which the gesture has been swiped. Next we are using the translation method to get how much the user has swiped on the card. The third line is essentially getting the mid point of the parent container. The last line is where we are setting the card.center. As the user swipes on the card, the center of the card increases by translated x value and translated y value. In order to get that snapping behavior we are essentially changing the center point of the card from fixed coordinates. When the gesture translation ends we get it back to the card.center.

In state.ended case:

if (card.center.x) > 400 {
                delegate?.swipeDidEnd(on: card)
                UIView.animate(withDuration: 0.2) {
                    card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75)
                    card.alpha = 0
                    self.layoutIfNeeded()
                }
                return
            }else if card.center.x < -65 {
                delegate?.swipeDidEnd(on: card)
                UIView.animate(withDuration: 0.2) {
                    card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75)
                    card.alpha = 0
                    self.layoutIfNeeded()
                }
                return
            }

we are checking if the card.center.x value is greater than 400 or if the card.center.x is less than -65. If it is so then we are dismissing those cards by changing the card.center.

If swiped to the right:

card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75)

If swiped to the left:

card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75)

If the user ends the gesture mid way between 400 and -65 well then we will reset the center of the card.  We are also calling a delegate method when the swipe ends. More on this later.

For getting that tilt when you swipe on the card; I will be brutally honest. I used bit of geometry and used different values of perpendicular and base and then used tan function to get a rotation angle. Again this was just trial and error. Using the point.x and container’s width as two perimeters seemed to work well. Feel free to experiment with these values.

case .changed:
             let rotation = tan(point.x / (self.frame.width * 2.0))
            card.transform = CGAffineTransform(rotationAngle: rotation)

Now lets talk about the delegate function. We will use the delegate function to communicate between the SwipeCardView and the ContainerView.

protocol SwipeCardsDelegate {
    func swipeDidEnd(on view: SwipeCardView)
}

This function will take in a view on which the swipe took place and we will do some steps on it to remove it from the subviews and then redo all the frames for the cards beneath it. Here is how:

    func swipeDidEnd(on view: SwipeCardView) {
        guard let datasource = dataSource else { return }
        view.removeFromSuperview()

        if remainingcards > 0 {
            let newIndex = datasource.numberOfCardsToShow() - remainingcards
            addCardView(cardView: datasource.card(at: newIndex), atIndex: 2)
            for (cardIndex, cardView) in visibleCards.reversed().enumerated() {
                UIView.animate(withDuration: 0.2, animations: {
                cardView.center = self.center
                  self.addCardFrame(index: cardIndex, cardView: cardView)
                    self.layoutIfNeeded()
                })
            }

        }else {
            for (cardIndex, cardView) in visibleCards.reversed().enumerated() {
                UIView.animate(withDuration: 0.2, animations: {
                    cardView.center = self.center
                    self.addCardFrame(index: cardIndex, cardView: cardView)
                    self.layoutIfNeeded()
                })
            }
        }
    }

First remove this view from the super view. Once that is done check if there is any remaingcard left. If there is, then we will create a new index for a new card to be created. We will create the newIndex by subtracting the total number of cards to show with the remaining cards. Then we will add the card as subview. However, this new card will be the bottom most one so the 2 that we are sending will essentially make sure that the frame we will add is corresponding to the index 2 or the bottom most.

In order to animate the frames of the rest of the cards,  we will use the index of the subviews. To do this we will create a visibleCards array that will have all the subviews of the containerView in it as an array.

var visibleCards: [SwipeCardView] {
        return subviews as? [SwipeCardView] ?? []
    }

The problem however is that visibleCards array will have subviews index inverted. So the first card will be third, second will remain in second place but the third card will be in the first position. To prevent this from happening we will run the visibleCards array in reversed manner in order to get the actual index of the subview and not how they are arranged in the visibleCards array.

    for (cardIndex, cardView) in visibleCards.reversed().enumerated() {
                UIView.animate(withDuration: 0.2, animations: {
                cardView.center = self.center
                  self.addCardFrame(index: cardIndex, cardView: cardView)
                    self.layoutIfNeeded()
                })
            }

So now we will update the frames of the rest of the cardViews.

So that’s basically it. This is a perfect way to present small amount of data.

View post on imgur.com

 

Leave a Reply

Your email address will not be published. Required fields are marked *