Making a Bézier Curve Editor in SwiftUI

Making a Bézier Curve Editor in SwiftUI

Making a Bezier Curve Editor in SwiftUI

Why?

Because I wanted to have a way to visually pick values for animation timing curves and while there are plenty of tools around for doing so (like this one ), I thought it would be fun to make one in SwiftUI.


Making a handle

This is a rather simple view, a circle, serving as our handle for moving around the control points of our cubic Bézier curve. Nothing really special here, except for the offset, which is adjusted so the center of the circle will be originated at the top-left corner.

1*oGZ9xtulpUCLFI9-DWId8w.png It may look like a slice of a grayscale lemon but it’s all there, I promise

struct ControlPointHandle: View {
    private let size: CGFloat = 20
    var body: some View {
        Circle()
            .frame(width: size, height: size)
            .overlay(
                Circle()
                    .stroke(Color.white, lineWidth: 2)
            )
            .offset(x: -size/2, y: -size/2)
    }
}

A simple circular SwiftUI view for our handles


With a little help from my friends…

Now, before moving on it would be really useful if we defined a few basic operators for common vector math (elementwise addition, multiplication, division, and scaling).

The reason I’m mixing CGPoint & CGSize is due to the fact that DragGestureRecognizer in SwiftUI reports back the translation as a CGSize and not as a CGPoint as UIKit’s UIPanGestureRecognizer does.

import Foundation

// Just for clarity of intention
typealias AbsolutePoint = CGPoint
typealias RelativePoint = CGPoint

func * (lhs: CGSize, rhs: CGSize) -> CGSize {
    .init(width: lhs.width * rhs.width, height: lhs.height * rhs.height)
}

func * (lhs: CGPoint, rhs: CGSize) -> CGPoint {
    .init(x: lhs.x * rhs.width, y: lhs.y * rhs.height)
}

func - (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
    .init(x: lhs.x - rhs, y: lhs.y - rhs)
}

func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

func + (lhs: CGSize, rhs: CGSize) -> CGSize {
    .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}

func / (lhs: CGSize, rhs: CGSize) -> CGSize {
    .init(width: lhs.width / rhs.width, height: lhs.height / rhs.height)
}

extension CGSize {
    var toPoint: CGPoint { .init(x: width, y: height) }
    var half: CGSize { .init(width: width/2, height: height/2) }
}

Convenient operators for making things easier later on


Moving around

Now that we have our handle, we need a means of moving it. Instead of attaching a gesture recognizer in the view directly, when we later compose our curve editor, we are going to generalize and implement a custom ViewModifier that we can apply to any view.

struct Draggable: ViewModifier {
    @State var isDragging: Bool = false

    @State var offset: CGSize = .zero
    @State var dragOffset: CGSize = .zero

    var onChanged: ((CGSize) -> Void)?
    var onEnded: ((CGSize) -> Void)?

    func body(content: Content) -> some View {
        let drag = DragGesture()
        .onChanged { (value) in
            self.offset     = self.dragOffset + value.translation
            self.isDragging = true
            self.onChanged?(self.offset)
        }.onEnded { (value) in
            self.isDragging = false
            self.offset     = self.dragOffset + value.translation
            self.dragOffset = self.offset
            self.onEnded?(self.offset)
        }
        return content.offset(offset).gesture(drag)
    }
}

extension View {
    func draggable(onChanged: ((CGSize) -> Void)? = nil, onEnded: ((CGSize) -> Void)? = nil) -> some View {
        return self.modifier(Draggable(onChanged: onChanged, onEnded: onEnded))
    }
}

A Draggable custom modifier implementation


A simple curve

For our curve, the implementation is straight forward. We need two control points as input (cp0 and cp1) and return a Path.

1*S8iyn_CQZoJSoje89l-qZw.png A Bézier curve with 2 control points

One thing to note here is that inputs are expected to be normalized (in the range 0…1). RelativePoint typealias is used in order to make it a little bit more clear.

struct CurveShape: Shape {
    let cp0, cp1: RelativePoint
    func path(in rect: CGRect) -> Path {
        Path { p in
            p.move(to: CGPoint(x: 0, y: rect.size.height))
            p.addCurve(to: CGPoint(x: rect.size.width, y: 0),
                       control1: cp0 * rect.size,
                       control2: cp1 * rect.size)
        }
    }
}

Let’s do some bending

We got every ingredient now to construct our editor view.

Here are the key points:

  1. Some preset values
  2. An internal state for our handle offsets
  3. Exposed bindings for final values
  4. The curve
  5. The lines connecting control points to curve points
  6. The handles

1*XtJFUGBrwZZ4kcIsaTpdvw.png Handles and everything

struct CurveEditorView: View {

    // 1
    private let initialPoint0: CGSize = .init(width: 0.4, height: 0.3)
    private let initialPoint1: CGSize = .init(width: 0.6, height: 0.6)

    // 2
    @State private var offsetPoint0: CGSize = .zero
    @State private var offsetPoint1: CGSize = .zero

    private var curvePoint0: RelativePoint {
        (initialPoint0 + offsetPoint0).toPoint
    }

    private var curvePoint1: RelativePoint {
        (initialPoint1 + offsetPoint1).toPoint
    }

    // 3
    @Binding var controlPoint1: RelativePoint
    @Binding var controlPoint2: RelativePoint

    var body: some View {

        let primaryColor   = Color.blue
        let secondaryColor = primaryColor.opacity(0.7)

        return GeometryReader { reader in
            Color.white

            // 4
            CurveShape(cp0: self.curvePoint0, cp1: self.curvePoint1)
                .stroke(primaryColor, lineWidth: 4)

            // 5
            Path { p in
                p.move(to: CGPoint(x: 0, y: 1 * reader.size.height))
                p.addLine(to: self.curvePoint0 * reader.size)
            }.stroke(secondaryColor, lineWidth: 2)

            Path { p in
                p.move(to: CGPoint(x: 1 * reader.size.width, y: 0))
                p.addLine(to: self.curvePoint1 * reader.size)
            }.stroke(secondaryColor, lineWidth: 2)

            // 6
            ControlPointHandle()
                .offset(self.initialPoint0 * reader.size)
                .foregroundColor(primaryColor)
                .draggable(onChanged: { (size) in
                    self.offsetPoint0 = size / reader.size
                    self.controlPoint1 = self.curvePoint0
                })

            ControlPointHandle()
                .offset(self.initialPoint1 * reader.size)
                .foregroundColor(primaryColor)
                .draggable(onChanged: { (size) in
                    self.offsetPoint1 = size / reader.size
                    self.controlPoint2 = self.curvePoint1
                })
        }
        .aspectRatio(contentMode: .fit)
        .onAppear {
            self.controlPoint1 = self.curvePoint0
            self.controlPoint2 = self.curvePoint1
        }
    }
}

Putting it all together

At this point, one more thing to note (that was quite surprising to me) is that we can have out-of-range values too, which means that we can even have basic anticipation (or overshooting) without keyframing.

Finally, we should keep in mind that we have to flip the Y on our control points before passing them to the animation (zero at the bottom left / one at top right).

1*zCBmzu1wpFy5MkVmYIrF8g.gif A sweet, sweet moving dot

struct TimingCurveView: View {
    @State var value: CGFloat = 0

    @State var cp1: RelativePoint = .zero
    @State var cp2: RelativePoint = .zero

    let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()

    var animation: Animation {
        Animation.timingCurve(
            Double(cp1.x),
            Double(1 - cp1.y),
            Double(cp2.x),
            Double(1 - cp2.y),
            duration: 2
        )
    }

    var body: some View {
        VStack {
            CurveEditorView(controlPoint1: $cp1, controlPoint2: $cp2)
                .aspectRatio(contentMode: .fill)
            Spacer()
            GeometryReader { reader in
                Circle()
                    .position(x: 0, y: 6)
                    .offset(x: self.value * reader.size.width, y: 0)
                    .frame(height: 12)
            }.frame(height: 40)
        }
        .onReceive(timer) { _ in
            self.value = 0
            withAnimation(self.animation) {
                self.value = 1
            }
        }
    }
}

And that was it!

We got a fully functional curve editor that we can use to visually design our animation timing curves.

You can find the final project here

Did you find this article valuable?

Support Vasilis Akoinoglou by becoming a sponsor. Any amount is appreciated!