HomeiOS DevelopmentSwiftUI horizontal drag-to-reorder view feels jittery – how can I make the...

SwiftUI horizontal drag-to-reorder view feels jittery – how can I make the drag clean?


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
        )
    }
}

like
SwiftUI horizontal drag-to-reorder view feels jittery – how can I make the drag clean?

Any strategies on the way to construction the info / gestures in order that dragging and reordering feels clean?

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments