Created
February 3, 2025 13:40
-
-
Save achernoprudov/adf33ba81df74fd685a8d0cbc2861958 to your computer and use it in GitHub Desktop.
Playground with draggable and interactable bubbles view
This file contains 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 | |
import PlaygroundSupport | |
struct ContainerView: View { | |
var body: some View { | |
BubbleContainer() | |
} | |
} | |
struct BubbleContainer: View { | |
@State | |
var positions: BubblePositions = .init(positions: [ | |
.init(x: 100, y: 100), | |
.init(x: 200, y: 200), | |
.init(x: 300, y: 100), | |
.init(x: 400, y: 200), | |
.init(x: 500, y: 100), | |
]) | |
var body: some View { | |
ZStack { | |
ForEach(positions.positions.indices, id: \.self) { index in | |
let location = Binding<CGPoint> { | |
positions.positions[index] | |
} set: { newValue in | |
positions.positions[index] = newValue | |
withAnimation(.spring) { | |
positions.rearrange(except: index, pinDragged: false) | |
} | |
} | |
BubbleView( | |
location: location.projectedValue, | |
label: index.description | |
) { | |
withAnimation(.spring) { | |
positions.rearrange(except: index, pinDragged: true) | |
} | |
} | |
} | |
} | |
.frame(width: 700, height: 300, alignment: .center) | |
} | |
} | |
struct BubblePositions: Sendable { | |
var initialPositions: [CGPoint] | |
var positions: [CGPoint] | |
init(positions: [CGPoint]) { | |
self.initialPositions = positions | |
self.positions = positions | |
} | |
// resets positions to the `initialPositions` | |
// starts from the position at `priorIndex` - finds the closest position to it | |
// then goes to the other positions in the list and finds closest positions(unassigned to other items) and puts it there | |
mutating func rearrange(except priorIndex: Int, pinDragged: Bool) { | |
guard !positions.isEmpty, !initialPositions.isEmpty else { return } | |
var usedIndices = Set<Int>() | |
var newPositions = Array(repeating: CGPoint.zero, count: positions.count) | |
func findClosest(from startIndex: Int) -> Int { | |
let currentPosition = positions[startIndex] | |
var minDistance = CGFloat.greatestFiniteMagnitude | |
var closestIndex: Int = 0 | |
for index in 0..<initialPositions.count { | |
if usedIndices.contains(index) { | |
continue | |
} | |
let distance = initialPositions[index].distance(to: currentPosition) | |
if distance < minDistance { | |
minDistance = distance | |
closestIndex = index | |
} | |
} | |
return closestIndex | |
} | |
// find for initial but keep original position | |
let initialClosest = findClosest(from: priorIndex) | |
usedIndices.insert(initialClosest) | |
newPositions[priorIndex] = pinDragged | |
? initialPositions[initialClosest] | |
: positions[priorIndex] | |
// find for others | |
for index in 0..<initialPositions.count { | |
if index == priorIndex { | |
continue | |
} | |
let closestIndex = findClosest(from: index) | |
usedIndices.insert(closestIndex) | |
newPositions[index] = initialPositions[closestIndex] | |
} | |
positions = newPositions | |
} | |
} | |
extension CGPoint { | |
func distance(to point: CGPoint) -> CGFloat { | |
let dx = self.x - point.x | |
let dy = self.y - point.y | |
return sqrt(dx * dx + dy * dy) | |
} | |
} | |
struct BubbleView: View { | |
@Binding | |
var location: CGPoint | |
let label: String | |
let onDragEnded: () -> Void | |
var simpleDrag: some Gesture { | |
DragGesture() | |
.onChanged { value in | |
self.location = value.location | |
} | |
.onEnded { value in | |
onDragEnded() | |
} | |
} | |
var body: some View { | |
Circle() | |
.foregroundColor(.pink) | |
.overlay(content: { | |
Text(label) | |
}) | |
.frame(width: 100, height: 100) | |
.position(location) | |
.gesture(simpleDrag) | |
} | |
} | |
PlaygroundPage.current.setLiveView(ContainerView()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example
Screen.Recording.2025-02-03.at.14.38.12.mov