Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Forked from rygrob/BezierGridView.swift
Created February 6, 2025 08:37
Show Gist options
  • Save Codelaby/aeb771c84eeb3f5fd79d99302bb96814 to your computer and use it in GitHub Desktop.
Save Codelaby/aeb771c84eeb3f5fd79d99302bb96814 to your computer and use it in GitHub Desktop.
Bézier Grid View
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)
}
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