Skip to content

Instantly share code, notes, and snippets.

@saldous
Last active April 30, 2025 23:06
Show Gist options
  • Save saldous/bdf8f2d3e1a386bf58957a1cb4aed6c4 to your computer and use it in GitHub Desktop.
Save saldous/bdf8f2d3e1a386bf58957a1cb4aed6c4 to your computer and use it in GitHub Desktop.
DotGridView
import SwiftUI
struct DotPosition: Equatable, Hashable {
let row: Int
let column: Int
}
struct DotGridView: View {
let rows = 11
let columns = 11
let spacing: CGFloat = 12
let centerRow = 5
let centerColumn = 5
@State private var snappedRow: Int = 100
@State private var snappedColumn: Int = 100
@State private var isDragging: Bool = false
@State private var dragLocation: CGPoint = .zero
@State private var markedPosition: DotPosition? = nil
@State private var dragStartPosition: DotPosition? = nil
var body: some View {
GeometryReader { geometry in
let gridWidth = spacing * CGFloat(columns - 1)
let gridHeight = spacing * CGFloat(rows - 1)
let startX = (geometry.size.width - gridWidth) / 2
let startY = (geometry.size.height - gridHeight) / 2
ZStack {
// Background
Color.black
// Grid of dots
ForEach(0..<rows, id: \.self) { row in
ForEach(0..<columns, id: \.self) { column in
Circle()
.fill(Color.white.opacity(getDotOpacity(row: row, column: column)))
.frame(width: getDotSize(row: row, column: column, startX: startX, startY: startY),
height: getDotSize(row: row, column: column, startX: startX, startY: startY))
.position(
x: startX + CGFloat(column) * spacing,
y: startY + CGFloat(row) * spacing
)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: dragLocation)
}
}
// Center indicator circle
Circle()
.stroke(Color.white.opacity(0.4), lineWidth: 0.5)
.frame(width: 12, height: 12)
.position(
x: startX + CGFloat(centerColumn) * spacing,
y: startY + CGFloat(centerRow) * spacing
)
// Start position mark (only visible during drag)
if let startPos = dragStartPosition {
Circle()
.fill(Color.white.opacity(0.6))
.frame(width: 8, height: 8)
.position(
x: startX + CGFloat(startPos.column) * spacing,
y: startY + CGFloat(startPos.row) * spacing
)
}
// Final position mark
if let mark = markedPosition {
Circle()
.fill(Color.white.opacity(0.6))
.frame(width: 8, height: 8)
.position(
x: startX + CGFloat(mark.column) * spacing,
y: startY + CGFloat(mark.row) * spacing
)
}
// Indicator dot
Circle()
.fill(Color.white.opacity(0.4))
.frame(width: isDragging ? 28 : 16, height: isDragging ? 28 : 16)
.position(
x: startX + CGFloat(snappedColumn) * spacing,
y: startY + CGFloat(snappedRow) * spacing
)
.animation(.spring(response: 0.2, dampingFraction: 0.70, blendDuration: 0), value: isDragging)
.animation(.spring(response: 0.2, dampingFraction: 0.45, blendDuration: 0), value: snappedRow)
.animation(.spring(response: 0.1, dampingFraction: 0.45, blendDuration: 0), value: snappedColumn)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isDragging {
dragStartPosition = DotPosition(row: snappedRow, column: snappedColumn)
}
isDragging = true
// Calculate grid position with magnetic snapping effect
let rawColumn = (value.location.x - startX) / spacing
let rawRow = (value.location.y - startY) / spacing
// Apply magnetic force (stronger snapping)
let snapThreshold: CGFloat = 0.3 // Increase this value for stronger magnetic effect
let columnRemainder = rawColumn.truncatingRemainder(dividingBy: 1)
let rowRemainder = rawRow.truncatingRemainder(dividingBy: 1)
let column = Int(round(rawColumn))
let row = Int(round(rawRow))
let newColumn = max(0, min(columns - 1, column))
let newRow = max(0, min(rows - 1, row))
// Only update position if close enough to grid point
if abs(columnRemainder) < snapThreshold || abs(columnRemainder) > (1 - snapThreshold) ||
abs(rowRemainder) < snapThreshold || abs(rowRemainder) > (1 - snapThreshold) {
if newColumn != snappedColumn || newRow != snappedRow {
let impactMed = UIImpactFeedbackGenerator(style: .rigid)
impactMed.impactOccurred(intensity: 0.9)
withAnimation(.spring(response: 0.3, dampingFraction: 0.45, blendDuration: 0)) {
snappedColumn = newColumn
snappedRow = newRow
}
}
}
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
dragLocation = value.location
}
}
.onEnded { _ in
isDragging = false
dragStartPosition = nil
markedPosition = DotPosition(row: snappedRow, column: snappedColumn)
}
)
.onTapGesture(count: 2) {
markedPosition = nil
}
}
.clipShape(RoundedRectangle(cornerRadius: 20))
}
private func getDotOpacity(row: Int, column: Int) -> Double {
if row == snappedRow && column == snappedColumn {
return 1.0
} else if row == snappedRow || column == snappedColumn {
return 0.8
}
return 0.3
}
private func getDotSize(row: Int, column: Int, startX: CGFloat, startY: CGFloat) -> CGFloat {
if !isDragging {
return 4
}
let dotX = startX + CGFloat(column) * spacing
let dotY = startY + CGFloat(row) * spacing
let distance = sqrt(
pow(dotX - dragLocation.x, 2) +
pow(dotY - dragLocation.y, 2)
)
let baseSize: CGFloat = 4
let maxSizeIncrease: CGFloat = 8
let falloffRate: CGFloat = 35
let sizeIncrease = maxSizeIncrease * pow(2.71828, -distance / falloffRate)
return baseSize + sizeIncrease
}
}
#Preview {
DotGridView()
.frame(width: 150, height: 150)
.preferredColorScheme(.light)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment