Search code examples
swiftswiftuidrawing

Create a pencil effect for drawing on SwiftUi


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!


Solution

  • 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)
    

    PencilTip

    This can be used for drawing a pencil line as follows:

    • a circle is applied as clip shape to the pencil tip and it is scaled down to the line width
    • opacity and shadow effects are also applied
    • a line between two points is drawn by stepping along the line and drawing an image at each point
    • if the same image is used for all points then I found it could cause regular patterns, so it works better to have multiple tips which you can switch between in an irregular way
    • a 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
        }
    }
    

    Scribbles

    More notes:

    • The class PencilCase is used to manage the pencil tips, creating them on demand.
    • You might like to try tweaking the values that PencilCase uses to create the pencil tips. Or you could try a different kind of image altogether.
    • Instead of having a 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.
    • I added the tool type for a line to the struct Line, so that it is possible to draw the lines with the appropriate tool.
    • It took me a while to realize that the Canvas did not fill the whole of the top region. So I added a frame around it to make it clearer.
    • An easier way to apply a background to the bottom region is just to use .background on the VStack. Both ZStack were then redundant so I dropped them.
    • Instead of using .offset for fixing the layout, I would suggest using .padding.

    I hope there might be parts of the solution that you find useful.