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