Return_to_vault
[CONSTRUCT: 2026-03-08]
SwiftUI Spring Card Swiping
SwiftSwiftUIiOSAnimation
SwiftUI Spring Card Swiping
Extracted straight from the SwipeClean iOS app. This is the card stack with drag gestures, spring snap-back, and fly-away animations that make swiping feel physical instead of animated. Three cards rendered in a ZStack with scale and offset to create depth. Only the top card responds to drag input.
When to Use
- Building any Tinder-style swipeable card interface in SwiftUI
- Photo review or decision UIs where users need to sort items quickly
- Any interface where gesture-driven dismissal needs to feel satisfying
The Code
// Card Stack with Spring-Physics Swiping (SwipeClean pattern)
// ZStack compositor: 3 cards deep with scale + offset depth.
// Only the top card responds to drag.
struct CardStackView: View {
@Bindable var viewModel: RoundViewModel
var body: some View {
ZStack {
ForEach(Array(viewModel.visibleCards.enumerated().reversed()),
id: \.element.id) { index, asset in
cardLayer(asset: asset, depth: index)
}
}
}
private func cardLayer(asset: PhotoAsset, depth: Int) -> some View {
let isTop = depth == 0
let scale = 1.0 - CGFloat(depth) * 0.05
let yOffset = CGFloat(depth) * 4.0
return CardView(asset: asset, isTopCard: isTop)
.scaleEffect(scale)
.offset(y: yOffset)
.offset(x: isTop ? viewModel.dragOffset.width : 0)
.rotationEffect(isTop ? dragRotation : .zero)
.gesture(isTop ? dragGesture : nil)
}
private var dragRotation: Angle {
let normalized = viewModel.dragOffset.width / (UIScreen.main.bounds.width * 0.5)
return .degrees(max(-1, min(1, normalized)) * 15)
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { viewModel.onDragChanged($0.translation) }
.onEnded { _ in viewModel.onDragEnded() }
}
}
// Drag handler with spring snap-back and fly-away
func onDragEnded() {
let isPastThreshold = abs(dragOffset.width) > screenWidth * 0.3
if isPastThreshold, let direction = dragDirection {
let flyX: CGFloat = direction == .keep ? 1 : -1
withAnimation(.easeOut(duration: 0.3)) {
dragOffset = CGSize(width: flyX * screenWidth * 1.5, height: dragOffset.height)
}
} else {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
dragOffset = .zero
}
}
}
Notes
The spring values (response: 0.4, dampingFraction: 0.7) were tuned by hand on a real device. The defaults feel floaty. The 30% screen-width threshold for committing a swipe prevents accidental dismissals while still feeling responsive. Max rotation is capped at 15 degrees so the card tilts without looking broken.