swiftuishapes

What’s the best way to create a "scribbling" effect with any shape in SwiftUI?


I've written a code to draw a "scribbling" effect with a rectangular shape.

Ofc shape of scribbling must not be changed on view refresh.

At least it works — and result looks pretty good too!

scribbling

I wonder if it's possible to write a code that can generate "scribbling" with:

As example I want to draw shapes:

I believe there must be a better way than writing separate code for each shape.

Here is my code to draw a rectangular shape:


import SwiftUI

struct FillScribbleRect: View {
    var brushSize: CGFloat = 3
    var color: Color = .green
    var type: ScribbleType = .vertical
    var outer = false
    
    var body: some View {
        ApproximateRect(distance: brushSize * (outer ? 0.6 : 0.5), type: type, outer: outer )
            .stroke(style: StrokeStyle(lineWidth: brushSize, lineCap: .square, lineJoin: .bevel))
            .foregroundColor(color)
    }
}

fileprivate struct ApproximateRect: Shape {
    let distance: Double
    let type: ScribbleType
    let outer: Bool
    
    func path(in rect: CGRect) -> Path {
        switch type {
        case .vertical:
            return vertical(in: rect)
        case .horizontal:
            return horizontal(in: rect)
        }
    }
}

extension ApproximateRect {
    func distCalc(in rect: CGRect) -> ([UInt], [UInt]) {
        var distToBorder: [UInt] = []
        var distBetweenLines: [UInt] = []
        
        var rnd = SeededRandom(seed: Int(distance) )
        var count: Int
        
        switch type {
        case .vertical:
            count = Int(rect.width/distance)
        case .horizontal:
            count = Int(rect.height/distance)
        }
        
        for _ in 1...count {
            distToBorder.append( rnd.next(upperBound: UInt(8) ) )
            distBetweenLines.append( rnd.next(upperBound: UInt(distance) ) )
        }
        
        return (distToBorder, distBetweenLines)
    }
    
    func vertical(in rect: CGRect) -> Path {
        let (distToBorder, distBetweenLines) = distCalc(in: rect)
        
        var path = Path()
        
        //top line
        path.move(to: CGPoint(x: rect.minX, y: rect.minY) )
        
        var flag = true
        for i in distToBorder.indices {
            if flag {
                let t = (outer ? 1 : -1 ) * Double(distToBorder[i])
                
                path.addQuadCurve(
                    to: CGPoint(x: rect.minX + distance * Double(i+2) - Double(distBetweenLines[i]), y: rect.height + t ),
                    control: CGPoint(x: distance * Double(i), y: rect.height/2)
                )
            } else {
                let t = (outer ? -1 : 1 ) * Double(distToBorder[i])
                
                path.addQuadCurve(
                    to: CGPoint(x: rect.minX + distance * Double(i+2) - Double(distBetweenLines[i]), y: rect.minY + t ),
                    control: CGPoint(x: distance * Double(i), y: rect.height/2 )
                )
            }
            flag.toggle()
        }
        
        return path
    }
    
    func horizontal(in rect: CGRect) -> Path {
        let (distToBorder, distBetweenLines) = distCalc(in: rect)
        
        var path = Path()
        
        //top line
        path.move(to: CGPoint(x: rect.minX, y: rect.minY) )
        
        var flag = true
        for i in distToBorder.indices {
            if flag {
                let t = (outer ? 1 : -1 ) * Double(distToBorder[i])
                
                path.addQuadCurve(
                    to: CGPoint(x: rect.width + t, y: rect.minY + distance * Double(i+2) - Double(distBetweenLines[i]) ),
                    control: CGPoint(x: rect.width/2, y: distance * Double(i))
                )
            } else {
                let t = (outer ? -1 : 1 ) * Double(distToBorder[i])
                
                path.addQuadCurve(
                    to: CGPoint(x: rect.minX + t, y: rect.minY + distance * Double(i+2) - Double(distBetweenLines[i]) ),
                    control: CGPoint(x: rect.width/2, y: distance * Double(i) )
                )
            }
            flag.toggle()
        }
        
        return path
    }
}


fileprivate struct SeededRandom: RandomNumberGenerator {
    init(seed: Int) { srand48(seed) }
    func next() -> UInt64 { return UInt64(drand48() * Double(UInt64.max)) }
}

enum ScribbleType {
    case vertical
    case horizontal
}

/////////////////////
/// Preview
////////////////////
struct FillScribbleRect_Previews: PreviewProvider {
    static var previews: some View {
        let size: CGFloat = 5
        
        return VStack (spacing: 30) {
            FillScribbleRect(brushSize: size, type: .horizontal)
                .frame(width: 50, height: 50)
            
            FillScribbleRect(brushSize: 6, type: .vertical)
            .frame(width: 200, height: 50)
            
            FillScribbleRect(brushSize: size, type: .horizontal)
                .frame(width: 50, height: 200)
        }
        .padding(150)
        .background(Color.white)
    }
}

Solution

  • Path provides the function contains(_:eoFill:), so this can be used to scan across the painting area and test whether a point is inside or outside a shape. This works quite well for shapes that have a simple form. Shapes with bits "sticking out" (such as a star shape) will be more complicated to fill.

    Here is an example of how a scribbled path can be implemented using the point-inspection technique. It uses SeededRandom from your example, so that the random effects are always the same:

    struct ScribbledForm<S: Shape>: Shape {
        let shape: S
        let brushSize: CGFloat
    
        func path(in rect: CGRect) -> Path {
            let shapePath = shape.path(in: rect)
            let rnd = SeededRandom(seed: Int(brushSize))
            return Path { path in
                var x = 0.0
                var y = 0.0
                var xStep = 1.0
                var lastPoint: CGPoint?
                while y <= rect.size.height {
                    while x >= 0 && x <= rect.size.width {
                        if shapePath.contains(CGPoint(x: x, y: y)) {
                            let dx = randomOffset(randomVal: rnd.next(), magnitude: (brushSize * brushSize) / 2.0)
                            let dy = randomOffset(randomVal: rnd.next(), magnitude: brushSize / 2)
                            let point = CGPoint(x: x + dx, y: y + dy)
                            if let lastPoint {
                                let midX = lastPoint.x + ((point.x - lastPoint.x) / 2)
                                let midY = lastPoint.y + ((point.y - lastPoint.y) / 2)
                                path.addQuadCurve(
                                    to: point,
                                    control: CGPoint(x: midX, y: midY - (brushSize * 0.5))
                                )
                            } else {
                                path.move(to: point)
                            }
                            lastPoint = point
                            break
                        } else {
                            x += xStep
                        }
                    }
                    if xStep < 0 {
                        x = 0
                        xStep = 1
                    } else {
                        x = rect.size.width
                        xStep = -1
                    }
                    y += brushSize / 2
                }
            }
        }
    
        private func randomOffset(randomVal: UInt64, magnitude: Double) -> Double {
            ((Double(randomVal) / Double(UInt64.max)) * magnitude) - (magnitude / 2)
        }
    }
    
    struct ScribbledShape<S: Shape>: View {
        let shape: S
        var brushSize: CGFloat = 3
    
        var body: some View {
            ScribbledForm(shape: shape, brushSize: brushSize)
                .stroke(style: .init(lineWidth: brushSize, lineCap: .square, lineJoin: .bevel))
        }
    }
    

    This implementation always works horizontally. If you want to fill using a vertical scribble, just rotate the result.

    Example use:

    VStack(spacing: 20) {
        ScribbledShape(shape: Rectangle(), brushSize: 5)
            .frame(width: 100, height: 200)
            .foregroundStyle(.green)
            .border(.red)
    
        ScribbledShape(shape: Circle(), brushSize: 5)
            .foregroundStyle(.green)
            .frame(width: 200, height: 200)
            .rotationEffect(.degrees(-30))
            .border(.red)
    
        Color.clear
            .frame(width: 300, height: 200)
            .overlay {
    
                // DiamondShape: see https://stackoverflow.com/q/78496625/20386264
                ScribbledShape(shape: DiamondShape(), brushSize: 5)
                    .frame(width: 200, height: 300)
                    .foregroundStyle(.green)
                    .rotationEffect(.degrees(-90))
            }
            .border(.red)
    }
    

    Screenshot