Search code examples
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:

  • Any shape
  • Any angle of lines ?

As example I want to draw shapes:

  • Circle
  • Capsule
  • Rounded Rectangle
  • Diamond (Rhomboid)
  • ect

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.

    • One way to approach this kind of painting exercise would be to use a Canvas. This way, the brush size can be passed in as a parameter and used by the Canvas to perform the stroke. However, a Canvas gets clipped to its frame size, so this means insetting the shape to allow for the random scribble effect.
    • An easier approach is to create the scribbled form as a Shape. This way, the path can overflow the frame, which makes it simpler to fill the shape as supplied. Padding can be added afterwards, if you don't want it overflowing the frame by so much.
    • When implemented as a Path, the stroke needs to be performed afterwards, so care must be taken to use the same brush size as was passed as parameter. Alternatively, the Shape can be wrapped in a View, so that the stroke is performed by the View and the brush size is sure to be the same. This is in fact how you were already doing it.

    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
        var maxDivergence: CGFloat = 10
    
        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: maxDivergence)
                            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


    EDIT The scribble can be animated by applying .trim to the path and animating the trim size. Important: the trim must be applied before the stroke!

    Example adaption:

    struct AnimatedScribbledShape<S: Shape>: View {
        let shape: S
        var brushSize: CGFloat = 10
        var animationDuration = 5.0
        var pauseDuration = 2.0
        var withRepeat = false
        @State private var trimTo = CGFloat.zero
    
        var body: some View {
            ScribbledForm(shape: shape, brushSize: brushSize)
                .trim(from: 0, to: trimTo) // must come before stroke!
                .stroke(style: .init(lineWidth: brushSize, lineCap: .square, lineJoin: .bevel))
                .animation(.easeInOut(duration: trimTo == 0 ? 0 : animationDuration), value: trimTo)
                .task(id: trimTo) {
                    if trimTo == 0 {
                        trimTo = 1
                    } else if withRepeat {
                        try? await Task.sleep(for: .seconds(animationDuration + pauseDuration))
                        trimTo = 0
                    }
                }
        }
    }
    
    // ScribbledShape(shape: DiamondShape(), brushSize: 5)
    AnimatedScribbledShape(shape: DiamondShape(), withRepeat: true)
    

    Animation