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!
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)
}
}
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.
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.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.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
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)
}