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.
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()
}
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:
Use a GestureState
state variable to record the drag location. This automatically resets at end of drag.
Instead of mutating the array of correct paths (the target sequence), define it as constant with let
. Then use a separate state variable to save the array of discovered circles.
You are already using a state variable to record the current position. A didSet
setter observer function can be attached to this variable. The logic for extending the array of discovered circles can then be implemented here.
Use a Canvas
in the background to draw the lines between discovered circles.
If the spacing between circles is known then the position of the middle of a circle can be computed quite easily by dividing up the total width and height of the grid. However, it is important to set the horizontal spacing between GridItem
, otherwise the LazyVGrid
uses its own default spacing.
Use a Namespace
to name the coordinate space. This prevents the name from being mistyped.
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)
}
}