I am trying to create a pencil effect/texture for drawing on SwiftUI without using PencilKit if possible, but I am not sure how to do that.
This is what I have for now, I would like to draw on the canvas with a pencil effect if I choose the pencil tool
I already implemented a pen/marker tool
Content View
import SwiftUI
struct ContentView: View {
@State private var lines: [Line] = []
// selecting color and line width
@State private var selectedColor = Color.orange
@State private var selectedLineWidth: CGFloat = 7
@State var isPen:Bool = true
@State var isMarker:Bool = false
@State var isPencil:Bool = false
var body: some View {
VStack {
Spacer()
//Canvas for drawing
ZStack {
Canvas {ctx, size in
for line in lines {
var path = Path()
path.addLines(line.points)
ctx.stroke(path, with: .color(line.color),
style: StrokeStyle(lineWidth: line.lineWidth, lineCap: .round, lineJoin: .round))
}
}
.frame(width:390, height: 390)
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged({value in
let position = value.location
if value.translation == .zero {
lines.append(Line(points: [position], color: selectedColor, lineWidth: selectedLineWidth))
} else {
guard let lastIdx = lines.indices.last else {
return
}
lines[lastIdx].points.append(position)
}
})
)
.offset(y: 50)
Spacer()
// bottom tool display
ZStack {
Rectangle()
.ignoresSafeArea()
.frame(width: 395, height: 100)
.foregroundColor(.gray).opacity(0.3)
VStack {
HStack{
Spacer()
pen()
pencil()
marker()
ColorPicker("Color", selection: $selectedColor).padding().font(.largeTitle).labelsHidden()
Spacer()
clearButton()
Spacer()
}
Slider(value: $selectedLineWidth, in: 1...20).frame(width:200).accentColor(selectedColor)
}
}
}
}
//functions for the buttons
//@ViewBuilder
func clearButton() -> some View {
Button {
lines = []
} label: {
Image(systemName: "pencil.tip.crop.circle.badge.minus")
.font(.largeTitle)
.foregroundColor(.gray)
}
}
func pen() -> some View {
Button(action: {penAction()})
{
if(isPen) {
Image(systemName: "paintbrush.pointed.fill").font(.title).foregroundColor(selectedColor)
} else {
Image(systemName: "paintbrush.pointed.fill").font(.title).foregroundColor(.gray)
}
}
}
func pencil() -> some View {
Button(action: {pencilAction()})
{
if(isPencil) {
Image(systemName: "pencil").font(.title).foregroundColor(selectedColor)
} else {
Image(systemName: "pencil").font(.title).foregroundColor(.gray)
}
}
}
func marker() -> some View {
Button(action: {markerAction()})
{
if(isMarker) {
Image(systemName: "paintbrush.fill").font(.title).foregroundColor(selectedColor)
} else {
Image(systemName: "paintbrush.fill").font(.title).foregroundColor(.gray)
}
}
}
// actions when choosing tools and change the values
func penAction() {
self.isPen.toggle()
isPencil = false
isMarker = false
}
func pencilAction() {
self.isPencil.toggle()
isPen = false
isMarker = false
}
func markerAction() {
self.isMarker.toggle()
isPen = false
isPencil = false
}
func lineCapIs() -> CGLineCap {
//var linesCap:CGLineCap
if isMarker {
return .square
}
else {
return .round
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Line.swift
import SwiftUI
struct Line {
var points: [CGPoint]
var color: Color
var lineWidth: CGFloat
}
Any help is appreciated. Thank you in advance!
A GraphicsContext
is used to draw on the Canvas
and this provides methods to stroke a path using Shading
. In turn, Shading
provides support for stroking with a gradient or an image. However, it seems that the gradient or image is not used as the tip of a stylus, but rather as a tiled effect or as the background. So I don't think that this approach is useful to you.
In iOS 17, a Shader
can be used for custom effects, so this might provide a possible solution. But assuming you still need to support older iOS versions for a while, I think the best way to get a textured draw effect for now is probably to draw every point of the path using an Image
.
This introduces a new problem of needing a suitable image. A good solution might be to use a symbol, because this would make it easy to switch colors. But another way is to create an image from a Path
.
I came up with the following Shape
to use as the path for a pencil tip. It consists of horizontal and vertical lines with random offsets to make them non-straight.
struct PencilTip: Shape {
let nLines: Int
func path(in rect: CGRect) -> Path {
var path = Path()
let lineSpace = rect.width / CGFloat(nLines)
for i in 0..<nLines {
var x = (CGFloat(i) * lineSpace) + (CGFloat.random(in: 0...1) * lineSpace)
path.move(to: CGPoint(x: x, y: rect.minY))
x = (CGFloat(i) * lineSpace) + (CGFloat.random(in: 0...1) * lineSpace)
path.addLine(to: CGPoint(x: x, y: rect.maxY))
var y = (CGFloat(i) * lineSpace) + (CGFloat.random(in: 0...1) * lineSpace)
path.move(to: CGPoint(x: rect.minX, y: y))
y = (CGFloat(i) * lineSpace) + (CGFloat.random(in: 0...1) * lineSpace)
path.addLine(to: CGPoint(x: rect.maxX, y: y))
}
return path
}
}
Here is an example of how the shape is used and how it looks:
PencilTip(nLines: 7)
.stroke(lineWidth: 10)
.frame(width: 140, height: 140)
This can be used for drawing a pencil line as follows:
Canvas
constantly repaints itself, so you want to be sure that the image used for any particular point is always the same, otherwise you see the lines "twitching" with every repaint.Here is a revised version of your canvas example, which now uses the pencil tips for drawing pencil lines. More notes follow below.
class PencilCase {
let nTipsPerSize = 7
let scalingFactor = CGFloat(20)
let scaledLineWidth = CGFloat(10)
let scaledBlurRadius = CGFloat(8)
typealias Images = [Image]
typealias PencilTips = [CGFloat: Images]
private var colorMap = [Color: PencilTips]()
private func point2TipIndex(_ point: CGPoint) -> Int {
Int(100 * abs(point.x + point.y)) % nTipsPerSize
}
private func scaledRectangle(lineWidth: CGFloat) -> CGRect {
CGRect(x: 0, y: 0, width: lineWidth * scalingFactor, height: lineWidth * scalingFactor)
}
private func createPencilTip(color: Color, lineWidth: CGFloat) -> Image {
Image(size: CGSize(width: lineWidth, height: lineWidth)) { context in
context.scaleBy(x: 1.0 / self.scalingFactor, y: 1.0 / self.scalingFactor)
context.addFilter(.shadow(color: color, radius: self.scaledBlurRadius))
context.clip(to: Path(ellipseIn: self.scaledRectangle(lineWidth: lineWidth)))
context.stroke(
PencilTip(nLines: max(1, Int(lineWidth.squareRoot())))
.path(in: self.scaledRectangle(lineWidth: lineWidth)),
with: .color(color.opacity(0.8)),
lineWidth: self.scaledLineWidth
)
}
}
private func createPencilTips(color: Color, lineWidth: CGFloat) -> Images {
var result = Images()
result.reserveCapacity(nTipsPerSize)
for _ in 0..<nTipsPerSize {
result.append(createPencilTip(color: color, lineWidth: lineWidth))
}
return result
}
func pencilTip(color: Color, lineWidth: CGFloat, point: CGPoint) -> Image {
let result: Image
if let pencilTips = colorMap[color] {
if let existingTips = pencilTips[lineWidth] {
result = existingTips[point2TipIndex(point)]
} else {
let tips = createPencilTips(color: color, lineWidth: lineWidth)
colorMap[color]?[lineWidth] = tips
result = tips[point2TipIndex(point)]
}
} else {
let tips = createPencilTips(color: color, lineWidth: lineWidth)
colorMap[color] = [lineWidth: tips]
result = tips[point2TipIndex(point)]
}
return result
}
}
enum DrawingTool {
case pen
case pencil
case marker
}
struct Line {
let tool: DrawingTool
var points: [CGPoint]
let color: Color
let lineWidth: CGFloat
}
struct ContentView: View {
@State private var lines: [Line] = []
// selecting color and line width
@State private var selectedColor = Color.orange
@State private var selectedLineWidth: CGFloat = 7
@State private var drawingTool = DrawingTool.pen
private let pencilCase = PencilCase()
private func pencilLine(
ctx: GraphicsContext,
pointA: CGPoint,
pointB: CGPoint,
color: Color,
lineWidth: CGFloat
) {
// Determine the length of the line
var x = pointA.x
var y = pointA.y
let dx = pointB.x - x
let dy = pointB.y - y
let len = ((dx * dx) + (dy * dy)).squareRoot()
// Determine the number of steps and the step sizes,
// aiming for approx. 1 step per pixel of length
let nSteps = max(1, Int(len + 0.5))
let stepX = dx / CGFloat(nSteps)
let stepY = dy / CGFloat(nSteps)
// Draw the points of the line
for _ in 0..<nSteps {
let point = CGPoint(x: x, y: y)
let pencilTip = pencilCase.pencilTip(
color: color,
lineWidth: lineWidth,
point: point
)
ctx.draw(pencilTip, at: point)
x += stepX
y += stepY
}
}
private func connectPointsWithPencil(ctx: GraphicsContext, line: Line) {
var lastPoint: CGPoint?
for point in line.points {
if let lastPoint {
pencilLine(
ctx: ctx,
pointA: lastPoint,
pointB: point,
color: line.color,
lineWidth: line.lineWidth
)
}
lastPoint = point
}
}
var body: some View {
VStack {
Spacer()
//Canvas for drawing
Canvas { ctx, size in
for line in lines {
if line.tool == .pencil {
connectPointsWithPencil(ctx: ctx, line: line)
} else {
var path = Path()
path.addLines(line.points)
ctx.stroke(
path,
with: .color(line.color),
style: StrokeStyle(
lineWidth: line.lineWidth,
lineCap: lineCapIs(tool: line.tool),
lineJoin: .round
)
)
}
}
}
.frame(maxWidth: .infinity)
.frame(height: 390)
.border(.gray)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
let position = value.location
if value.translation == .zero {
lines.append(
Line(
tool: drawingTool,
points: [position],
color: selectedColor,
lineWidth: selectedLineWidth
)
)
} else {
guard let lastIdx = lines.indices.last else {
return
}
lines[lastIdx].points.append(position)
}
}
)
.padding(.horizontal)
.padding(.bottom, 100)
// bottom tool display
VStack {
HStack{
Spacer()
toolSymbol(tool: .pen, imageName: "paintbrush.pointed.fill")
toolSymbol(tool: .pencil, imageName: "pencil")
toolSymbol(tool: .marker, imageName: "paintbrush.fill")
ColorPicker("Color", selection: $selectedColor)
.padding()
.font(.largeTitle)
.labelsHidden()
Spacer()
clearButton()
Spacer()
}
Slider(value: $selectedLineWidth, in: 1...20)
.frame(width:200)
.accentColor(selectedColor)
}
.background(.gray.opacity(0.3))
}
}
func clearButton() -> some View {
Button {
lines = []
} label: {
Image(systemName: "pencil.tip.crop.circle.badge.minus")
.font(.largeTitle)
.foregroundColor(.gray)
}
}
private func toolSymbol(tool: DrawingTool, imageName: String) -> some View {
Button { drawingTool = tool } label: {
Image(systemName: imageName)
.font(.title)
.foregroundColor(drawingTool == tool ? selectedColor : .gray)
}
}
func lineCapIs(tool: DrawingTool) -> CGLineCap {
tool == .marker ? .square : .round
}
}
More notes:
PencilCase
is used to manage the pencil tips, creating them on demand.PencilCase
uses to create the pencil tips. Or you could try a different kind of image altogether.Bool
for each kind of drawing tool, I introduced the enum DrawingTool
. This means, only one state variable is needed for tracking the selected type, instead of three.Line
, so that it is possible to draw the lines with the appropriate tool.Canvas
did not fill the whole of the top region. So I added a frame around it to make it clearer..background
on the VStack
. Both ZStack
were then redundant so I dropped them..offset
for fixing the layout, I would suggest using .padding
.I hope there might be parts of the solution that you find useful.