Search code examples
swiftuiimmutabilitydraggesture

SwiftUI Dragging an Object in an Array by Gesture


In my model, I have an object which the user can drag around the screen.

struct ScreenElement: Hashable {
    var id = UUID()
    var offset = CGSize.zero
    var lastDragAmount = CGSize.zero
    
    func hash(into myhasher: inout Hasher) {
        myhasher.combine(id)
    }
}

The model holds an array of these objects:

@Observable
class DraggerModel {
    var screenElements: [ScreenElement] = []

func addScreenElement() {
    let newOne: ScreenElement = ScreenElement()
    screenElements.append(newOne)
}

In my View, I place the objects, and then track when the user drags on any one of them:

struct ContentView: View {
    @State private var theModel = DraggerModel()

    var body: some View {
        VStack {
            ForEach(theModel.screenElements, id: \.self) { element in
                Circle()
                    .foregroundColor(.red)
                    .frame(width: 200)
                    .offset(element.offset)
            
                    .gesture(
                        DragGesture()
                            .onChanged { gesture in
                                print("new drag: \(element.id)")
                            }
                    )
            }

This works! When I drag on an object, I get a stream of printed output. But I want to actually move the object, so I add the following function:

// given an object, change its drag amount
func changeDragAmount(element: ScreenElement, theValue: DragGesture.Value) {
    for index in screenElements.indices {
        if screenElements[index].id == element.id {
            screenElements[index].offset = screenElements[index].lastDragAmount + theValue.translation
        }
    }
}

And I change the DragGesture function to call this function:

.gesture(
    DragGesture()
        .onChanged { gesture in
             print("new drag: \(element.id)")
             myModel.changeDragAmount(element: element, theValue: gesture)
                   }
        )

This technique (which works great when there is only one object, and it's not in an array), doesn't work in this case. I get a single Drag notification, and that's it.

I believe this is because in my changeDragAmount function, I am creating a whole new array each time (immutability!), and the new array isn't watching for, or isn't being informed of the subsequent Drag notifications.

What is a better way to do this? That works?


Solution

  • Try this approach using struct ScreenElement: Identifiable, and the modified func changeDragAmount. Note also the ForEach(theModel.screenElements), no id: \.self.

    Note, you will have to adjust the positioning (x,y) of the Circles to meet your requirements.

    struct ScreenElement: Identifiable {  //<--- here
        let id = UUID()
        var offset = CGSize.zero
        var lastDragAmount = CGSize.zero
    }
    
    @Observable
    class DraggerModel {
        // --- for testing
        var screenElements: [ScreenElement] = [ScreenElement(), ScreenElement(), ScreenElement()]
        
        func addScreenElement() {
            let newOne: ScreenElement = ScreenElement()
            screenElements.append(newOne)
        }
        
        func changeDragAmount(element: ScreenElement, theValue: DragGesture.Value) {
            for index in screenElements.indices {
                if screenElements[index].id == element.id {
                    //--- here
                    screenElements[index].offset = CGSize(width: screenElements[index].lastDragAmount.width + theValue.translation.width, height: screenElements[index].lastDragAmount.height + theValue.translation.height)  
                }
            }
        }
    }
    
    struct ContentView: View {
        @State private var theModel = DraggerModel()
        
        var body: some View {
            VStack {
                ForEach(theModel.screenElements) { element in  //<--- here
                    Circle()
                        .foregroundColor(.red)
                        .frame(width: 50)
                        .offset(element.offset)
                    
                        .gesture(
                            DragGesture().onChanged { gesture in
                                theModel.changeDragAmount(element: element, theValue: gesture)
                            }
                        )
                }
            }
        }
    }
    

    EDIT-1:

    An alternative approach to drag an array of Circles around the view is using GeometryReader and a simple var position: CGPoint.

    Note you don't need a @Observable class DraggerModel for that, a simple @State private var screenElements: [ScreenElement] would suffice.

    struct ScreenElement: Identifiable {
        let id = UUID()
        var position: CGPoint
    }
    
    @Observable
    class DraggerModel {
        var screenElements: [ScreenElement] = [
            ScreenElement(position: CGPoint(x: 100, y: 100)),
            ScreenElement(position: CGPoint(x: 200, y: 200)),
            ScreenElement(position: CGPoint(x: 250, y: 250))
        ]
        
        func addScreenElement() {
            let newOne = ScreenElement(position: CGPoint(x: 100, y: 100))
            screenElements.append(newOne)
        }
    }
    
    struct ContentView: View {
        @State private var theModel = DraggerModel()
    
        var body: some View {
            VStack {
                Button("Add new") {
                    theModel.addScreenElement()
                }.buttonStyle(.bordered)
                
                GeometryReader { geometry in
                    ForEach($theModel.screenElements) { $element in
                        Circle()
                            .fill(Color.red)
                            .frame(width: 50, height: 50)
                            .position(element.position)
                            .gesture(
                                DragGesture()
                                    .onChanged { value in
                                        element.position = value.location
                                    }
                            )
                    }
                }
                .background(Color.gray.opacity(0.2))
            }
        }
    }