A minimal, working reference project showing how to build wallet-style card deck interactions in SwiftUI — stacked layout, pull-to-fan, hero transitions, long-press reorder, and drag-to-dismiss.
This is the animation pattern Apple uses in Wallet but barely documents. There is no clean, end-to-end example showing how to combine matchedGeometryEffect, UILongPressGestureRecognizer, scroll geometry tracking, and spring animations into a cohesive card deck. This repo is that example.
Extracted from ToMe, an iOS app for saving and organizing content from anywhere.
demo.mp4
You want a card deck UI with these interactions:
| Interaction | Challenge |
|---|---|
| Stacked cards with overlap | Negative spacing + z-index management for correct tap targeting |
| Pull down to fan cards apart | Must capture overscroll from ScrollView with resistance factor |
| Tap to expand with hero animation | matchedGeometryEffect across two different view hierarchies |
| Long-press + drag to reorder | SwiftUI gestures can't handle this on overlapping views inside a ScrollView |
| Drag down to dismiss | Must coexist with scroll content and use velocity-based thresholds |
| Haptic feedback | Paired with every interaction for tactile polish |
SwiftUI provides the building blocks but no guidance on combining them. The gesture system in particular falls apart when you need long-press-then-drag on overlapping cards inside a scroll view.
SwiftUI's gesture system can't resolve which overlapping card is under the finger, or handle long-press-then-drag while coexisting with a parent ScrollView. The solution is a UIViewRepresentable wrapping UILongPressGestureRecognizer:
LongPressDragGestureView(
minimumPressDuration: 0.4,
onTapAt: { location in /* resolve card via coordinate math */ },
onBeganAt: { location in /* start reorder */ },
onChanged: { translationY in /* update drag offset */ },
onEnded: { /* commit reorder */ }
)It disables the parent ScrollView during drag and resolves card identity using pickCard(at:) coordinate math.
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newValue in
let baseline = initialContentOffset ?? newValue
let overscrollRaw = max(0, baseline - newValue)
stackedScrollOffset = overscrollRaw * 0.25 // resistance factor
}The stackedScrollOffset is passed to CardStackView which spreads cards apart proportionally:
let spreadPerCard = min(pullAmount * 0.8, 150)
// Each card offsets by: anchorOffset + (spreadPerCard * index)// DeckView owns the namespace
@Namespace private var animation
// Both CardStackView and ExpandedCardView tag the same card ID
.matchedGeometryEffect(id: card.id, in: animation)
// Toggle with spring animation
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
selectedCard = card // or nil to dismiss
}.simultaneousGesture(
DragGesture(minimumDistance: 20)
.onChanged { value in
if verticalAmount > 0 && canDragDownToDismiss {
dragDownOffset = verticalAmount * 0.6 // rubber-band resistance
}
}
.onEnded { value in
// Dismiss if offset > 120pt OR velocity > 1.5 && offset > 60pt
}
)Visual feedback during drag: .offset(y:), .opacity(1 - progress*0.3), .scaleEffect(1 - progress*0.1, anchor: .top).
The expanded card header fades and scales as you scroll down, tracking the finger directly (no animation):
.opacity(max(0.0, 1.0 - (scrollOffset / 200.0)))
.scaleEffect(max(0.85, 1.0 - (scrollOffset / 800.0)))
.animation(nil, value: scrollOffset) // Track finger, don't animateCardDeckInteractions/
├── project.yml # XcodeGen project definition
└── Sources/
└── App/
├── CardDeckApp.swift # @main entry point
├── Models/
│ └── Card.swift # Simple model: id, color, title, sortOrder
├── Views/
│ ├── DeckView.swift # Orchestrator: hero animation + pull-to-fan
│ ├── CardStackView.swift # Stacked layout + reorder + bounce
│ ├── ExpandedCardView.swift # Full-screen card + drag-to-dismiss
│ └── DeckCardView.swift # Single card visual (color + corner radius)
├── Gestures/
│ └── LongPressDragGesture.swift # UIKit gesture wrapper for long-press + drag
└── Helpers/
├── Animations.swift # Spring presets (hero, reorder, dismiss, bounce)
└── HapticManager.swift # Simple haptic feedback
The project uses XcodeGen to generate the .xcodeproj from project.yml. This avoids .pbxproj merge conflicts and keeps the setup reproducible.
# 1. Install XcodeGen (if you don't have it)
brew install xcodegen
# 2. Generate the Xcode project
xcodegen generate
# 3. Open and run
open CardDeckInteractions.xcodeprojSelect an iOS Simulator target and press Cmd+R.
A two-stage animation triggered by fast scroll-to-top or shake:
for index in cards.indices {
// Stage 1: Jump up with quick stagger
withAnimation(.spring(duration: 0.3, bounce: 0.3).delay(Double(index) * 0.04)) {
cardBounceOffsets[index] = -CGFloat.random(in: 15...30)
}
// Stage 2: Fall back with extra bouncy settle
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.04 + 0.15) {
withAnimation(.spring(duration: 0.4, bounce: 0.65)) {
cardBounceOffsets[index] = 0
}
}
}Non-dragged cards shift with spring animations to make room:
// Cards between start and drop index shift by ±64pt
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: reorderOffset)All animations use curated spring presets for consistency:
| Preset | Usage | Parameters |
|---|---|---|
heroTransition |
Card open/close | response: 0.35, damping: 0.85 |
reorderShift |
Card shifting during drag | response: 0.25, damping: 0.7 |
dismiss |
Drag-to-dismiss | response: 0.3, damping: 0.85 |
extraBouncy |
Bounce settle | duration: 0.4, bounce: 0.65 |
responsive |
Quick feedback | duration: 0.3, bounce: 0.3 |
| Pitfall | Solution |
|---|---|
| Taps register on wrong overlapping card | Use coordinate-based pickCard(at:) on a single gesture surface, not per-card gestures |
| Long-press fights with ScrollView | Disable ScrollView.isScrollEnabled when drag begins, restore on end |
matchedGeometryEffect flickers |
Ensure the same id and Namespace are used in both stacked and expanded states |
| Scroll-driven effects animate instead of tracking | Use .animation(nil, value: scrollOffset) to suppress interpolation |
| Overscroll keeps spreading cards | Capture a baseline offset once and measure relative to it, not the current offset |
| Cards jitter during reorder | Use zIndex to keep the dragged card above all others |
- iOS 18.0+
- Xcode 16+
- Swift 6
- XcodeGen (
brew install xcodegen)
- SwiftDataSharing — SwiftData + App Group sharing across app, extension, and widget
- CloudKitSharing — CloudKit sharing + SwiftData with permission management
Extracted from ToMe by framara.
MIT