-
-
Save Codelaby/34450a7379681b14ff6fbc5b2aaa95fe to your computer and use it in GitHub Desktop.
DotGridView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | |
VStack { | |
Text("Pray for me to make\n 1-billion dollar App") | |
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