-
-
Save Codelaby/aeb771c84eeb3f5fd79d99302bb96814 to your computer and use it in GitHub Desktop.
Bézier Grid View
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct BezierGridView: View { | |
@State private var vertices: [[BezierVertex]] | |
let rows: Int | |
let cols: Int | |
init(rows: Int, cols: Int) { | |
self.rows = rows | |
self.cols = cols | |
_vertices = State(initialValue: Self.createGrid(rows: rows, cols: cols)) | |
} | |
var body: some View { | |
GeometryReader { proxy in | |
ZStack { | |
Canvas { context, size in | |
for col in 0..<cols { | |
for row in 0..<rows { | |
// Horizontal | |
if col < cols - 1 { | |
let curr = vertices[row][col] | |
let next = vertices[row][col + 1] | |
let path = Path { path in | |
path.move(to: curr.position.fromUnit(to: size)) | |
path.addCurve(to: next.position.fromUnit(to: size), | |
control1: curr.trailingControlPoint.fromUnit(to: size), | |
control2: next.leadingControlPoint.fromUnit(to: size)) | |
} | |
context.stroke(path, with: .color(.black), style: .init(lineWidth: 1)) | |
} | |
// Vertical | |
if row < rows - 1 { | |
let curr = vertices[row][col] | |
let next = vertices[row + 1][col] | |
let path = Path { path in | |
path.move(to: curr.position.fromUnit(to: size)) | |
path.addCurve(to: next.position.fromUnit(to: size), | |
control1: curr.bottomControlPoint.fromUnit(to: size), | |
control2: next.topControlPoint.fromUnit(to: size)) | |
} | |
context.stroke(path, with: .color(.black), style: .init(lineWidth: 1)) | |
} | |
// Lines to control points | |
let vertex = vertices[row][col] | |
let path = Path { path in | |
path.move(to: vertex.position.fromUnit(to: size)) | |
path.addLine(to: vertex.leadingControlPoint.fromUnit(to: size)) | |
path.move(to: vertex.position.fromUnit(to: size)) | |
path.addLine(to: vertex.topControlPoint.fromUnit(to: size)) | |
path.move(to: vertex.position.fromUnit(to: size)) | |
path.addLine(to: vertex.bottomControlPoint.fromUnit(to: size)) | |
path.move(to: vertex.position.fromUnit(to: size)) | |
path.addLine(to: vertex.trailingControlPoint.fromUnit(to: size)) | |
} | |
context.stroke(path, with: .color(.black), style: .init(lineWidth: 1, dash: [8, 4])) | |
} | |
} | |
} | |
ForEach(0..<rows, id: \.self) { row in | |
ForEach(0..<cols, id: \.self) { col in | |
DraggableBezierVertex($vertices[row][col], in: proxy.size) | |
} | |
} | |
} | |
} | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.background(.white) | |
} | |
static func createGrid(rows: Int, cols: Int) -> [[BezierVertex]] { | |
(0..<rows).map { row in | |
(0..<cols).map { col in | |
// All positions normalized. | |
let spacingX = 1 / (3 * CGFloat(cols - 1)) | |
let spacingY = 1 / (3 * CGFloat(rows - 1)) | |
let position = CGPoint(x: CGFloat(col) / CGFloat(cols - 1), y: CGFloat(row) / CGFloat(rows - 1)) | |
return BezierVertex( | |
position: position, | |
leadingControlPoint: CGPoint(x: position.x - spacingX, y: position.y), | |
topControlPoint: CGPoint(x: position.x, y: position.y - spacingY), | |
trailingControlPoint: CGPoint(x: position.x + spacingX, y: position.y), | |
bottomControlPoint: CGPoint(x: position.x, y: position.y + spacingY) | |
) | |
} | |
} | |
} | |
} | |
struct DraggableBezierVertex: View { | |
@Binding var vertex: BezierVertex | |
let parentSize: CGSize | |
// | |
private let circleSize: CGFloat = 12 | |
@State private var translation: CGSize = .zero | |
init(_ vertex: Binding<BezierVertex>, in parentSize: CGSize) { | |
self._vertex = vertex | |
self.parentSize = parentSize | |
} | |
var body: some View { | |
ZStack { | |
// Control points | |
ControlPointView(vertex.leadingControlPoint, in: parentSize) { dx, dy in | |
vertex.moveControlPoint(.leading, dx: dx, dy: dy) | |
} | |
ControlPointView(vertex.topControlPoint, in: parentSize) { dx, dy in | |
vertex.moveControlPoint(.top, dx: dx, dy: dy) | |
} | |
ControlPointView(vertex.trailingControlPoint, in: parentSize) { dx, dy in | |
vertex.moveControlPoint(.trailing, dx: dx, dy: dy) | |
} | |
ControlPointView(vertex.bottomControlPoint, in: parentSize) { dx, dy in | |
vertex.moveControlPoint(.bottom, dx: dx, dy: dy) | |
} | |
// Main vertex | |
Circle() | |
.fill(Color.blue) | |
.frame(width: circleSize, height: circleSize) | |
.position(vertex.position.fromUnit(to: parentSize)) | |
.gesture( | |
DragGesture(minimumDistance: 0) | |
.onChanged { value in | |
// Normalize | |
let dx = (value.translation.width - translation.width) / parentSize.width | |
let dy = (value.translation.height - translation.height) / parentSize.height | |
vertex.moveVertex(dx: dx, dy: dy) | |
translation = value.translation | |
} | |
.onEnded { value in | |
translation = .zero | |
} | |
) | |
} | |
} | |
} | |
struct ControlPointView: View { | |
var position: CGPoint | |
let parentSize: CGSize | |
let onDragged: (CGFloat, CGFloat) -> Void | |
// | |
private let circleSize: CGFloat = 6 | |
@State private var translation: CGSize = .zero | |
init(_ position: CGPoint, in parentSize: CGSize, onDragged: @escaping (CGFloat, CGFloat) -> Void) { | |
self.position = position | |
self.parentSize = parentSize | |
self.onDragged = onDragged | |
} | |
var body: some View { | |
Circle() | |
.fill(Color.red) | |
.frame(width: circleSize, height: circleSize) | |
.position(position.fromUnit(to: parentSize)) | |
.gesture( | |
DragGesture(minimumDistance: 0) | |
.onChanged { value in | |
// Normalize | |
let dx = (value.translation.width - translation.width) / parentSize.width | |
let dy = (value.translation.height - translation.height) / parentSize.height | |
onDragged(dx, dy) | |
translation = value.translation | |
} | |
.onEnded { value in | |
translation = .zero | |
} | |
) | |
} | |
} | |
// | |
struct BezierVertex { | |
enum ControlPoint { | |
case leading, top, trailing, bottom | |
} | |
var position: CGPoint | |
var leadingControlPoint: CGPoint | |
var topControlPoint: CGPoint | |
var trailingControlPoint: CGPoint | |
var bottomControlPoint: CGPoint | |
mutating func moveVertex(dx: CGFloat, dy: CGFloat) { | |
let translation: CGPoint = .init(x: dx, y: dy) | |
position += translation | |
leadingControlPoint += translation | |
topControlPoint += translation | |
trailingControlPoint += translation | |
bottomControlPoint += translation | |
} | |
mutating func moveControlPoint(_ controlPoint: ControlPoint, dx: CGFloat, dy: CGFloat) { | |
let translation: CGPoint = .init(x: dx, y: dy) | |
switch controlPoint { | |
case .leading: | |
let pos = leadingControlPoint + translation | |
let r = position.distance(to: trailingControlPoint) | |
let theta = position.angle(to: pos) + .pi | |
leadingControlPoint = pos | |
trailingControlPoint = .init(r: r, theta: theta, relativeTo: position) | |
case .top: | |
let pos = topControlPoint + translation | |
let r = position.distance(to: bottomControlPoint) | |
let theta = position.angle(to: pos) + .pi | |
topControlPoint = pos | |
bottomControlPoint = .init(r: r, theta: theta, relativeTo: position) | |
case .trailing: | |
let pos = trailingControlPoint + translation | |
let r: CGFloat = position.distance(to: leadingControlPoint) | |
let theta = position.angle(to: pos) + .pi | |
trailingControlPoint = pos | |
leadingControlPoint = .init(r: r, theta: theta, relativeTo: position) | |
case .bottom: | |
let pos = bottomControlPoint + translation | |
let r = position.distance(to: topControlPoint) | |
let theta = position.angle(to: pos) + .pi | |
bottomControlPoint = pos | |
topControlPoint = .init(r: r, theta: theta, relativeTo: position) | |
} | |
} | |
} | |
#Preview { | |
BezierGridView(rows: 4, cols: 5) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import CoreGraphics | |
extension CGPoint { | |
/// Initialize with polar coordinates. | |
init(r: CGFloat, theta: CGFloat, relativeTo origin: CGPoint = .zero) { | |
self.init(x: origin.x + r * cos(theta), y: origin.y - r * sin(theta)) | |
} | |
/// Get the distance to a point. | |
func distance(to point: CGPoint) -> CGFloat { | |
let dx = self.x - point.x | |
let dy = self.y - point.y | |
return sqrt(dx * dx + dy * dy) | |
} | |
/// Angle to positive x-axis. | |
func angle(to point: CGPoint) -> CGFloat { | |
let dx = point.x - self.x | |
let dy = point.y - self.y | |
return atan2(-dy, dx) | |
} | |
/// Scale a unit point to a point in `size`. | |
func fromUnit(to size: CGSize) -> CGPoint { | |
return .init(x: self.x * size.width, y: self.y * size.height) | |
} | |
/// Scale a point in `size` to a unit point. | |
func toUnit(from size: CGSize) -> CGPoint { | |
return .init(x: self.x / size.width, y: self.y / size.height) | |
} | |
// MARK: - Addition & Subtraction | |
/// Add | |
static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint { | |
return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) | |
} | |
/// Subtract | |
static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint { | |
return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) | |
} | |
/// Add | |
static func +=(lhs: inout CGPoint, rhs: CGPoint) { | |
lhs = lhs + rhs | |
} | |
/// Subtract | |
static func -=(lhs: inout CGPoint, rhs: CGPoint) { | |
lhs = lhs - rhs | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment