How to make some point make draggable trough set of views edges like on the video:
Instead of free drag:
I don't even know where to start since I can't measure the bounds of a view without GeometryReader.
However, GeometryReader isn't suitable in this case because these are different views on separate layers.
Sample View:
struct ContentView: View {
@State var point: CGPoint = .zero
var body: some View {
ZStack {
//foreach Nodes
NodeView()
NodeView()
.offset(x:0, y:100)
//foreach Points
BezierPoint(p1: $point)
}
}
}
struct NodeView : View {
// var nodeViewModel: NodeViewModel
// with exact location in space
var body: some View {
Text("Business")
.multilineTextAlignment(.center)
.foregroundStyle(.red)
.shadow(color: .black, radius: 2 )
.frame(minHeight: 40)
.padding( EdgeInsets(horizontal: 20, vertical: 14) )
.background {
// ANY Shape can be here
RoundedRectangle(cornerRadius: 10)
}
}
}
struct BezierPoint: View {
@Binding var p1: CGPoint
let pointsSize: CGFloat = 15
var body: some View {
GeometryReader { reader in
ControlPointHandle(size: pointsSize)
.offset( CGSize(width: p1.x + reader.size.width/2, height: p1.y + reader.size.height/2) )
.gesture(
DragGesture()
.onChanged { value in
self.p1 = value.location.relativeToCenter(of: reader.size, minus: true)
}
)
}
}
}
private struct ControlPointHandle: View {
let size: CGFloat
var body: some View {
Circle()
.frame(width: size, height: size)
.overlay(
Circle()
.stroke(Color.blue, lineWidth: 2)
)
.offset(x: -size/2, y: -size/2)
}
}
fileprivate extension CGPoint {
func relativeToCenter(of size: CGSize, minus: Bool = false) -> CGPoint {
let a: CGFloat = minus ? -1 : 1
return CGPoint(x: x + a * size.width/2, y: y + a * size.height/2)
}
}
The technique shown in the answer to Is it possible to detect which View currently falls under the location of a DragGesture? can be used to detect when a shape is under the drag point (it was my answer). This uses a GeometryReader
in the background of the shape, which should work even if you have a multi-layer view.
To find the point along the edge of the shape which is closest to the drag point, I would suggest the following approach:
Path
function lineIntersection(_:eoFill:)
to find the intersection of the line with the shape.You were previously wrapping each point with a GeometryReader
. A GeometryReader
is greedy and consumes all the space available, so this was bloating the size of each point to the full size of the parent view. Instead of doing it that way, I would suggest using .onGeometryChange
to measure the position of each point.
Here is the updated example to show it working. It includes a second point, so that the independence of the points can be tested too.
struct ContentView: View {
@State private var dragLocation: CGPoint?
@State private var contactPoint: CGPoint?
@State private var nearestNodeId: Int?
@State private var previousNodeId: Int?
private let proximityMargin: CGFloat = 10
var body: some View {
ZStack {
//foreach Nodes
NodeView()
.background {
contactDetector(nodeId: 1, shape: .rect(cornerRadius: 10))
}
NodeView()
.background {
contactDetector(nodeId: 2, shape: .rect(cornerRadius: 10))
}
.offset(x:0, y:100)
//foreach Points
BezierPoint(dragLocation: $dragLocation, contactPoint: contactPoint)
BezierPoint(dragLocation: $dragLocation, contactPoint: contactPoint)
.offset(x:0, y:100)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(red: 0.99, green: 0.94, blue: 0.76))
.onChange(of: dragLocation) { oldVal, newVal in
if newVal == nil {
contactPoint = nil
nearestNodeId = nil
previousNodeId = nil
}
}
}
private struct ProximityInfo: Equatable {
let isNearby: Bool
let nearestPoint: CGPoint?
}
private func contactDetector<S: Shape>(nodeId: Int, shape: S) -> some View {
GeometryReader { proxy in
let frame = proxy.frame(in: .global)
let proximity = proximity(nodeId: nodeId, frame: frame, shape: shape)
Color.clear
.onChange(of: proximity) { oldVal, newVal in
if newVal.isNearby {
if nearestNodeId != nodeId {
nearestNodeId = nodeId
}
} else if nearestNodeId == nodeId {
previousNodeId = nodeId
nearestNodeId = nil
}
if let nearestPoint = newVal.nearestPoint {
contactPoint = nearestPoint
}
}
}
}
private func proximity<S: Shape>(nodeId: Int, frame: CGRect, shape: S) -> ProximityInfo {
let result: ProximityInfo
if let dragLocation {
let isNearby = frame
.insetBy(dx: -proximityMargin, dy: -proximityMargin)
.contains(dragLocation)
if isNearby || (nearestNodeId == nil && previousNodeId == nodeId) {
let shapePath = shape.path(in: frame)
let joiningLine = Path { path in
path.move(to: CGPoint(x: frame.midX, y: frame.midY))
let dx = dragLocation.x - frame.midX
let dy = dragLocation.y - frame.midY
path.addLine(to: CGPoint(x: dx * 1000, y: dy * 1000))
}
let intersection = joiningLine.lineIntersection(shapePath)
result = ProximityInfo(isNearby: isNearby, nearestPoint: intersection.currentPoint)
} else {
result = ProximityInfo(isNearby: false, nearestPoint: nil)
}
} else {
result = ProximityInfo(isNearby: false, nearestPoint: nil)
}
return result
}
}
struct BezierPoint: View {
@Binding var dragLocation: CGPoint?
let contactPoint: CGPoint?
@State private var dragOffset: CGSize?
@State private var currentOffset = CGSize.zero
@State private var defaultFrame: CGRect?
let pointsSize: CGFloat = 15
private var offsetForContactPoint: CGSize? {
if let contactPoint, let defaultFrame {
CGSize(
width: contactPoint.x - defaultFrame.midX,
height: contactPoint.y - defaultFrame.midY
)
} else {
nil
}
}
private var offset: CGSize {
let result: CGSize
if let dragOffset {
if let offsetForContactPoint {
result = offsetForContactPoint
} else {
result = CGSize(
width: currentOffset.width + dragOffset.width,
height: currentOffset.height + dragOffset.height
)
}
} else {
result = currentOffset
}
return result
}
var body: some View {
Circle()
.fill(.blue)
.stroke(.primary, lineWidth: 2)
.frame(width: pointsSize, height: pointsSize)
.offset(offset)
.gesture(
DragGesture(minimumDistance: 1, coordinateSpace: .global)
.onChanged { value in
dragOffset = value.translation
dragLocation = value.location
}
.onEnded { value in
if let offsetForContactPoint {
currentOffset = offsetForContactPoint
}
dragOffset = nil
dragLocation = nil
}
)
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { frame in
defaultFrame = frame
}
}
}
// + NodeView: as before
// - ControlPointHandle, CGPoint extension: not needed