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

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

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

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

Share Improve this question edited Mar 3 at 1:04 Andrew asked Mar 3 at 0:51 AndrewAndrew 11.6k9 gold badges84 silver badges118 bronze badges 0
Add a comment  | 

1 Answer 1

Reset to default 3 +100

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.

import SwiftUI

struct ContentView: View {
    @State private var dragLocation: CGPoint?
    @State private var contactPoint: CGPoint?
    @State private var nearestNodeId: Int?
    @State private var previousNodeId: Int?
    
    var body: some View {
        ZStack {
            ForEach(1...5, id: \.self) { i in
                NodeView()
                    .background {
                        SplineContactDetector(nodeId: i,
                                              shape:  .rect(cornerRadius: 10),
                                              dragLocation: $dragLocation,
                                              contactPoint: $contactPoint,
                                              nearestNodeId: $nearestNodeId,
                                              previousNodeId: $previousNodeId
                        )
                    }
                    .offset(x:0, y: CGFloat(i) * 100 - 400)
            }
            
            //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) { _, newVal in
            if newVal == nil {
                contactPoint = nil
                nearestNodeId = nil
                previousNodeId = nil
            }
        }
    }
}

/// ///////////////////
/// Basic Views
/// /////////////////

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

struct NodeView : View {
    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)
            }
    }
}

/// ///////////////////
/// Helpers
/// /////////////////

struct SplineContactDetector<S: Shape> : View {
    let nodeId: Int
    let shape: S
    
    @Binding var dragLocation: CGPoint?
    @Binding var contactPoint: CGPoint?
    @Binding var nearestNodeId: Int?
    @Binding var previousNodeId: Int?
    
    private let proximityMargin: CGFloat = 10
    
    var body: 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) { _, 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(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
    }
    
    private struct ProximityInfo: Equatable {
        let isNearby: Bool
        let nearestPoint: CGPoint?
    }
}

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745113363a4611978.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信