SwiftUI实现可沿视图边缘拖拽的吸附式圆形控制点
实现沿视图边缘拖拽的控制点解决方案
要实现控制点沿多个视图边缘拖拽的效果,核心是获取所有视图的全局边界信息,并在拖拽时将控制点约束到最近的视图边缘上。以下是针对你场景的具体解决方案:
关键思路
- 用
PreferenceKey收集所有NodeView的全局位置和尺寸,避免GeometryReader在分层视图中带来的布局干扰。 - 拖拽时,计算当前触摸点到各视图边缘的最近投影点,将控制点吸附到该点,实现沿边缘移动的效果。
修改后的完整代码
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() }
代码解释
- NodeFrameKey:自定义PreferenceKey,用于收集所有
NodeView的全局坐标系下的frame信息,避免在分层视图中嵌套GeometryReader导致的布局问题。 - 投影点计算:
projectPointOntoLine函数计算触摸点到视图边缘线段的投影点,确保控制点始终落在视图边缘上,而不是自由移动。 - 坐标转换:将全局坐标系下的投影点转换为ZStack的局部坐标系,保证控制点位置正确。
内容的提问来源于stack exchange,提问作者Andrew
相关产品推荐
相关产品推荐

