I’m building a horizontal “carousel” style view in SwiftUI where items can be reordered by dragging (similar to an editor timeline).
The basic idea:
Items are laid out in a horizontal track inside a ScrollView(.horizontal).
In Edit mode, you can drag an item horizontally.
While dragging, I update the array and recompute all item positions to live-reorder them.
The code below is a minimal example. It works functionally, but the drag does not feel smooth: the dragged item jitters and the other items jump around while I’m dragging.
My question is:
What changes should I make to this implementation so that the drag-to-reorder interaction feels smooth and continuous (no jitter) when dragging items horizontally?
auto-scrolling when dragging cells near the edges in a horizontally scrolling canvas
Here is the demo code:
struct ContentView: View {
@State var isEditing: Bool = false
var body: some View {
VStack(alignment: .center, spacing: 30){
Button( isEditing ? "Done" : "Edit", systemImage: isEditing ? "checkmark" : "slider.horizontal.3") {
isEditing.toggle()
}
.buttonStyle(.borderedProminent)
HorizontalCanvasView(editMode: $isEditing)
}
.frame(maxHeight: .infinity)
}
}
struct CanvasItem: Identifiable, Equatable {
let id = UUID()
var color: Color
var size: CGSize = CGSize(width: 100, height: 100)
}
struct HorizontalCanvasView: View {
@State private var items: [CanvasItem] = [
CanvasItem(color: .blue),
CanvasItem(color: .red),
CanvasItem(color: .green)
]
@Binding var editMode: Bool
private let canvasHeight: CGFloat = 300
private let itemSize = CGSize(width: 100, height: 100)
private let itemSpacing: CGFloat = 10
private let horizontalPadding: CGFloat = 200
// Drag state
@State private var draggedID: UUID? = nil
@State private var dragOffsetX: CGFloat = 0 // visual offset for dragged item
@State private var lastTranslationX: CGFloat = 0 // last gesture.translation.width
private var cellWidth: CGFloat {
itemSize.width + itemSpacing
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: canvasHeight)
HStack(spacing: itemSpacing) {
ForEach(items) { item in
CanvasItemView(
item: item,
size: itemSize,
isDragging: draggedID == item.id,
editMode: editMode,
horizontalOffset: draggedID == item.id ? dragOffsetX : 0,
onDragChanged: { value in
handleDragChanged(for: item, value: value)
},
onDragEnded: { value in
handleDragEnded(for: item, value: value)
}
)
}
}
.padding(.horizontal, horizontalPadding)
.frame(height: canvasHeight)
}
}
.frame(height: canvasHeight)
.onChange(of: editMode) { oldValue, newValue in
if !newValue {
// Reset drag state when leaving edit mode
draggedID = nil
dragOffsetX = 0
lastTranslationX = 0
}
}
}
// MARK: - Drag logic (smooth, order-based)
private func handleDragChanged(for item: CanvasItem, value: DragGesture.Value) {
guard editMode else { return }
// Start of drag
if draggedID == nil {
draggedID = item.id
dragOffsetX = 0
lastTranslationX = value.translation.width
return
}
// Only track the currently dragged item
guard draggedID == item.id,
let currentIndex = items.firstIndex(where: { $0.id == item.id }) else { return }
// Incremental delta instead of full translation
let deltaX = value.translation.width - lastTranslationX
lastTranslationX = value.translation.width
dragOffsetX += deltaX
// Move right
if dragOffsetX > cellWidth / 2, currentIndex < items.count - 1 {
let newIndex = currentIndex + 1
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
items.move(fromOffsets: IndexSet(integer: currentIndex),
toOffset: newIndex + 1)
}
// Keep visual position continuous
dragOffsetX -= cellWidth
}
// Move left
else if dragOffsetX < -cellWidth / 2, currentIndex > 0 {
let newIndex = currentIndex - 1
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
items.move(fromOffsets: IndexSet(integer: currentIndex),
toOffset: newIndex)
}
dragOffsetX += cellWidth
}
}
private func handleDragEnded(for item: CanvasItem, value: DragGesture.Value) {
guard draggedID == item.id else { return }
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
dragOffsetX = 0
}
draggedID = nil
lastTranslationX = 0
}
}
struct CanvasItemView: View {
let item: CanvasItem
let size: CGSize
let isDragging: Bool
let editMode: Bool
let horizontalOffset: CGFloat
let onDragChanged: (DragGesture.Value) -> Void
let onDragEnded: (DragGesture.Value) -> Void
var body: some View {
ZStack {
Rectangle()
.fill(item.color.opacity(isDragging ? 0.7 : 0.8))
.frame(width: size.width, height: size.height)
.overlay(
Text("Item \(item.id.uuidString.prefix(4))")
.foregroundColor(.white)
)
.scaleEffect(isDragging ? 1.05 : 1.0)
.shadow(color: .black.opacity(0.25),
radius: isDragging ? 10 : 0,
x: 0,
y: isDragging ? 6 : 0)
if editMode {
VStack {
Spacer()
Image(systemName: "line.3.horizontal")
.font(.title2)
.foregroundColor(.black)
.padding(.bottom, 8)
}
.frame(width: size.width)
}
}
.offset(x: horizontalOffset, y: 0)
.zIndex(isDragging ? 1 : 0)
.gesture(
editMode ?
DragGesture()
.onChanged(onDragChanged)
.onEnded(onDragEnded)
: nil
)
}
}
Any suggestions on how to structure the data / gestures so that dragging and reordering feels smooth?

