You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI实现可沿视图边缘拖拽的吸附式圆形控制点

实现沿视图边缘拖拽的控制点解决方案

要实现控制点沿多个视图边缘拖拽的效果,核心是获取所有视图的全局边界信息,并在拖拽时将控制点约束到最近的视图边缘上。以下是针对你场景的具体解决方案:

关键思路

  1. 用PreferenceKey收集所有NodeView的全局位置和尺寸,避免GeometryReader在分层视图中带来的布局干扰。
  2. 拖拽时,计算当前触摸点到各视图边缘的最近投影点,将控制点吸附到该点,实现沿边缘移动的效果。

修改后的完整代码

import SwiftUI

struct ContentView: View {
    @State var point: CGPoint = .zero
    @State private var nodeFrames: [CGRect] = []
    
    var body: some View {
        ZStack {
            // 遍历所有节点
            NodeView()
            NodeView()
                .offset(x: 0, y: 100)
            
            // 拖拽控制点
            BezierPoint(p1: $point, nodeFrames: nodeFrames)
        }
        .onPreferenceChange(NodeFrameKey.self) { newFrames in
            // 收集所有NodeView的全局frame
            nodeFrames = newFrames
        }
    }
}

// 用于收集NodeView全局frame的PreferenceKey
struct NodeFrameKey: PreferenceKey {
    static var defaultValue: [CGRect] = []
    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value.append(contentsOf: nextValue())
    }
}

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 {
                RoundedRectangle(cornerRadius: 10)
                    .overlay(
                        // 用GeometryReader获取当前视图的全局frame,并通过Preference传递
                        GeometryReader { proxy in
                            Color.clear
                                .preference(key: NodeFrameKey.self, value: [proxy.frame(in: .global)])
                        }
                    )
            }
    }
}

struct BezierPoint: View {
    @Binding var p1: CGPoint
    let nodeFrames: [CGRect]
    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
                            let globalLocation = value.location
                            // 找到当前触摸点最近的视图边缘点
                            if let nearestEdgePoint = findNearestEdgePoint(to: globalLocation, from: nodeFrames) {
                                // 转换为ZStack内的局部坐标
                                let zStackGlobalFrame = reader.frame(in: .global)
                                let localPoint = CGPoint(
                                    x: nearestEdgePoint.x - zStackGlobalFrame.minX,
                                    y: nearestEdgePoint.y - zStackGlobalFrame.minY
                                )
                                // 调整控制点的相对位置
                                p1 = localPoint.relativeToCenter(of: reader.size, minus: true)
                            }
                        }
                )
        }
    }
    
    // 找到当前点最近的视图边缘投影点
    private func findNearestEdgePoint(to point: CGPoint, from frames: [CGRect]) -> CGPoint? {
        guard !frames.isEmpty else { return nil }
        
        var closestPoint: CGPoint?
        var minDistance: CGFloat = .infinity
        
        for frame in frames {
            // 计算当前点到视图四条边的投影点
            let topEdge = (start: CGPoint(x: frame.minX, y: frame.minY), end: CGPoint(x: frame.maxX, y: frame.minY))
            let bottomEdge = (start: CGPoint(x: frame.minX, y: frame.maxY), end: CGPoint(x: frame.maxX, y: frame.maxY))
            let leftEdge = (start: CGPoint(x: frame.minX, y: frame.minY), end: CGPoint(x: frame.minX, y: frame.maxY))
            let rightEdge = (start: CGPoint(x: frame.maxX, y: frame.minY), end: CGPoint(x: frame.maxX, y: frame.maxY))
            
            let edges = [topEdge, bottomEdge, leftEdge, rightEdge]
            
            for edge in edges {
                let projectedPoint = projectPointOntoLine(point, lineStart: edge.start, lineEnd: edge.end)
                let distance = hypot(point.x - projectedPoint.x, point.y - projectedPoint.y)
                
                if distance < minDistance {
                    minDistance = distance
                    closestPoint = projectedPoint
                }
            }
        }
        
        return closestPoint
    }
    
    // 计算点到线段的投影点(核心算法)
    private func projectPointOntoLine(_ point: CGPoint, lineStart: CGPoint, lineEnd: CGPoint) -> CGPoint {
        let lineVector = CGVector(dx: lineEnd.x - lineStart.x, dy: lineEnd.y - lineStart.y)
        let pointVector = CGVector(dx: point.x - lineStart.x, dy: point.y - lineStart.y)
        
        let dotProduct = pointVector.dx * lineVector.dx + pointVector.dy * lineVector.dy
        let lineLengthSquared = lineVector.dx * lineVector.dx + lineVector.dy * lineVector.dy
        
        guard lineLengthSquared > 0 else { return lineStart }
        
        // 限制t在0-1之间,确保投影点在线段上
        let t = max(0, min(1, dotProduct / lineLengthSquared))
        return CGPoint(x: lineStart.x + t * lineVector.dx, y: lineStart.y + t * lineVector.dy)
    }
}

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

#Preview {
    ContentView()
}

代码解释

  1. NodeFrameKey:自定义PreferenceKey,用于收集所有NodeView的全局坐标系下的frame信息,避免在分层视图中嵌套GeometryReader导致的布局问题。
  2. 投影点计算:projectPointOntoLine函数计算触摸点到视图边缘线段的投影点,确保控制点始终落在视图边缘上,而不是自由移动。
  3. 坐标转换:将全局坐标系下的投影点转换为ZStack的局部坐标系,保证控制点位置正确。

内容的提问来源于stack exchange,提问作者Andrew

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 01:50:57