A SwiftUI wrapper for ZLSwipeableViewSwift, bringing Tinder-style swipeable card stacks to SwiftUI.
- Swipeable card stack with gesture controls for left, right, up, and down
- Bounded or infinite card collections
- Current item binding for tracking the top card
- Per-card and per-view action callbacks
- Built-in
CardViewcomponent with customizable styling
Add ZLSwipeableViewSwiftUI to your project using Xcode:
- In Xcode, select File → Add Package Dependencies...
- Enter the repository URL:
https://github.com/alldritt/ZLSwipeableViewSwiftUI - Select the version or branch you want to use
- Click Add Package
Or add it to your Package.swift file:
dependencies: [
.package(url: "https://github.com/alldritt/ZLSwipeableViewSwiftUI", from: "0.2.0")
]- iOS 17.0+
- Swift 6.0+
- Xcode 16.0+
See the screen recording below for a demonstration of SwipeableView in action:
Pass a collection directly, similar to List or ForEach. The card sequence ends when the collection is exhausted:
import SwiftUI
import ZLSwipeableViewSwiftUI
struct ContentView: View {
let colors: [Color] = [.green, .blue, .purple, .pink, .yellow,
.brown, .teal, .cyan, .orange, .red,
.mint, .indigo]
var body: some View {
SwipeableView(colors, id: \.self) { color in
CardView()
.foregroundColor(color)
.padding(5)
}
}
}Collections with Identifiable elements don't need the id: parameter:
SwipeableView(profiles) { profile in
ProfileCard(profile: profile)
}CardView is a built-in component that creates a rounded rectangle card with shadow. You can use it as-is or create your own custom card views.
Pass a currentItem: binding to track which element is on top of the stack. The binding is read-only -- it updates automatically as cards are swiped away and becomes nil when all cards are gone, but setting it externally won't change which card is displayed:
@State private var currentProfile: Profile?
var body: some View {
VStack {
Text(currentProfile?.name ?? "No more profiles")
SwipeableView(profiles, currentItem: $currentProfile) { profile in
ProfileCard(profile: profile)
}
}
}This works with both Identifiable collections and the explicit id: key path form.
Data-driven SwipeableViews react to changes in the backing collection. If you append or replace items, the deck automatically loads new cards into any empty slots. The currentItem binding and canSwipe state update accordingly:
struct ContentView: View {
@State private var cards: [Profile] = initialBatch
var body: some View {
SwipeableView(cards, currentItem: $currentProfile) { profile in
ProfileCard(profile: profile)
}
Button("Load More") {
cards.append(contentsOf: nextBatch)
}
}
}Cards already displayed are not refreshed -- only empty slots are filled from the updated collection. Element identity (via Identifiable or the id: key path) is used to detect changes, not collection count.
For dynamic or infinite card sequences, use the content closure form. The closure is called each time a new card is needed. Return nil to end the sequence:
SwipeableView {
if hasMoreCards {
CardView()
.foregroundColor(colors.randomElement()!)
.padding(5)
}
else {
nil
}
}Action callbacks can be attached at three levels: the SwipeableView itself, a card, or any subview within a card.
To add a callback to a card or its subviews, place the modifier on the view returned by your content closure:
SwipeableView {
ZStack {
CardView()
.padding()
Text("Hello World")
.onZLSwipeStarted { location in
print("Text.onZLSwipeStarted at \(location)")
}
}
.onZLSwipeStarted { location in
print("Card.onZLSwipeStarted at \(location)")
}
}To add a callback to the SwipeableView container:
SwipeableView {
CardView()
.padding()
}
.onZLSwipeStarted { location in
print("SwipeableView.onZLSwipeStarted at \(location)")
}The following action modifiers are available:
onZLSwiped-- called when a card is swiped away. Provides aDirection(from ZLSwipeableViewSwift) and aCGVectorvelocity.onZLSwipeStarted-- called when a swipe gesture begins. Provides the startingCGPointlocation.onZLSwipeEnded-- called when a swipe gesture ends. Provides the endingCGPointlocation.onZLSwipeCancelled-- called when a swipe gesture is cancelled.onZLSwiping-- called continuously during a swipe. Provides the currentCGPointlocation, aCGPointtranslation, and aUnitPointmovement value clamped to [-1, 1] representing the relative swipe position.
numberOfActiveView(_ count: UInt)-- sets the number of cards visible in the stack at once.numberOfHistoryItem(_ count: UInt)-- sets the number of previously swiped cards kept in history.allowsSwiping(_ allowed: Bool)-- enables or disables swipe gestures. Whenfalse, the top card cannot be swiped away by the user. Useful for single-card collections where swiping doesn't make sense. Defaults totrue.
Use SwipeableViewReader to get a proxy object that can swipe, rewind, or discard cards programmatically. This follows the same pattern as SwiftUI's ScrollViewReader / ScrollViewProxy:
SwipeableViewReader { proxy in
VStack {
SwipeableView(items, currentItem: $currentItem) { item in
CardView()
.foregroundColor(item.color)
}
.numberOfActiveView(5)
.numberOfHistoryItem(3)
HStack {
Button("Undo") { proxy.rewind() }
Button("Swipe Left") { proxy.swipe(.Left) }
Button("Swipe Right") { proxy.swipe(.Right) }
Button("Skip") { proxy.discardTop() }
}
}
}The proxy provides the following methods and properties:
swipe(_ direction: Direction)-- programmatically swipe the top card in the given direction with an animated transition. ThecurrentItembinding stays in sync automatically.rewind()-- restore the most recently swiped card from history. RequiresnumberOfHistoryItemto be set. No-op when history is empty. Note that cards removed viadiscardTop()are not added to history and cannot be rewound.discardTop()-- instantly remove the top card without animation. No-op when there are no cards. This operation is not undoable.canRewind: Bool-- whether there are cards in history that can be rewound.canSwipe: Bool-- whether there is a top card that can be swiped or discarded.
Note:
canRewindandcanSwipeare computed properties that read UIKit state. They re-evaluate on SwiftUI re-renders (triggered bycurrentItembinding changes), which covers the typical case. They won't update reactively on their own without a binding change.
The repository includes a complete example application. To run it:
- Clone this repository
- Open
Example.xcodeprojin Xcode - Build and run the Example target
The example demonstrates bounded card collections, action callbacks at multiple levels, custom card content layered on CardView, configuration options, and programmatic card control via SwipeableViewReader.
v0.2.0 renamed the action callback API. The old names still work but are deprecated:
onDidStart→onZLSwipeStartedonDidEnd→onZLSwipeEndedonDidCancel→onZLSwipeCancelled
- v0.1.0 -- initial implementation.
- v0.2.0 -- data-driven initializers with dynamic data change handling,
currentItem:binding, per-card action callbacks, bounded card support,CardView, andSwipeableViewReaderfor programmatic control.
ZLSwipeableViewSwiftUI is available under the MIT license. See the LICENSE file for more info.
Created by Mark Alldritt
Built on top of ZLSwipeableViewSwift by Zhixuan Lai.
