I’m constructing a horizontal “carousel” fashion view in SwiftUI the place objects could be reordered by dragging (much like an editor timeline).
The fundamental concept:
Gadgets are specified by a horizontal monitor inside a ScrollView(.horizontal).
In Edit mode, you’ll be able to drag an merchandise horizontally.
Whereas dragging, I replace the array and recompute all merchandise positions to live-reorder them.
The code under is a minimal instance. It really works functionally, however the drag doesn’t really feel clean:
the dragged merchandise jitters and the opposite objects bounce round whereas I’m dragging.
My query is:
What modifications ought to I make to this implementation in order that the drag-to-reorder interplay feels clean and steady (no jitter) when dragging objects horizontally?
Right here is the demo code:
import SwiftUI
struct CanvasItem: Identifiable, Equatable {
let id = UUID()
var place: CGPoint
var measurement: CGSize = CGSize(width: 100, top: 100)
var coloration: Colour = .blue
}
struct HorizontalCanvasView: View {
@State non-public var objects: [CanvasItem] = [
CanvasItem(position: CGPoint(x: 200, y: 150), color: .blue),
CanvasItem(position: CGPoint(x: 320, y: 150), color: .red),
CanvasItem(position: CGPoint(x: 440, y: 150), color: .green)
]
@State non-public var displayItems: [CanvasItem] = []
@State non-public var draggedID: UUID? = nil
@State non-public var dragTranslation: CGSize = .zero
@State non-public var editMode: Bool = false
non-public let canvasHeight: CGFloat = 300
non-public let itemSpacing: CGFloat = 120
non-public let startX: CGFloat = 200
non-public let reorderThreshold: CGFloat = 60
non-public let padding: CGFloat = 200
non-public var totalWidth: CGFloat {
let numItems = CGFloat(objects.rely)
return max(800, startX + (numItems * itemSpacing) + padding)
}
init() {
_displayItems = State(initialValue: [])
}
non-public func updatePositions(for array: inout [CanvasItem]) {
for (index, _) in array.enumerated() {
array[index].place.x = startX + CGFloat(index) * itemSpacing
array[index].place.y = canvasHeight / 2
}
}
non-public func insertionIndex(for x: CGFloat, excluding draggedID: UUID) -> Int {
let others = displayItems
.filter { $0.id != draggedID }
.sorted { $0.place.x < $1.place.x }
var index = 0
for different in others {
if x < different.place.x + reorderThreshold {
return index
}
index += 1
}
return index
}
non-public func updateLiveReorder(for draggedX: CGFloat) {
guard
let id = draggedID,
let draggedIndex = displayItems.firstIndex(the place: { $0.id == id })
else { return }
var temp = displayItems
let dragged = temp.take away(at: draggedIndex)
let newIndex = insertionIndex(for: draggedX, excluding: id)
temp.insert(dragged, at: newIndex)
updatePositions(for: &temp)
if let finalDraggedIndex = temp.firstIndex(the place: { $0.id == id }) {
temp[finalDraggedIndex].place.x = draggedX
}
displayItems = temp
}
non-public func commitReorder() {
objects = displayItems
draggedID = nil
dragTranslation = .zero
updatePositions(for: &objects)
updatePositions(for: &displayItems)
}
non-public func toggleEditMode() {
if !editMode {
editMode = true
} else {
editMode = false
draggedID = nil
dragTranslation = .zero
updatePositions(for: &displayItems)
}
}
var physique: some View {
VStack {
Button(motion: toggleEditMode) {
Textual content(editMode ? "Completed" : "Edit")
.font(.headline)
.padding()
.background(editMode ? Colour.purple : Colour.blue)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.padding()
GeometryReader { _ in
ScrollView(.horizontal, showsIndicators: false) {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Colour.grey.opacity(0.1))
.body(width: totalWidth, top: canvasHeight)
ForEach(displayItems) { merchandise in
CanvasItemView(
merchandise: merchandise,
draggedID: $draggedID,
dragTranslation: $dragTranslation,
canvasWidth: totalWidth,
isDragging: draggedID == merchandise.id,
editMode: editMode,
onDrag: { newX in
updateLiveReorder(for: newX)
},
onEnd: {
commitReorder()
}
)
}
}
.body(width: totalWidth, top: canvasHeight)
.animation(.spring(response: 0.5, dampingFraction: 0.9), worth: displayItems)
}
.body(top: canvasHeight)
}
}
.onAppear {
displayItems = objects
updatePositions(for: &displayItems)
}
.onChange(of: objects) { newItems in
displayItems = newItems
updatePositions(for: &displayItems)
}
}
}
struct CanvasItemView: View {
let merchandise: CanvasItem
@Binding var draggedID: UUID?
@Binding var dragTranslation: CGSize
let canvasWidth: CGFloat
let isDragging: Bool
let editMode: Bool
let onDrag: (CGFloat) -> Void
let onEnd: () -> Void
@State non-public var animatedOffset: CGSize = .zero
non-public let lerpFactor: Double = 0.15
non-public let minTranslationChange: CGFloat = 2.0
var physique: some View {
ZStack {
Rectangle()
.fill(merchandise.coloration.opacity(isDragging ? 0.7 : 0.8))
.body(width: merchandise.measurement.width, top: merchandise.measurement.top)
.overlay(
Textual content("Merchandise (merchandise.id.uuidString.prefix(4))")
.foregroundColor(.white)
)
.place(merchandise.place)
.offset(isDragging ? animatedOffset : .zero)
.scaleEffect(isDragging ? 1.05 : 1.0)
.shadow(coloration: .black.opacity(0.25),
radius: isDragging ? 10 : 0,
x: 0,
y: isDragging ? 6 : 0)
if editMode {
VStack {
Spacer()
Picture(systemName: "line.3.horizontal")
.font(.title2)
.foregroundColor(.black)
.padding(.backside, 8)
}
.body(width: 30)
.allowsHitTesting(false)
}
}
.gesture(
editMode ?
DragGesture()
.onChanged { worth in
if draggedID == nil {
draggedID = merchandise.id
}
let newTranslation = worth.translation
let deltaWidth = abs(newTranslation.width - dragTranslation.width)
if deltaWidth > minTranslationChange {
dragTranslation = newTranslation
let currentX = merchandise.place.x + newTranslation.width
let clampedX = max(merchandise.measurement.width / 2,
min(canvasWidth - merchandise.measurement.width / 2, currentX))
animatedOffset.width += (newTranslation.width - animatedOffset.width) * lerpFactor
animatedOffset.top += (newTranslation.top - animatedOffset.top) * lerpFactor
withAnimation(.easeInOut(length: 0.05)) {
onDrag(clampedX)
}
}
}
.onEnded { worth in
let finalX = merchandise.place.x + dragTranslation.width
let clampedFinalX = max(merchandise.measurement.width / 2,
min(canvasWidth - merchandise.measurement.width / 2, finalX))
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
animatedOffset = .zero
onDrag(clampedFinalX)
onEnd()
}
}
: nil
)
}
}
Any strategies on the way to construction the info / gestures in order that dragging and reordering feels clean?


