Skip to content
___________________________
V.1.0.0 // SECURE CONNECTION
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.

Share

"End of transmission."

[CLOSE_CONSTRUCT]