How to Achieve a Calligraphy Effect in SwiftUI Starting with a Single Curved Path

113 views Asked by At

Edit: I welcome more answers.

In SwiftUI, how can you derive calligraphic styled Shape from a single curved path/line?

I would assume that the basic algorithm would be:

  • Duplicate the path.
  • Translate the duplicate.
  • "merge" / weld / combine both lines by drawing a line between them "head-to-head" and "tail-to-tail"

Here is a simple example of this algorithm corresponding to a flat pen going left to right and being held at a slight angle:

Duplicated, not yet connected:

enter image description here

Connected:

enter image description here

But the Shape/path I am trying to transform into calligraphy is rather complex and is not composed of a series of points (from what I understand -- it is a series of curves).

AsemicWord_Squiggle()
                .stroke(Color.blue, lineWidth: 2)
                .frame(width: 200, height: 100)
                .background(Color.gray).opacity(0.3)

enter image description here

struct AsemicWord_Squiggle: Shape {
    
    func path(in rect: CGRect) -> Path {
        
        var path = Path()
        
        // Rate at which the amplitude will decrease
        // smaller = faster
        let waveAmplitudePowerSeed = CGFloat.random(in: 0.7...0.95)
        // Rate at with the wavelength will increase.
        // larger = more
        let waveLengthPowerSeed = CGFloat.random(in: 1.1...1.3)

        // Start point
        let startingX = rect.minX
        let startingY = rect.midY
        path.move(to: CGPoint(x: rect.minX, y: startingY))
        
        // Amplitude of first wave (used for Y component of first control point)
        var waveAmplitude = CGFloat.random(in: rect.height * 0.4...rect.height * 0.6)
        // Wavelength of first wave
        var waveLength = CGFloat.random(in: rect.width * 0.1...rect.width * 0.2)
        
        let firstWaveControlY = CGFloat.random(in: rect.midY - waveAmplitude...rect.midY + waveAmplitude)
        
        // Draw the first wave segment
        let firstWaveLength = waveLength
        let firstNextX = startingX + firstWaveLength
        let firstControl1 = CGPoint(x: startingX + firstWaveLength / 4, y: firstWaveControlY)
        let firstControl2 = CGPoint(x: startingX + 3 * firstWaveLength / 4, y: startingY - waveAmplitude)
        let firstNextPoint = CGPoint(x: firstNextX, y: startingY)
        
        path.addCurve(to: firstNextPoint, control1: firstControl1, control2: firstControl2)
        
        // Continue drawing the rest of the waves
        var currentX = firstNextX
        while currentX < rect.maxX/2 {
            let nextX = currentX + waveLength
            let control1 = CGPoint(x: currentX + waveLength / 4, y: startingY + waveAmplitude)
            let control2 = CGPoint(x: currentX + 3 * waveLength / 4, y: startingY - waveAmplitude)
            let nextPoint = CGPoint(x: nextX, y: startingY)
            
            waveAmplitude = pow(waveAmplitude, waveAmplitudePowerSeed)
            waveLength = pow(waveLength, waveLengthPowerSeed)
            path.addCurve(to: nextPoint, control1: control1, control2: control2)
            currentX = nextX
        }
        
        return path
    }
}

I was hoping there would be a way to simply add something before "return path" such as:

let path2 = path
path2.translateBy(x,y)
path.weld(path2)
return path

But those methods don't exist.

Alternatively, in theory, if we started with a path that was comprised of a series/array of points, it would be possible to do this pseudo code:

let copyA:[CGPoint] = path.getPoints
let copyB:[CGPoint] = path.getPoints.translatedBy(x,y)

let result = Path()
result.moveTo(copyA.first)
for points in copyA { point in
    result.lineTo(point) 
}
result.moveTo(copyB.last)
for points in copyB.reversed() { point in
    result.lineTo(point)
}
result.lineTo(copyA.first)

But the shape I'm working with is not a series of points as I said and even if it was, I haven't been able to figure out how to get them.

Also chatGPT is telling me to use other methods that don't exist and is very difficult and annoying for problems like this.

Beginning with the "Asemic_Squiggle," how can this be done? Can it be done at the SwiftUI view-modifier level? If not, what is the easiest way?

1

There are 1 answers

1
Benzy Neez On BEST ANSWER

Unfortunately, SwiftUI does not provide any easy way to stroke a path with a custom brush. If the path consists of straight lines then you can draw an image at every point (this technique is being used in the answer to Create a pencil effect for drawing on SwiftUi), but it's much harder when the path consists of curves.

In this case, the nib of the pen has a geometric shape and it is fully opaque, so you can achieve the same result by stroking the path multiple times and setting an offset on each stroke.

I transposed your nib shape to a pixel arrangement as follows:

Pixels

Here is how the shape can then be drawn with multiple offset strokes:

ZStack {
    let size = CGSize(width: 400, height: 200)
    let path = AsemicWord_Squiggle().path(in: CGRect(origin: .zero, size: size))
    ForEach(0...3, id: \.self) { row in
        ForEach(0...2, id: \.self) { col in
            path
                .offsetBy(
                    dx: (CGFloat(row) / 3) + CGFloat(col),
                    dy: CGFloat(row)
                )
                .stroke()
        }
    }
}
.frame(width: 300, height: 200)
.foregroundStyle(.blue)
.background(.gray.opacity(0.3))

Squiggle