Andrew
Andrew

Reputation: 11425

How to make a "sticky" CircleView that stays attached to the edges of another views in SwiftUI?

How to make some point make draggable trough set of views edges like on the video:

Point draggable trough set of views

Instead of free drag:

Free draggable points

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)
    }
}

Upvotes: 1

Views: 35

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21675

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:

  • Determine whether the drag point is near the shape using the point-in-frame technique described in the other answer.
  • If the drag point is near the shape, create two paths:
    1. A path representing the outline of the shape.
    2. A path consisting of a line that goes from the middle of the shape, through the drag point and then beyond.
  • Use the Path function lineIntersection(_:eoFill:) to find the intersection of the line with the shape.
  • The last point in the intersection will be a point along the edge of 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?

    @GestureState 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)
                    .updating($dragOffset) { value, state, trans in
                        state = value.translation
                        dragLocation = value.location
                    }
                    .onEnded { value in
                        if let offsetForContactPoint {
                            currentOffset = offsetForContactPoint
                        }
                        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

Animation

Upvotes: 0

Related Questions