Creating Tinder-Like Swipeable Cards in SwiftUI

JC
4 min readOct 2, 2024

--

Preview of Swipeable Cards in SwiftUI

Ever wanted to recreate that iconic Tinder swipe animation in your own app? Swiping left or right to dismiss cards is a fun and interactive way to showcase content, and building that experience in SwiftUI is a breeze with just a few lines of code! Today, I’m walking you through how to make a swipeable card stack where users can swipe away each card in either direction, just like Tinder.

Let’s dive into creating a custom, swipeable card view that you can easily implement in your SwiftUI project. Along the way, I’ll highlight key parts of the code and how it all fits together. By the end, you’ll have a fully functional swipeable card stack!

Getting Started with CardView

We start by defining a single card’s design and swipe logic. Here, we have a basic card layout with some cool features like a dynamic shadow that changes color depending on the swipe direction.

import SwiftUI

struct CardView: View {
enum SwipeDirection {
case left, right, none
}

struct Model: Identifiable, Equatable {
let id = UUID()
let text: String
var swipeDirection: SwipeDirection = .none
}

var model: Model
var size: CGSize
var dragOffset: CGSize
var isTopCard: Bool
var isSecondCard: Bool

var body: some View {
Text(model.text)
.frame(width: size.width * 0.8, height: size.height * 0.8)
.background(Color.white)
.cornerRadius(15)
.shadow(color: isTopCard ? getShadowColor() : (isSecondCard && dragOffset.width != 0 ? Color.gray.opacity(0.2) : Color.clear), radius: 10, x: 0, y: 3)
.foregroundColor(.black)
.font(.largeTitle)
.padding()
}

private func getShadowColor() -> Color {
if dragOffset.width > 0 {
return Color.green.opacity(0.5)
} else if dragOffset.width < 0 {
return Color.red.opacity(0.5)
} else {
return Color.gray.opacity(0.2)
}
}
}

Swipe Logic with SwipeableCardsView

Next up is where the magic happens: the swipeable card stack! Each card reacts to user gestures, and we remove the top card from the deck when a swipe exceeds a certain threshold. The DragGesture handles all the swipe detection, and once a card is swiped, we update the stack.

import SwiftUI

struct SwipeableCardsView: View {
class Model: ObservableObject {
private var originalCards: [CardView.Model]
@Published var unswipedCards: [CardView.Model]
@Published var swipedCards: [CardView.Model]

init(cards: [CardView.Model]) {
self.originalCards = cards
self.unswipedCards = cards
self.swipedCards = []
}

func removeTopCard() {
if !unswipedCards.isEmpty {
guard let card = unswipedCards.first else { return }
unswipedCards.removeFirst()
swipedCards.append(card)
}
}

func updateTopCardSwipeDirection(_ direction: CardView.SwipeDirection) {
if !unswipedCards.isEmpty {
unswipedCards[0].swipeDirection = direction
}
}

func reset() {
unswipedCards = originalCards
swipedCards = []
}
}

@ObservedObject var model: Model
@State private var dragState = CGSize.zero
@State private var cardRotation: Double = 0

private let swipeThreshold: CGFloat = 100.0
private let rotationFactor: Double = 35.0

var action: (Model) -> Void

var body: some View {
GeometryReader { geometry in
if model.unswipedCards.isEmpty && model.swipedCards.isEmpty {
emptyCardsView
.frame(width: geometry.size.width, height: geometry.size.height)
} else if model.unswipedCards.isEmpty {
swipingCompletionView
.frame(width: geometry.size.width, height: geometry.size.height)
} else {
ZStack {
Color.white.ignoresSafeArea()

ForEach(model.unswipedCards.reversed()) { card in
let isTop = card == model.unswipedCards.first
let isSecond = card == model.unswipedCards.dropFirst().first

CardView(
model: card,
size: geometry.size,
dragOffset: dragState,
isTopCard: isTop,
isSecondCard: isSecond
)
.offset(x: isTop ? dragState.width : 0)
.rotationEffect(.degrees(isTop ? Double(dragState.width) / rotationFactor : 0))
.gesture(
DragGesture()
.onChanged { gesture in
self.dragState = gesture.translation
self.cardRotation = Double(gesture.translation.width) / rotationFactor
}
.onEnded { _ in
if abs(self.dragState.width) > swipeThreshold {
let swipeDirection: CardView.SwipeDirection = self.dragState.width > 0 ? .right : .left
model.updateTopCardSwipeDirection(swipeDirection)

withAnimation(.easeOut(duration: 0.5)) {
self.dragState.width = self.dragState.width > 0 ? 1000 : -1000
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.model.removeTopCard()
self.dragState = .zero
}
} else {
withAnimation(.spring()) {
self.dragState = .zero
self.cardRotation = 0
}
}
}
)
.animation(.easeInOut, value: dragState)
}
}
.padding()
}
}
}

var emptyCardsView: some View {
VStack {
Text("No Cards")
.font(.title)
.padding(.bottom, 20)
.foregroundStyle(.gray)
}
}

var swipingCompletionView: some View {
VStack {
Text("Finished Swiping")
.font(.title)
.padding(.bottom, 20)

Button(action: {
action(model)
}) {
Text("Reset")
.font(.headline)
.frame(width: 200, height: 50)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
}

Putting it All Together in ContentView

Finally, let’s wrap this up with a simple ContentView to see our swipeable cards in action. When all the cards are swiped, users will be prompted to reset the stack.

import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
let cards = [
CardView.Model(text: "Card 1"),
CardView.Model(text: "Card 2"),
CardView.Model(text: "Card 3"),
CardView.Model(text: "Card 4")
]

let model = SwipeableCardsView.Model(cards: cards)
SwipeableCardsView(model: model) { model in
print(model.swipedCards)
model.reset()
}
}
.padding()
}
}

Conclusion

And that’s it! We’ve created a fun, swipeable card experience using SwiftUI with minimal effort. You can customize the cards with any content, whether it’s profiles, food options, or tasks to complete. The possibilities are endless!

--

--

JC

Software engineer & founder. Built ML investing tools, Wall Bounce game, BetTheFarm, Barrier app, & Annot8. Innovating solutions daily.