Spring Physics and Swipe Mechanics: Building SwipeClean's Card Interface in SwiftUI

The card swiping interaction is the most important surface in SwipeClean. It's where users spend 95% of their time. If it feels sluggish, laggy, or disconnected from your finger, the entire app fails. No amount of gamification or visual polish will save a core interaction that doesn't feel right.
I spent more time tuning the card swipe than any other single feature in the app. Not because the code is complex (it's not, honestly), but because the difference between "this feels good" and "this feels native" lives in about six carefully chosen constants and two animation curves.
This is the full breakdown of how SwipeClean's card interface works, from the ZStack compositor to the spring physics to the fly-away dismissal. Real code from the production app.
#The Architecture: Three Layers of Abstraction
The swipe interface is split across three views, each with a distinct responsibility:
-
SwipeViewis the root container. It manages the round lifecycle (loading, active, ended), renders the background wash, top bar, progress bar, and action buttons. It owns theRoundViewModeland presents the card stack. -
CardStackViewis the ZStack compositor. It takes the visible cards from the view model, renders them as layered cards with depth scaling, and owns theDragGesture. Only the top card responds to touch. -
CardViewis a single card. It handles media display (photo or video), the metadata strip, and the "KEEP" / "DELETE" stamp overlay. It has zero knowledge of swiping or gestures.
This separation matters. CardView is completely reusable. It appears in the standard swipe round, the Boss Battle mode, and the screenshot generator for the App Store. It doesn't know or care whether it's being swiped, tapped, or displayed statically. All swipe behavior is owned by CardStackView.
#The Card Stack: ZStack with Reverse Enumeration
The visible card stack renders up to three cards simultaneously. The trick is reverse enumeration: the bottom card renders first, then the middle card on top of it, then the top card on top of everything.
var body: some View {
ZStack {
ForEach(
Array(viewModel.visibleCards.enumerated().reversed()),
id: \.element.id
) { index, asset in
cardLayer(asset: asset, depth: index)
}
}
.padding(.horizontal, DesignTokens.Card.horizontalPadding)
}
The visibleCards computed property returns a slice of the photo array starting at the current index:
var visibleCards: [PhotoAsset] {
let start = currentIndex
let end = min(currentIndex + AppConfig.maxCardStackDepth, photoAssets.count)
guard start < end else { return [] }
return Array(photoAssets[start..<end])
}
maxCardStackDepth is 3. I tested with 5 and the visual noise wasn't worth the rendering cost. Three cards gives you the "deck of cards" illusion without cluttering the screen.
The depth index (0, 1, 2) drives everything: scale, vertical offset, z-index, and whether the card accepts gestures.
#Depth Illusion: Scale and Offset
Each card behind the top card is slightly smaller and pushed down:
let scale = 1.0 - CGFloat(depth) * (1.0 - AppConfig.cardStackScaleFactor)
let yOffset = CGFloat(depth) * AppConfig.cardStackVerticalOffset
The constants:
cardStackScaleFactor: 0.95. Each successive card is 5% smaller.cardStackVerticalOffset: 4.0 points. Each successive card peeks out 4 points below.
So the stack looks like:
| Card | Scale | Y Offset |
|---|---|---|
| Top (depth 0) | 1.00 | 0pt |
| Middle (depth 1) | 0.95 | 4pt |
| Bottom (depth 2) | 0.90 | 8pt |
These values are intentionally subtle. You want the user to perceive depth, not consciously notice it. I tried larger offsets (8pt, 12pt) and they made the stack look messy. 4 points is just enough to see the card edges peeking behind the top card.
The scale and offset are applied as SwiftUI modifiers:
return CardView(/* ... */)
.scaleEffect(scale)
.offset(y: yOffset)
.offset(x: isTop ? viewModel.dragOffset.width : 0,
y: isTop ? viewModel.dragOffset.height * 0.3 : 0)
.rotationEffect(isTop ? dragRotation : .zero)
.zIndex(Double(10 - depth))
Notice the vertical drag damping: dragOffset.height * 0.3. The card follows your finger horizontally at 1:1, but vertically at 0.3:1. This prevents the card from flying off the screen diagonally. Horizontal motion feels intentional (swipe left/right). Vertical motion is a byproduct of imprecise thumb movement. Damping the vertical axis keeps the interaction focused on the decision axis without feeling locked.
#The Drag Gesture: From Finger to Physics
The gesture handler is deceptively simple:
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
viewModel.onDragChanged(value.translation)
}
.onEnded { _ in
viewModel.onDragEnded()
}
}
minimumDistance: 10 is important. Without it, every touch registers as a drag, including taps. 10 points gives enough dead zone to distinguish a tap (which opens the photo zoom overlay) from a drag (which swipes the card). I started at 0 and the tap-to-zoom feature was unusable. 20 felt sluggish, like the card was stuck to the table. 10 is the sweet spot.
The gesture is only attached to the top card:
.gesture(isTop ? dragGesture : nil)
Background cards are inert. They sit there looking pretty.
#Rotation: The Tilted Card Effect
Cards rotate during drag, tilting in the direction of movement. This is the detail that makes the interaction feel physical rather than digital. A card being pushed across a table tilts. So does ours.
private var dragRotation: Angle {
let normalizedOffset = viewModel.dragOffset.width / (viewModel.screenWidth * 0.5)
let clamped = max(-1, min(1, normalizedOffset))
return .degrees(clamped * AppConfig.maxCardRotationDegrees)
}
The rotation is proportional to drag distance, normalized against half the screen width. maxCardRotationDegrees is 15 degrees. The clamping to [-1, 1] means the card never rotates past 15 degrees in either direction, even if the user drags past the threshold.
Why 15? Because I stole it from Tinder. Not literally, but I studied their interaction closely, and 12-18 degrees is the range that reads as "casual tilt" rather than "the card is falling off the screen." Below 10 degrees, the rotation is barely perceptible. Above 20, it looks broken. 15 is the Goldilocks number.
The rotation feels continuous because it updates on every .onChanged callback, which fires at 60-120Hz depending on the device's ProMotion refresh rate.
#The Threshold Decision: Commit or Snap Back
When the user lifts their finger, the system has to make a binary decision: did they mean to swipe, or were they just exploring?
The answer lives in one constant:
static let dragThresholdRatio: CGFloat = 0.3
If the card has been dragged more than 30% of the screen width, the swipe is committed. Otherwise, it snaps back.
var isPastThreshold: Bool {
abs(dragOffset.width) > screenWidth * AppConfig.dragThresholdRatio
}
The onDragEnded handler branches on this:
func onDragEnded() {
guard case .active = roundState else { return }
let committedDirection: SwipeDecision? = isPastThreshold ? dragDirection : nil
if let direction = committedDirection {
// Fly away
let flyDirection: CGFloat = direction == .keep ? 1 : -1
withAnimation(DesignTokens.Animations.cardFlyAway) {
dragOffset = CGSize(
width: flyDirection * screenWidth * 1.5,
height: dragOffset.height
)
}
Task {
try? await Task.sleep(for: .milliseconds(150))
recordDecision(direction)
dragOffset = .zero
}
} else {
// Snap back
withAnimation(DesignTokens.Animations.cardSnapBack) {
dragOffset = .zero
}
}
}
Two paths. Two different animation curves.
#The Two Animations: Fly-Away vs. Snap-Back
This is where the physics happen.
Fly-away uses easeOut(duration: 0.3):
static let cardFlyAway: Animation = .easeOut(duration: 0.3)
EaseOut starts fast and decelerates. This matches the physics of flicking something: high initial velocity that bleeds off as the object moves away. The card accelerates to 1.5x the screen width (so it fully exits the viewport) and decelerates as it leaves.
0.3 seconds is fast. The card needs to get out of the way quickly because the next card is waiting behind it. Anything slower than 0.4 and the interaction starts to feel laggy when you're swiping through 50 photos at speed.
The 150ms delay before calling recordDecision is deliberate. It gives the fly-away animation time to start before the view model updates currentIndex, which triggers the next card to promote to the top position. Without this delay, the next card would visually "jump" into position while the outgoing card is still visible. The delay lets the exit animation lead, then the state change follows.
Snap-back uses a spring:
static let cardSnapBack: Animation = .spring(response: 0.4, dampingFraction: 0.7)
Springs are the right choice here because the card is returning to its resting position. A linear or ease animation would feel robotic. A spring overshoots slightly and settles, which reads as "the card bounced back."
The two parameters:
- Response (0.4s): How long the spring takes to reach approximate equilibrium. 0.4 is brisk. Not so fast that you miss the bounce, not so slow that it feels floaty.
- Damping fraction (0.7): Controls overshoot. 1.0 = no overshoot (critically damped). 0.0 = infinite oscillation. 0.7 gives a single visible overshoot before settling. The card springs past center by maybe 3-5 points, then returns. It's subtle but it sells the physicality.
I went through about 12 iterations on these values. Early versions used response: 0.6, dampingFraction: 0.5, which looked like a bouncy ball. Too playful for an app about deleting photos. The current values feel precise and controlled, like a well-calibrated latch mechanism.
#Drag Progress: Continuous Feedback
The drag progress is a normalized 0-to-1 value that drives visual feedback across the entire interface:
var dragProgress: CGFloat {
let threshold = screenWidth * AppConfig.dragThresholdRatio
return min(abs(dragOffset.width) / threshold, 1.0)
}
At 0.0, no visual feedback. At 1.0, maximum feedback (threshold reached). This value propagates to:
- Stamp opacity: The "KEEP" or "DELETE" stamp fades in proportionally.
- Stamp scale: Starts at 0.8x and grows to 1.0x as drag progress increases.
- Action button glow: The keep/delete button on the matching side grows a halo.
- Background wash: The screen tints green (keep) or red (delete).
All of these are driven by the same dragProgress value, so they are perfectly synchronized. When you drag the card 50% to the right, the keep stamp is at 50% opacity, the keep button has a 50% glow, and the background has a 50% green tint. The whole interface breathes with your gesture.
The stamp overlay implementation:
Text(stampText)
.font(.largeTitle.weight(.heavy))
.tracking(DesignTokens.Stamp.tracking)
.foregroundStyle(color)
.rotationEffect(.degrees(rotation))
.opacity(Double(progress))
.scaleEffect(
DesignTokens.Stamp.minScale
+ DesignTokens.Stamp.scaleRange * Double(progress)
)
The rotation on the stamp (15 degrees, matching the card tilt) is a nice touch. The stamp and card tilt in the same direction, reinforcing the physical metaphor.
#Direction Detection: The 20-Point Dead Zone
var dragDirection: SwipeDecision? {
if dragOffset.width > 20 { return .keep }
if dragOffset.width < -20 { return .delete }
return nil
}
The 20-point dead zone in the center prevents visual noise. Without it, the tiniest horizontal movement would trigger the stamp overlay and background tint, which looks jittery during vertical scrolling or minor touch adjustments.
20 points is about 6mm on a standard iPhone. You have to deliberately move the card sideways before the interface starts reacting to direction. This prevents false positive feedback while still feeling responsive.
#Image Loading: The Hidden Performance Lever
Smooth swiping means nothing if every card shows a loading spinner. Image loading is the performance-critical path.
The strategy is three-tiered:
-
Arena preload: Before the round starts, load the first 5 photos at high quality (400x533 points, matching the 3:4 card aspect ratio). This happens during the "Preparing Arena" loading screen, with an 8-second timeout for slow iCloud downloads.
-
Parallel lookahead: During gameplay, maintain a 10-photo lookahead buffer. When the user swipes, a TaskGroup loads the next batch in parallel.
-
Background caching: Beyond the lookahead window, a background task continuously pre-caches upcoming images.
private func loadImagesForVisibleCards() async {
// Evict images more than 10 positions behind current index
let evictionThreshold = max(currentIndex - 10, 0)
for i in 0..<evictionThreshold {
let id = photoAssets[i].id
loadedImages.removeValue(forKey: id)
}
let endIndex = min(currentIndex + 10, photoAssets.count)
let upcomingAssets = Array(photoAssets[currentIndex..<endIndex])
let assetsToLoad = upcomingAssets.filter { loadedImages[$0.id] == nil }
await withTaskGroup(of: (String, UIImage?).self) { group in
for asset in assetsToLoad {
group.addTask {
let image = await self.photoService.loadHighQualityImage(
for: asset,
targetSize: AppConfig.photoThumbnailSize
)
return (asset.id, image)
}
}
for await (id, image) in group {
if let image { loadedImages[id] = image }
}
}
}
The eviction at currentIndex - 10 bounds memory usage. On a 50-photo batch, you never hold more than ~20 images in memory (10 behind for undo support, 10 ahead for the buffer). Without eviction, memory climbs linearly as the user swipes.
The undo buffer is why the eviction threshold is 10 and not 3. If the user undoes a swipe, the previous card needs its image immediately. Keeping a 10-card trailing buffer means undo never shows a loading state.
#Haptic Feedback: The Invisible Layer
Every swipe action triggers haptic feedback through a centralized HapticService:
static func keep() {
guard isEnabled else { return }
UISelectionFeedbackGenerator().selectionChanged()
}
static func delete() {
guard isEnabled else { return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
Keep uses selectionChanged(), a light tick. Delete uses .medium impact, a heavier thud. The asymmetry is intentional. Keeping a photo should feel effortless. Deleting should feel deliberate.
The haptic fires at the moment of commitment (when onDragEnded determines the swipe crossed the threshold), not during the drag. Continuous haptics during drag would be overwhelming. A single precise tap at the decision point is all you need.
#Background Wash: Environment as Feedback
The entire screen background shifts color during swipe:
if let direction = viewModel.dragDirection {
let color = direction == .keep
? DesignTokens.Colors.actionKeep
: DesignTokens.Colors.actionDelete
color.opacity(
Double(viewModel.dragProgress) * AppConfig.maxBackgroundTintOpacity
)
.animation(
reduceMotion
? DesignTokens.Animations.instant
: DesignTokens.Animations.backgroundTint,
value: viewModel.dragProgress
)
}
maxBackgroundTintOpacity is 0.3. At full drag progress, the background is 30% tinted. This is strong enough to be unmistakable, but subtle enough that it doesn't overwhelm the photo on the card.
The backgroundTint animation is easeInOut(duration: 0.15). Fast enough to feel responsive, slow enough to avoid flickering when the user rapidly changes direction.
Notice the reduceMotion check. If the user has enabled Reduce Motion in iOS accessibility settings, all animations snap instantly instead of animating. Every animation in the app checks this flag. It isn't optional.
#Accessibility: Not an Afterthought
Accessibility permeates the card stack:
.accessibilityAddTraits(isTop ? .isSelected : [])
.accessibilityHint(isTop ? SwipeRoundStrings.zoomHint : "")
.accessibilityHidden(depth != 0)
Background cards are hidden from VoiceOver entirely. Only the top card is accessible, marked as selected, with a hint explaining the interaction. The action buttons provide VoiceOver users with an alternative to swiping.
The stamp overlay is explicitly marked accessibilityHidden(true) because it's a visual redundancy. VoiceOver users get the swipe decision feedback through the button labels and announcements, not through visual stamps they can't see.
#What I Would Change
Two things.
First, the 150ms delay in onDragEnded before calling recordDecision is a hack. A cleaner solution would be an explicit animation completion callback, but SwiftUI's withAnimation doesn't provide one as of iOS 18. The delay works, but it's brittle. If the fly-away animation duration ever changes from 0.3 to something shorter, the 150ms delay could create a visible gap.
Second, the image eviction strategy is crude. Evicting by index position (currentIndex - 10) doesn't account for image memory size. A 4K video thumbnail consumes significantly more memory than a small screenshot. A more sophisticated approach would track actual byte usage and evict by size when a threshold is exceeded. For a 50-photo batch this doesn't matter. For larger batches, it might.
#The Constants That Matter
If you're building a similar interface, here are the numbers that took the longest to dial in:
| Constant | Value | Why |
|---|---|---|
dragThresholdRatio | 0.30 | 30% of screen width feels intentional without requiring a full-arm swipe |
maxCardRotationDegrees | 15.0 | Reads as natural tilt, not broken layout |
cardStackScaleFactor | 0.95 | Perceptible depth without visual clutter |
cardStackVerticalOffset | 4.0pt | Just enough peek to signal "more cards behind" |
cardSnapBack spring | response 0.4, damping 0.7 | One visible overshoot, then settle |
cardFlyAway | easeOut 0.3s | Fast exit so the next card feels immediate |
minimumDistance (DragGesture) | 10pt | Dead zone that separates tap from drag |
Every one of these went through multiple iterations. The final values aren't mathematically derived. They are the result of testing on-device, swiping hundreds of times, and adjusting until the interaction felt right.
That's the reality of gesture-driven UI. The code is straightforward. The tuning is where the craft lives.
Last updated: March 8, 2026