Place views evenly distributed along a path

58 views Asked by At

I would like to place N views, say Circle() for example, around the perimeter of a rounded rectangle. They need to be evenly spaced. I could figure out the math to do this if the main path was a circle by using the circumference and radius of the circle. But there's no such formula for rounded rectangles.

So I'm wondering if there's a way to extract the path from the rounded rect, or really any general shape, and place N evenly spaced views along the path. Even if I have to draw the rectangle path manually, I guess the main question is how to place views along that path.

2

There are 2 answers

2
Benzy Neez On BEST ANSWER

This can be done by trimming the path and then using currentPoint to find the position along the path. According to the documentation, this returns

the last point in the path, or nil if the path contains no points

When you trim from 0 to 0, the currentPoint point is nil. So the loop needs to use a closed range from 1...nDots instead of 0..<nDots:

private func dottedShape<S: Shape>(shape: S, nDots: Int, dotSize: CGFloat = 10) -> some View {
    GeometryReader { proxy in
        let rect = CGRect(origin: .zero, size: proxy.size)
        let path = shape.path(in: rect)
        ForEach(1...nDots, id: \.self) { n in
            let fraction = Double(n) / Double(nDots)
            if let position = path.trimmedPath(from: 0, to: fraction).currentPoint {
                Circle()
                    .frame(width: dotSize, height: dotSize)
                    .position(position)
            }
        }
    }
}

var body: some View {
    dottedShape(
        shape: RoundedRectangle(cornerRadius: 15),
        nDots: 80
    )
    .frame(width: 300, height: 200)
    .foregroundStyle(.blue)
}

Screenshot

2
Sweeper On

Path has the very convenient trimmed method that allows you to just get a very thin "slice" of the whole path. Using this, we can approximate the points along the path by getting the slice's bounding rect.

Here is an example:

var body: some View {
    let path = Path(roundedRect: CGRect(x: -125, y: -125, width: 250, height: 250), cornerRadius: 30)
    ZStack {
        ForEach(0..<100) { i in
            let d = Double(i) / 100
            let epsilon = 0.001
            let point = path.trimmedPath(from: d, to: d + epsilon).boundingRect.origin
            Circle().frame(width: 10)
                .offset(x: point.x, y: point.y)
        }
    }
}

Of course, this is just an approximation. For small enough values of epsilon, the bounding box's origin becomes infinity, and so useless.

For a more exact approach, you need to iterate over the path elements, like this answer. Adapting that answer for SwiftUI Path, you can do:

struct ContentView: View {
    
    var body: some View {
        let path = Path(roundedRect: CGRect(x: -125, y: -125, width: 250, height: 250), cornerRadius: 30)
        let points = path.points(interval: 10)
        ZStack {
            ForEach(points.indices, id: \.self) { i in
                let point = points[i]
                Circle().frame(width: 10)
                    .offset(x: point.x, y: point.y)
            }
        }
    }
}

extension Path {
    func points(interval: CGFloat) -> [CGPoint] {
        var points = [CGPoint]()
        forEachPoint(interval: interval) { point, vector in
            points.append(point)
        }
        return points
    }
    
    func forEachPoint(interval: CGFloat, block: (_ point: CGPoint, _ vector: CGVector) -> Void) {
        let path = dashedPath(pattern: [interval * 0.5, interval * 0.5])
        path.forEachPoint { point, vector in
            block(point, vector)
        }
    }
    
    private func dashedPath(pattern: [CGFloat]) -> Path {
        let dashedPath = cgPath.copy(dashingWithPhase: 0, lengths: pattern)
        return Path(dashedPath)
    }
    
    private var elements: [PathElement] {
        var pathElements = [PathElement]()
        cgPath.applyWithBlock { elementsPointer in
            let element = PathElement(element: elementsPointer.pointee)
            pathElements.append(element)
        }
        return pathElements
    }
    
    private func forEachPoint(_ block: (_ point: CGPoint, _ vector: CGVector) -> Void) {
        var hasPendingSegment: Bool = false
        var pendingControlPoint = CGPoint.zero
        var pendingPoint = CGPoint.zero
        for pathElement in elements {
            switch pathElement {
            case let .moveToPoint(destinationPoint):
                if hasPendingSegment {
                    block(pendingPoint, vector(from: pendingControlPoint, to: pendingPoint))
                    hasPendingSegment = false
                }
                pendingPoint = destinationPoint
            case let .addLineToPoint(destinationPoint):
                pendingControlPoint = pendingPoint
                pendingPoint = destinationPoint
                hasPendingSegment = true
            case let .addQuadCurveToPoint(controlPoint, destinationPoint):
                pendingControlPoint = controlPoint
                pendingPoint = destinationPoint
                hasPendingSegment = true
            case let .addCurveToPoint(controlPoint1, _, destinationPoint):
                pendingControlPoint = controlPoint1
                pendingPoint = destinationPoint
                hasPendingSegment = true
            case .closeSubpath:
                break
            }
        }
        if hasPendingSegment {
            block(pendingPoint, vector(from: pendingControlPoint, to: pendingPoint))
        }
    }
    
    private func vector(from point1: CGPoint, to point2: CGPoint) -> CGVector {
        let length = hypot(point2.x - point1.x, point2.y - point1.y)
        return CGVector(dx: (point2.x - point1.x) / length, dy: (point2.y - point1.y) / length)
    }
}

enum PathElement {
    case moveToPoint(CGPoint)
    case addLineToPoint(CGPoint)
    case addQuadCurveToPoint(CGPoint, CGPoint)
    case addCurveToPoint(CGPoint, CGPoint, CGPoint)
    case closeSubpath
    
    init(element: CGPathElement) {
        switch element.type {
        case .moveToPoint: self = .moveToPoint(element.points[0])
        case .addLineToPoint: self = .addLineToPoint(element.points[0])
        case .addQuadCurveToPoint: self = .addQuadCurveToPoint(element.points[0], element.points[1])
        case .addCurveToPoint: self = .addCurveToPoint(element.points[0], element.points[1], element.points[2])
        case .closeSubpath: self = .closeSubpath
        @unknown default:
            fatalError("Unknown CGPathElement type")
        }
    }
}

This also gives you the tangent vectors for each point, which might help when drawing your views.

Sample output:

enter image description here