When I’m not furiously swiping right on Tinder trying desperately to find the love of my life in a sea of random people I’ve never met, I’m building software and interfaces for the iPhone. As it turns out, Tinder actually pioneered an incredibly interesting and unique gesture-based interaction pattern that set the standard for many mobiles apps in the software industry today. Often one might hear investors, designers and engineers referring to their business idea as “the Tinder for X” — alluding to the way that it adopts Tinder’s swipe-able card system and applies it to something other than semi-psychopathically rating other humans in fractions of a second based on nothing more than a mere glimpse of a photograph.
This weekend I stumbled upon a Dribbble post that caught my attention, which depicted a simplified version of the Tinder-esque card interface. I had nothing else planned for the weekend, so I decided to take the time to implement it natively in Swift. As you would expect I’ve open sourced my code on Github, and wrote about my process behind building the components, gestures and animations. I’m always looking for ways to dive into Core Animation, and understand more about how to build dynamic, gesture-based animations. This ended up being a great opportunity for me to learn a bit more about the tools available for crafting interfaces that are exciting and people love to use.
Interacting with this entire component is about dropping a
SwipeableCardViewContainer view into your Storyboard (or code) and conforming to the
SwipeableCardViewDelegate protocols. This container view is the view that is responsible for laying out all of the cards within itself and handling all of the underlying logic of keeping track of a series of cards. It’s designed to be quite similar to UICollectionView and UITableView which you’re likely already familiar with.
Your DataSource will provide a numberOfCards, and for each index, a
SwipeableCardViewCard to be displayed. As well as optionally a view to display underneath all cards to be seen when all cards have been swiped away.
reloadData() is called, it will remove any existing card views from superview. And insert the first 3 card views from the dataSource as subviews. In order to achieve the overlay/inset effect where cards appear to be stacked above one another, the frame of each card is manipulated based on its index.
horizontalInset is calculated, as well as a
verticalInset and these values are applied to the frame’s origin and width, relative to the bounds of the container view. For example, the first card’s index is 0, so it will apply a 0 inset vertically and horizontally so that the card sits perfectly within the container. The second card’s index is 1 so it’ll push the card’s y origin over, reduce it’s width and drop the card’s x origin down, all by a factor of 1. And so forth for each visible card index 0 to 3.
A challenge that I faced with this approach of updating frames was implementing the animation that you see as a card is swiped away, where the existing cards animate upwards revealing a new card from underneath. Each time I added a card to the container, I would insert the subview at origin 0. This meant that the
SwipeableCardViewContainer's subviews array was in reverse order of the actual card indexes expected. i.e The subview at origin 0 was the view furthest back in the view hierarchy, although the card index associated with that view was 2 (the highest index).
When inserting the new card at the bottom of the stack, I would insert the view at index 0 in the array of subviews causing an index mismatch. Then as I updated the frames of all the views to their new position based on their new index, it would invert all of the card’s positions. I resolved this issue by ensuring that I was iterating through the subviews using
.reversed() to ensure that their frames were updated based on their actual index, not their index within the subviews array.
As you would’ve expected the most complex and time consuming part of implementing this component was the draggable cards. This required applying a lot of complex math (of which some I still don’t fully understand). Most of this is housed within a UIView subclass called
Each card subclasses
SwipeableView which uses a
UIPanGestureRecognizer internally to listen for Pan gestures such as a user ‘grabbing’ a card with their finger and moving it around the screen, then flicking it or lifting their finger. Gesture Recognizers are very underrated APIs that are incredibly easy and simple to work with considering how much functionality and power they provide.
UIGestureRecognizer has a state that provides some information about what has or has not happened. Of note for this component is the
Ended states which specify if the user has started a pan, a pan is in progress or a pan has finished. There are also some other states such as
Failed which are triggered if a gesture is not yet registered as a pan gesture, or the user cancelled the pan by reversing their gesture or if something failed. This simple enum handles quite a lot of complicated logic happening under the hood within UIKit to determine how the user is interacting with the software.
I listened to an incredible talk recently by Andy Matuschak who worked on UIKit and he explains why the UIKit team used this specific approach to handling gestures over other traditional approaches such as is used in React.js. I recommend spending the time to listen or watch this talk.
When a pan gesture begins a few different things have to happen. Such as calculating the initialTouchPoint, a
CGPoint representing exactly where the user first started their pan on the screen in the coordinate system of the
SwipeableView. This is used to calculate a new anchor point, that will soon be set as the SwipeableView’s layer’s anchorPoint.
UIKit describes the
anchorPoint as a point in which all geometric transformations are applied relative to. By default the anchorPoint for all UIViews is the exact center of the view
CGPoint(x: 0.5, y: 0.5).
All geometric manipulations to the view occur about the specified point. For example, applying a rotation transform to a layer with the default anchor point causes the layer to rotate around its center. Changing the anchor point to a different location would cause the layer to rotate around that new point.
By setting the anchor point to the point at which the user began their pan gesture we ensure that all translations and rotations occur relative to the user’s finger, which will ensure a far more natural animation and gives the sense that the user has actually grabbed ahold of the Card.
Once the anchor point has been calculated and set, the layer position is updated, any existing animations (which might have been started elsewhere) are removed and the SwipeableView’s layer
rasterizationScale is set to the scale of the device such that rasterization occurs relative to the device’s actual scale and content is not shrunk, or enlarged.
When the pan gesture state is
Changed, such as the user dragging their finger (and the Card) around the screen a transformation needs to be applied to the card so that the card will follow their finger and give the sense that the user is dragging the card. We also want to perform this translation with a slight rotation on the view the give more of a ‘flickable’ arch to the card, and make it behave more like a card that the user should flick in a certain direction, as opposed to just freely moving it. This is where Tinder emphasizes a ‘Swipe Left’ or ‘Swipe Right’ to accept/reject paradigm that actually makes this interface feel more controlled and well-rounded.
Applying this transformation is relatively simple a
CATransform3D is created and both a
CATransform3DTranslate are applied to it. The rotation is applied based on a rotationAngle calculate by multiplying some settings such as
rotationStrength. Rotation Angle specifies the degree of rotation applied to the card as it moves, or the ‘intensity’ of the curve. By default this curve is
π/10. Rotation Strength is calculated based on the translation point, and a maximum translation setting of
Once the Pan gesture has ended, an ended animation has to be applied to the card so that it flicks off of the screen, or if the user did not fully flick the card past a certain threshold we need to animate it back to its original place in the stack.
Firstly we calculate the
dragDirection of the pan, which requires a lot of complicated math that normalizes the pan gesture’s translation point, and performs a reduce to calculate the closest Swipe Direction based on some static values given to each Swipe Direction. While I didn’t fully implement this logic myself, I was able to reverse engineer an existing open source implementation of a similar component - https://github.com/Yalantis/Koloda and encapsulate this logic into an enum called
SwipeDirection. Each Swipe Direction has a
verticalPosition relative to where it is on a generic geometric system (such as top left being horizontally at the left, and vertically at the top). Using this information I’m able to calculate the Swipe Direction of a given swipe to either be top left, bottom right, right, or left… and so forth.
Similarly using a normalized drag point, and the gesture’s drag translation point I’m able to calculate the drag percentage as a fraction of the distance from the Gesture’s endpoint to the ‘target point’ where a card would be fully ‘flicked’. This target point is calculated based on a swipe percentage margin, that determines the threshold of how far this percentage must be before a gesture is considered ‘flicked’ enough to remove the card. By default this threshold is
0.6, meaning if a pan gesture is greater than or equal to 60% of the distance to the target point the card is considered flicked and can be removed form the stack otherwise it must return to its original position in the stack.
Using Facebook’s POP animation framework to simplify things, and provide a more out of the box dynamic animation, I apply a
POPBasicAnimation to the card translating it’s X and Y origin to a value off the screen. Once this animation is complete I call my delegate function
self.delegate?.didEndSwipe(onView: self) and rely on my delegate (which added this SwipeableView as a subview) to remove this SwipeableView as a subview. This card is now completely removed from the stack and its job is done.
dragPercentage is not 60% or more, or if the pan gesture’s state is cancelled or failed, I need to animate it back into the stack. Typically this animation will be a little rubber-band-like springy bounce animation that communicates that the swipe failed and the user has ‘let go’ of the card, allowing it to fling back to its original place, exactly as you would expect in the real world physics space.
Performing this animation is as simple as applying a
POPSpringAnimation on the rotation that was already applied, to reverse it from its current values to the original values. As well as a
POPSpringAnimation on the translation we applied from its current values to its original values.
POPSpringAnimation will ensure that the actual values are overshot slightly back and forth resulting in the spring effect that we wanted.
Now that we’ve implemented SwipeableView, creating custom cards that look different, contain their own content such as
UIButtons as subviews is as easy as inheriting from
SwipeableView. The subclass itself is strictly responsible for managing its own content and relies on the superclass to take care of all of the swipe-able logic that was just implemented.
I’ve created a
SampleSwipeableCard subclass that contains a a
UILabel for a title, and a subtitle, as well as a red
UIButton with a plus icon and a
UIView with a distinct background color that contains an inner
UIImageView. All standard, simple and basic UIKit elements thrown onto a Xib exactly as you would expect.
In my ViewController I ensure that I’m creating a series of ViewModels for each Card, that my
SampleSwipeableCard can configure itself with.
And I return a SampleSwipeableCard configured for a viewModel at the given index. Exactly as I would configure a cell within a UICollectionView for a given ViewModel.
Using some code that I’d previously used for applying a rounded corners and a shadow (a surprisingly not-so-simple feat), for my Re-building the new iOS 11 App Store post — I was able to apply a similar rounded corner and shadow to my SampleSwipeableCard views.
Something I’ve been doing a lot more recently is stumbling upon something that catches my eye or pique’s my interest and then very quickly finding myself neck deep in the weeds hacking away at it. Animation is probably not my strong suit when it comes to building UIs. I’ve applied animation using UIKit, and Core Animation before with mostly success although I’m not as confident with it as I am in implementing other things. Mostly because there is never a right or wrong answer in how to animate something, but instead many different ways of achieving the same result.
I’m a big fan of Tinder’s card-style gesture interface and think its a really unique way of swiping, sorting or manipulating small-medium sets of data whether that be random potential love interests, or something else. I owe a lot of the implementation above to the many open source implementations of this card style interface that I discovered online, but I think it’s great that engineers can build something and open source their code for others to reverse engineer in order to build upon or apply to something else.
I’ve published this code on Github, feel free to fork it or submit a pull request if you’re interested. Let me know if you have any questions, or if you liked this post and want me to write about something in more detail.
Thanks for reading! Feel free to follow me on twitter @phillfarrugia