• Dec 19, 2024

Mastering DragGesture in SwiftUI

The DragGesture in SwiftUI, is a gesture recognizer which tracks the movement of a touch across the screen. Which capturing the movement of drag it also captures details about starting location, current location, and distance traveled. This gesture is commonly used to implement features like swiping, dragging views, or tracking movement for interactive UI elements.

The DragGesture in SwiftUI, is a gesture recognizer which tracks the movement of a touch across the screen. Which capturing the movement of drag it also captures details about starting location, current location, and distance traveled. This gesture is commonly used to implement features like swiping, dragging views, or tracking movement for interactive UI elements.

Let’s look at an example to understand this gesture. We will start with a simple view which has a circle in the center with an overlay Text view.

struct DragGestureExample: View {
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .preferredColorScheme(.dark)
    }
}

We will attach DragGesture to this view using gesture modifier.

struct DragGestureExample: View {
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .gesture(DragGesture())
            .preferredColorScheme(.dark)
    }
}

When the view is dragged, we want to update the offset value of the circle view to match the drag location. For that, we need to store the offset value and add an offset modifier to the circle view first.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .offset(offset)
            .gesture(DragGesture())
            .preferredColorScheme(.dark)
    }
}

Next, let’s add the onChange modifier to DragGesture and capture the translation of the gesture to update the offset value for the circle view. Let’s also use withAnimation block to animate the view to new location.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .offset(offset)
            .gesture(
                DragGesture().onChanged(
                    { gestureValue in
                        withAnimation {
                            offset = gestureValue.translation
                        }
                    })
            )
            .preferredColorScheme(.dark)
    }
}

DragGesture offers another modifier called onEnded, which helps us in defining the final state when a gesture ends.  

Let’s use this modifier to put circle view back to its original place.


struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .offset(offset)
            .gesture(
                DragGesture().onChanged(
                    { gestureValue in
                        withAnimation {
                            offset = gestureValue.translation
                        }
                    })
                .onEnded({ _ in
                    withAnimation {
                        offset = .zero
                    }

                })
            )
            .preferredColorScheme(.dark)
    }
}

We can calculate the drag velocity from the closure DragGesture.value parameter to determine the speed of the gesture to create a throwing effect in the direction of the drag.We will also utilize the predicatedEndLocation value provided by the DragGesture API for this effect.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    
    var body: some View {
        Circle()
            .fill(.orange)
            .frame(width: 100, height: 100)
            .overlay {
                Text("DevTechie")
                    .bold()
                    .foregroundStyle(.white)
            }
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged({ gestureValue in
                        isDragging = true
                        withAnimation {
                            offset = gestureValue.translation
                        }
                    })
                    .onEnded({ gestureValue in
                        isDragging = false
                        let velocity = gestureValue.velocity
                        let predictedEndLocation = CGSize(
                            width: offset.width + 0.1 * velocity.width,
                            height: offset.height + 0.1 * velocity.height
                        )
                        
                        withAnimation(.spring(response: 0.6, dampingFraction: 0.3, blendDuration: 0)) {
                            if abs(velocity.width) > 100 || abs(velocity.height) > 100 {
                                offset = predictedEndLocation
                                
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                                    withAnimation(.spring(response: 0.6, dampingFraction: 0.7, blendDuration: 0)) {
                                        offset = .zero
                                    }
                                }
                            } else {
                                offset = .zero
                            }
                        }
                    })
            )
            .preferredColorScheme(.dark)
    }
}

Now that we have a grasp of DragGesture and its states, let’s build a complex user interface to demonstrate the drag-and-drop functionality. Specifically, we want to create a scenario where the center view is dropped onto four predefined hotspots. If it remains on top of one of these hotspots, it should stay there. However, if it is dropped onto another hotspot or blank space, it should revert to its original position.

Start by creating State properties.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    @State private var activeDropPoint: Int? = nil
    @State private var hasAnimated = false

We will create dropPoints locations next

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    @State private var activeDropPoint: Int? = nil
    @State private var hasAnimated = false
    
    let dropPoints = [
        CGPoint(x: -150, y: -250),
        CGPoint(x: 150, y: -250),
        CGPoint(x: -150, y: 150),
        CGPoint(x: 150, y: 150)     
    ]

Let’s define a function to determine if the dragged circle falls within the designated drop zone.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    @State private var activeDropPoint: Int? = nil
    @State private var hasAnimated = false
    
    let dropPoints = [
        CGPoint(x: -150, y: -250),
        CGPoint(x: 150, y: -250),
        CGPoint(x: -150, y: 150),
        CGPoint(x: 150, y: 150)
    ]
    
    func isWithinDropArea(currentPosition: CGPoint, dropPoint: CGPoint) -> Bool {
        let distance = sqrt(pow(currentPosition.x - dropPoint.x, 2) +
                           pow(currentPosition.y - dropPoint.y, 2))
        return distance < 50
    }

We’ll create a ZStack to position all circles at the center of the screen and animate them once the view appears.

struct DragGestureExample: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    @State private var activeDropPoint: Int? = nil
    @State private var hasAnimated = false
    
    let dropPoints = [
        CGPoint(x: -150, y: -250),
        CGPoint(x: 150, y: -250),
        CGPoint(x: -150, y: 150),
        CGPoint(x: 150, y: 150)
    ]
    
    func isWithinDropArea(currentPosition: CGPoint, dropPoint: CGPoint) -> Bool {
        let distance = sqrt(pow(currentPosition.x - dropPoint.x, 2) +
                           pow(currentPosition.y - dropPoint.y, 2))
        return distance < 50
    }
    
    var body: some View {
        ZStack {
            ForEach(0..<4) { index in
                Circle()
                    .fill(activeDropPoint == index ? .green : .red)
                    .frame(width: 100, height: 100)
                    .position(
                        x: UIScreen.main.bounds.width/2 + (hasAnimated ? dropPoints[index].x : 0),
                        y: UIScreen.main.bounds.height/2 + (hasAnimated ? dropPoints[index].y : 0)
                    )
            }
            
            Circle()
                .fill(.orange)
                .frame(width: 100, height: 100)
                .overlay {
                    Text("DevTechie")
                        .bold()
                        .foregroundStyle(.white)
                }
                .offset(offset)
                .gesture(
                    DragGesture()
                        .onChanged({ gestureValue in
                            isDragging = true
                            withAnimation {
                                offset = gestureValue.translation
                            }
                        })
                        .onEnded({ gestureValue in
                            isDragging = false
                            let currentPosition = CGPoint(
                                x: gestureValue.translation.width,
                                y: gestureValue.translation.height
                            )
                            
                            var droppedOnPoint = false
                            for (index, dropPoint) in dropPoints.enumerated() {
                                if isWithinDropArea(currentPosition: currentPosition,
                                                   dropPoint: dropPoint) {
                                    withAnimation(.spring()) {
                                        offset = CGSize(width: dropPoint.x,
                                                      height: dropPoint.y)
                                        activeDropPoint = index
                                    }
                                    droppedOnPoint = true
                                    break
                                }
                            }
                            
                            if !droppedOnPoint {
                                withAnimation(.spring()) {
                                    offset = .zero
                                    activeDropPoint = nil
                                }
                            }
                        })
                )
        }
        .preferredColorScheme(.dark)
        .onAppear {
            withAnimation(.easeOut(duration: 1.0)) {
                hasAnimated = true
            }
        }
    }
}