1

I would like to interactively draw a line on an image using SwiftUI.

I ran across several problems getting things to work. I started with a DragGesture on an Image. However, the points from the image were offset from where the Path would want to draw. This was primarily solved by drawing in the overlay - which would be the same size and location as the image.

The solution below also gets image size in pixels and screen units to help with coordinate conversions.

2 Answers 2

2

I would suggest you use a DragGesture to record the points and a Canvas to draw the line.

The problem you described of the points of the path not corresponding to the positions where they were captured is a symptom of using different coordinate spaces. You need to draw the line in the same coordinate space as where the points are captured. For example:

  • attach the DragGesture to the Image and draw the line in an overlay over the Image
  • or, attach the DragGesture to a ZStack containing the Image and draw the line in a layer inside the ZStack.

Here is a simple implementation that draws straight lines between the end points of successive drag gestures. It uses the second of the two approaches described above, with a ZStack:

struct ContentView: View {

    @State private var points = [CGPoint]()
    @State private var startOfDrag = CGPoint.zero

    var body: some View {
        ZStack {
            Image(systemName: "ladybug")
                .resizable()
                .scaledToFit()
            if points.count > 1 {
                Canvas { ctx, size in
                    var path = Path()
                    path.addLines(points)
                    ctx.stroke(path, with: .color(.red), lineWidth: 5)
                }
            }
        }
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { val in
                    if points.isEmpty || startOfDrag != val.startLocation {
                        startOfDrag = val.startLocation
                        points.append(startOfDrag)
                    } else if val.translation != .zero {
                        if points.count > 1 {
                            points.removeLast()
                        }
                        points.append(val.location)
                    }
                }
        )
        .frame(width: 300, height: 300)
    }
}

Screenshot

This solution could be elaborated in all sorts of ways, such as:

  • collect all the points from a drag movement to build a more elaborate path (see also Create a pencil effect for drawing on SwiftUi)
  • use a GeometryReader to obtain the size of the drag area and convert the points to relative UnitPoint positions, so that they can be used to draw the same line over an image that has been re-sized
  • allow the path to be closed when the last point is near the first
  • provide an undo button to allow the last line of the path to be removed.
Sign up to request clarification or add additional context in comments.

1 Comment

Thanks. I will use your tip about using Canvas to modify my answer. I had originally tried the approach of using a ZStack, but Image and Canvas are not the same size, unless one fixes the frame size like you did. So, drawing the Canvas in the overlay works fine. Ultimately, I wanted to get the line into pixel coordinates for use elsewhere in the app. Liked your simplifications to the DragGesture which may be helpful to others, but operates a little bit differently interactively.
1

Draw line on image

The following code lets one draw a line path on top of the image as shown above. The points of the line are normalized to the image so that the line can be re-drawn correctly even after the image size changes such as may happen after device rotation.

Tricks involved include drawing the line in an overlay. Code also shows getting image size in pixels and image view size in points. These sizes are useful in converting between different views.

import SwiftUI

struct ContentView: View {
    @State private var line: [CGPoint] = []     // Line is an array of points

    @State private var pixelSize = PixelSize()
    @State private var viewSize = CGSize.zero

    @State private var startNewSegment = true
    @State private var pixelPath = ""

    var body: some View {
        VStack {
            lineView() // Magic happens here
            buttons()
            info()
        }
        .padding()
        .onAppear {
            // Comment out next line when no longer needed for debugging
            line = [CGPoint(x: 0, y: 0), CGPoint(x: 0.25, y: 0.25), CGPoint(x: 0.5, y: 0)]
        }
    }

    @ViewBuilder
    func lineView() -> some View {
        let largeConfig = UIImage.SymbolConfiguration(pointSize: 100, weight: .bold, scale: .large)
        let image = UIImage(systemName: "bolt.square", withConfiguration: largeConfig)! // Your image here

        Image(uiImage: image)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .saveSize(in: $viewSize) // NOTE: this is the screen size for the image!
            .overlay { // Draw line in overlay.  You really want to do this.
                if line.count > 1 {
                    // Replace "Path" with "Canvas"
                    Canvas { ctx, size in
                        var path = Path()
                        path.addLines(line.map { screenPoint($0)})
                        ctx.stroke(path, with: .color(.blue), lineWidth: 5)
                    }

                    // Path { path in
                    //     path.move(to: screenPoint(line[0]))
                    //     for i in 1..<line.count {
                    //         path.addLine(to: screenPoint(line[i]))
                    //     }
                    // }
                    // .stroke(.blue, lineWidth: 5)
                }
            }
            .onAppear {
                pixelSize = image.pixelSize
            }
        // Build up line by adding points in straight line segments.
        // Allow point to be added by tap
            .onTapGesture { location in
                line.append(limitPoint(location))
            }
        // Or allow new point to be added from drag
            .gesture(
                DragGesture()
                    .onChanged { value in
                        if line.count < 1 {
                            // If no points, add "startLocation" point (more accurate than simply location for first point)
                            line.append(limitPoint(value.startLocation))
                        } else if line.count < 2 || startNewSegment {
                            // Add point at current position
                            line.append(limitPoint(value.location))
                            startNewSegment = false
                        } else {
                            // Note: Now in mode where we are replacing the last point
                            line.removeLast()
                            line.append(limitPoint(value.location))
                            startNewSegment = false
                        }
                    }
                    .onEnded { value in
                        line.removeLast()
                        line.append(limitPoint(value.location))
                        startNewSegment = true
                    }
            )
    }

    func screenPoint(_ point: CGPoint) -> CGPoint {
        // Convert 0->1 to view's coordinates
        let vw = viewSize.width
        let vh = viewSize.height
        let nextX = min(1, max(0, point.x)) * vw
        let nextY = min(1, max(0, point.y)) * vh
        return CGPoint(x: nextX, y: nextY)
    }

    func limitPoint(_ point: CGPoint) -> CGPoint {
        // Convert view coordinate to normalized 0->1 range
        let vw = max(viewSize.width, 1)
        let vh = max(viewSize.height, 1)

        // Keep in bounds - even if dragging outside of bounds
        let nextX = min(1, max(0, point.x / vw))
        let nextY = min(1, max(0, point.y / vh))
        return CGPoint(x: nextX, y: nextY)
    }

    @ViewBuilder
    func buttons() -> some View {
        HStack {
            Button {
                line.removeAll()
                pixelPath = ""
            } label: {
                Text("Clear")
                    .padding()
            }
            Button {
                // Show line points in "Pixel" units
                let vw = viewSize.width
                let vh = viewSize.height
                if vw > 0 && vh > 0 {
                    let pixelWidth = CGFloat(pixelSize.width - 1)
                    let pixelHeight = CGFloat(pixelSize.height - 1)
                    let pixelPoints = line.map { CGPoint(x: pixelWidth * $0.x, y: pixelHeight * $0.y)}
                    pixelPath = "\(pixelPoints)"
                }
            } label: {
                Text("Pixel Path")
                    .padding()
            }
        }
    }

    @ViewBuilder
    func info() -> some View {
        Text("Image WxL: \(pixelSize.width) x \(pixelSize.height)")
        Text("View  WxL: \(viewSize.width) x \(viewSize.height)")
        Text("Line points: \(line.count)")
        if pixelPath != "" {
            Text("Pixel Path: \(pixelPath)")
        }
    }
}

// Auxiliary definitions and functions

struct PixelSize {
    var width: Int = 0
    var height: Int = 0
}

extension UIImage {
    var pixelSize: PixelSize {
        if let cgImage = cgImage {
            return PixelSize(width: cgImage.width, height: cgImage.height)
        }
        return PixelSize()
    }
}

// SizeCalculator from: https://stackoverflow.com/questions/57577462/get-width-of-a-view-using-in-swiftui

struct SizeCalculator: ViewModifier {

    @Binding var size: CGSize

    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    Color.clear // we just want the reader to get triggered, so let's use an empty color
                        .onAppear {
                            size = proxy.size
                        }
                        .onChange(of: proxy.size) { // Added to handle device rotation
                            size = proxy.size
                        }
                }
            )
    }
}

extension View {
    func saveSize(in size: Binding<CGSize>) -> some View {
        modifier(SizeCalculator(size: size))
    }
}

1 Comment

if the image is not square, how we can handle the rotation of device ?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.