iosswiftswiftuigesture

Drag Gesture to connect Circles in SwiftUI


I'm trying a POC to implement dragging between circles (similar to the Android pattern lock screen back in the day) but in SwiftUI and I'm learning how to use DragGestures() to achieve this.

I started by creating a simple 3 x 3 grid and setting the 'correct path drag' sequence as (0, 3, 6) this is referring to the index positions of the circles in the grid so that when the user drags the circles in this sequence, the paths would connect(like the next pic), else if the wrong circle was chosen and dragged, the path will not connect. enter image description here enter image description here

I've been able to start a drag from each circle's center, and defined the logic for onChanged and onEnded but I'm unable to get the path to join as of now, some assistance here would be greatly appreciated, this is my own implementation below:

import SwiftUI

struct Line: Hashable {
    var start: CGPoint
    var end: CGPoint
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(start.x)
        hasher.combine(start.y)
        hasher.combine(end.x)
        hasher.combine(end.y)
    }
    
    static func ==(lhs: Line, rhs: Line) -> Bool {
        return lhs.start == rhs.start && lhs.end == rhs.end
    }
}

//collect circle positions
struct CirclePositionsKey: PreferenceKey {
    static var defaultValue: [Int: CGPoint] { [:] }
    
    static func reduce(value: inout [Int : CGPoint], nextValue: () -> [Int : CGPoint]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

struct LineDragGesture: View {
    
    @State private var lines: [Line] = [] 
    @State private var currentDragPoint: CGPoint? = nil
    @State private var selectedCircleIndex: Int? 
    @State private var selectedCirclePosition: CGPoint? 
    
    @State private var correctPaths: [Int] = [0, 3, 6] // Correct path sequence
    @State private var currentPathIndex: Int = 0 
    
    @State private var circlePositions: [Int: CGPoint] = [:] 
    
    @State private var correctCircleIndex = 0
    @State private var correctCirclePosition: CGPoint = .zero
    
    @State private var nextCorrectCircleIndex = 0
    @State private var nextCorrectCirclePosition: CGPoint = .zero
    
    var sortedCoordinates: [(key: Int, value: CGPoint)] {
        circlePositions.sorted(by: { $0.key < $1.key})
    }
    
    let columns: [GridItem] = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    var body: some View {
        ZStack {
            // Draw each completed line
            ForEach(lines, id: \.self) { line in
                Path { path in
                    path.move(to: line.start)
                    path.addLine(to: line.end)
                }
                .stroke(Color.black, lineWidth: 6)
            }
            
            // Draw the line from the chosen circle to the current drag point
            if let currentDragPoint = currentDragPoint, let startingIndex = selectedCircleIndex {
                Path { path in
                    path.move(to: circlePositions[startingIndex] ?? .zero)
                    path.addLine(to: currentDragPoint)
                }
                .stroke(Color.black, lineWidth: 6)
            }
            
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(0..<9) { index in
                    GeometryReader { geo in
                        let frame = geo.frame(in: .named("GridContainer"))
                        let center = CGPoint(x: frame.midX, y: frame.midY)
                        ZStack {
                            Circle()
                                .preference(key: CirclePositionsKey.self, value: [
                                    index : center
                                ])
                                .gesture(
                                    DragGesture()
                                        .onChanged { value in
                                            // If the user starts dragging from the correct circle in the sequence
                                            if selectedCircleIndex == nil {
                                                selectedCircleIndex = index
                                                selectedCirclePosition = sortedCoordinates[index].value
                                            }
                                            
                                            // Calculate the current drag point based on the initial circle's position
                                            if let startingPos = selectedCirclePosition {
                                                currentDragPoint = CGPoint(
                                                    x: startingPos.x + value.translation.width,
                                                    y: startingPos.y + value.translation.height
                                                )
                                            }
                                        }
                                        .onEnded { value in
                                            guard let startingIndex = selectedCircleIndex,
                                                  let draggedPoint = currentDragPoint else { return }
                                            
                                            // Check if the next point in the path is correct and if user is dragging from correct circle
                                            if selectedCircleIndex == correctCircleIndex,
                                               distance(from: draggedPoint, to: nextCorrectCirclePosition) <= 25 {
                                                // Append line if correct next point is reached
                                                lines.append(Line(start: circlePositions[startingIndex]!, end: nextCorrectCirclePosition))
                                                removeCorrectlyGuessed()
                                                setNextCorrectCircle()
                                            }
                                            
                                            // Reset the drag point and the starting circle index
                                            currentDragPoint = nil
                                            selectedCircleIndex = nil
                                            selectedCirclePosition = nil
                                        }
                                )
                        }
                        // .frame(width: 50, height: 50)
                        
                    }
                    .frame(width: 50, height: 50)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
        }
        .onPreferenceChange(CirclePositionsKey.self) { value in
            circlePositions = value
        }
        .coordinateSpace(name: "GridContainer")
        .onAppear {
            let correctPoint = getFirstCorrectCircle()
            if let correctPoint {
                correctCircleIndex = correctPoint.0
                correctCirclePosition = correctPoint.1
            }
            
            let nextCorrectPoint = getNextCircle()
            if let nextCorrectPoint {
                correctCircleIndex = nextCorrectPoint.0
                correctCirclePosition = nextCorrectPoint.1
            }
        }
    }
    
    // Helper function to calculate distance between two points
    private func distance(from: CGPoint, to: CGPoint) -> CGFloat {
        return sqrt(pow(from.x - to.x, 2) + pow(from.y - to.y, 2))
    }
    
    //on load set first correct circle
    private func getFirstCorrectCircle() -> (Int, CGPoint)? {
        guard let pathNum = correctPaths.first else { return nil }
        let position = sortedCoordinates[pathNum].value
        return (pathNum, position)
    }
    
    
    private func getNextCircle() -> (Int, CGPoint)? {
        guard correctPaths.count > 1 else { return nil }
        let pathNum = correctPaths[1]
        let position = sortedCoordinates[pathNum].value
        return (pathNum, position)
        
    }
    
    private func setNextCorrectCircle() {
        guard let nextPosition = getNextCircle() else { return }
        correctCircleIndex = nextPosition.0
        correctCirclePosition = nextPosition.1
    }
    
    private func removeCorrectlyGuessed() {
        guard !correctPaths.isEmpty else { return }
        correctPaths.removeFirst()
    }
}

#Preview {
    LineDragGesture()
}

Solution

  • The answer to Is it possible to detect which View currently falls under the location of a DragGesture? illustrates a technique that can be used for detecting when a shape is under a drag gesture (it was my answer).

    You are using a similar technique. But instead of adding a drag gesture to each circle, try using just one drag gesture for the whole grid.

    Other suggestions:

    struct LineDragGesture: View {
        let circleSize: CGFloat = 50
        let gridSpacing: CGFloat = 20
        let correctPaths: [Int] = [0, 3, 6, 7, 8, 5, 1, 2, 4] // Correct path sequence
        let columns: [GridItem]
    
        @Namespace private var coordinateSpace
        @GestureState private var currentDragPoint = CGPoint.zero
        @State private var discoveredCircles = [Int]()
        @State private var selectedCircleIndex: Int? {
            didSet {
                if let selectedCircleIndex,
                   discoveredCircles.count < correctPaths.count,
                   selectedCircleIndex == correctPaths[discoveredCircles.count] {
                    discoveredCircles.append(selectedCircleIndex)
                }
            }
        }
    
        init() {
            self.columns = [
                GridItem(.flexible(), spacing: gridSpacing),
                GridItem(.flexible(), spacing: gridSpacing),
                GridItem(.flexible(), spacing: gridSpacing)
            ]
        }
    
        var body: some View {
            VStack(spacing: 80) {
                LazyVGrid(columns: columns, spacing: gridSpacing) {
                    ForEach(0..<9) { index in
                        Circle()
                            .frame(width: circleSize, height: circleSize)
                            .background { dragDetector(circleIndex: index ) }
                    }
                }
                .coordinateSpace(name: coordinateSpace)
                .gesture(
                    DragGesture(minimumDistance: 0, coordinateSpace: .named(coordinateSpace))
                        .updating($currentDragPoint) { val, state, trans in
                            state = val.location
                        }
                        .onEnded { val in
                            selectedCircleIndex = nil
                        }
                )
                .background { joinedCircles }
    
                Button("Reset") {
                    discoveredCircles.removeAll()
                }
                .buttonStyle(.bordered)
            }
        }
    
        private func dragDetector(circleIndex: Int) -> some View {
            GeometryReader { proxy in
                let frame = proxy.frame(in: .named(coordinateSpace))
                let isDragLocationInsideFrame = frame.contains(currentDragPoint)
                let isDragLocationInsideCircle = isDragLocationInsideFrame &&
                    Circle().path(in: frame).contains(currentDragPoint)
                Color.clear
                    .onChange(of: isDragLocationInsideCircle) { oldVal, newVal in
                        if currentDragPoint != .zero {
                            selectedCircleIndex = newVal ? circleIndex : nil
                        }
                    }
            }
        }
    
        private var joinedCircles: some View {
            Canvas { ctx, size in
                if !discoveredCircles.isEmpty {
                    var path = Path()
                    for (i, circleIndex) in discoveredCircles.enumerated() {
                        let point = circlePosition(circleIndex: circleIndex, gridSize: size)
                        if i == 0 {
                            path.move(to: point)
                        } else {
                            path.addLine(to: point)
                        }
                    }
                    if currentDragPoint != .zero {
                        path.addLine(to: currentDragPoint)
                    }
                    ctx.stroke(path, with: .color(.secondary), style: .init(lineWidth: 6, lineCap: .round))
                }
            }
        }
    
        private func circlePosition(circleIndex: Int, gridSize: CGSize) -> CGPoint {
            let cellWidth = (gridSize.width + gridSpacing) / 3
            let cellHeight = (gridSize.height + gridSpacing) / 3
            let row = circleIndex / 3
            let col = circleIndex % 3
            let x = (cellWidth * CGFloat(col)) + (cellWidth / 2) - (gridSpacing / 2)
            let y = (cellHeight * CGFloat(row)) + (cellHeight / 2) - (gridSpacing / 2)
            return CGPoint(x: x, y: y)
        }
    }
    

    Animation