HeWhoRemains
HeWhoRemains

Reputation: 41

Overlaying Text Along a Shape Path in SwiftUI — Issues with Text Following the Shape Border

I'm working on a SwiftUI project where I allow users to create shapes using paths, and everything works perfectly so far. I can draw shapes like circles, squares, rectangles, stars, and ovals using Path and manipulate their points. However, I am trying to overlay a text string along the path of these shapes so that the text follows the shape's border (e.g., the text should follow the perimeter of a square when the user selects the square shape).

I am using a custom ShapeAlongPathView to achieve this, where I calculate the position and angle of each character in the text to make it align with the path of the shape. The issue is that, while this works for some shapes (like circles and rectangles), it doesn’t work as expected for all shapes, especially complex ones like stars. The text either doesn’t align properly or appears distorted.

What I’ve tried:

Here is the code I am working with:

import SwiftUI

enum ShapeType: String, CaseIterable {
    case circle, square, rectangle, star, oval
}

struct ShapeModel: Identifiable {
    let id = UUID()
    var type: ShapeType
    var points: [VectorPoint]
    var position: CGPoint
}

struct ShapeView: View {
    @Environment(\.presentationMode) var presentationMode
    @State private var shapes: [ShapeModel] = []
    @State private var selectedShape: ShapeType = .circle
    @State var isDhased: Bool = false
    @State var isFilled: Bool = false
    @State var borderColor: Color = .blue
    @State var borderWidth: CGFloat = 1
    
    
    
    
    var body: some View {
        GeometryReader { geometry in
        ZStack {
            Image("imgCup")
                .resizable()
                .scaledToFit()

            
            VStack {
                GeometryReader { proxy in
                    ZStack {
                        ForEach($shapes) { $shape in
                            drawShape(shape: $shape)
                                .position(shape.position)
                                .gesture(DragGesture().onChanged { value in
                                    shape.position = value.location
                                })
                                .overlay(
                                    Path { path in
                                        for (index, point) in shape.points.enumerated() {
                                            path.addLine(to: point.position)
                                        }
                                    }
                                        .overlay(
                                            ShapeAlongPathView(text: "longStringlongStringlongStringlongStringlongStringlongStringlongString", path: shape.points.map { $0.position }, letterSpacing: $borderWidth, fontSize: $borderWidth)
                                        )
                                    
                                    )
                            
                            
                        }
                    }
                    .frame(width: proxy.size.width, height: proxy.size.height)
                }
            }
           
            
            

            // UI Elements
            VStack {
                HStack {
                    Button(action: {
                        presentationMode.wrappedValue.dismiss()
                    }, label: {
                        Image(systemName: "arrow.left")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 12, height: 12)
                            .tint(.white)
                    })
                    .frame(width: geometry.size.width * 0.1, height: geometry.size.height * 0.04)
                    .background(.ultraThinMaterial)
                    .cornerRadius(10)

                    Button("Add") {
                        addShape(type: selectedShape)
                    }
                    .frame(width: geometry.size.width * 0.2, height: geometry.size.height * 0.05)
                    .foregroundStyle(.white)
                    .background(.ultraThinMaterial)
                    .cornerRadius(10)
                    .shadow(radius: 1)
                    
                    Button("-") {
                        isDhased.toggle()
                    }
                    .frame(width: geometry.size.width * 0.1, height: geometry.size.height * 0.05)
                    .foregroundStyle(.white)
                    .background(.ultraThinMaterial)
                    .cornerRadius(10)
                    .shadow(radius: 1)
                        
                    Button("*") {
                        isFilled.toggle()
                    }
                    .frame(width: geometry.size.width * 0.1, height: geometry.size.height * 0.05)
                    .foregroundStyle(.white)
                    .background(.ultraThinMaterial)
                    .cornerRadius(10)
                    .shadow(radius: 1)
                  
                    ColorPicker("", selection: Binding(get: {
                        Color(borderColor)
                    }, set: { newValue in
                        borderColor = newValue
                    }))
                    
                    
                    Spacer()

                    Button(action: {
                        shapes.removeAll()
                    }, label: {
                        Text("Clear")
                            .frame(width: geometry.size.width * 0.2, height: geometry.size.height * 0.05)
                            .foregroundStyle(.white)
                            .background(.ultraThinMaterial)
                            .cornerRadius(10)
                            .shadow(radius: 1)
                    })
                }
                .frame(width: geometry.size.width - 40, height: 80, alignment: .center)
                
                Picker("Select Shape", selection: $selectedShape) {
                    ForEach(ShapeType.allCases, id: \ .self) { shape in
                        Text(shape.rawValue.capitalized)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
                
                
               
                
                
            }
            .position(x: geometry.size.width / 2, y: 80)
            
            
            Slider(value: $borderWidth, in: 10...30)
                .position(x: geometry.size.width / 2, y: geometry.size.height - 40)
        }
        .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
        .background(Color.gray)
        }
        
    }
    
    private func addShape(type: ShapeType) {
        let newShape = ShapeModel(type: type, points: createInitialPoints(for: type), position: CGPoint(x: 100, y: 200))
        shapes.append(newShape)
    }
    
    private func createInitialPoints(for type: ShapeType) -> [VectorPoint] {
        let width: CGFloat = 100
        let height: CGFloat = 100
        
        switch type {
        case .circle:
            return [
                VectorPoint(position: CGPoint(x: 150, y: 150)),
                VectorPoint(position: CGPoint(x: 200, y: 150)) // Resizable control point
            ]
        case .square:
            return [
                VectorPoint(position: CGPoint(x: 100, y: 100)),
                VectorPoint(position: CGPoint(x: 200, y: 100)),
                VectorPoint(position: CGPoint(x: 200, y: 200)),
                VectorPoint(position: CGPoint(x: 100, y: 200))
            ]
        case .rectangle:
            return [
                VectorPoint(position: CGPoint(x: 100, y: 100)),
                VectorPoint(position: CGPoint(x: 250, y: 100)),
                VectorPoint(position: CGPoint(x: 250, y: 200)),
                VectorPoint(position: CGPoint(x: 100, y: 200))
            ]
        case .star:
            return generateStarPoints(center: CGPoint(x: 150, y: 150), radius: 50)
        case .oval:
            return [
                VectorPoint(position: CGPoint(x: 150, y: 150)),
                VectorPoint(position: CGPoint(x: 200, y: 150)), // Horizontal scaling
                VectorPoint(position: CGPoint(x: 150, y: 180)) // Vertical scaling
            ]
        }
    }
    
    private func generateStarPoints(center: CGPoint, radius: CGFloat) -> [VectorPoint] {
        let angles = stride(from: 0, through: 360, by: 72).map { angle in
            angle * 3.14 / 180
        }
        return angles.map { angle in
            VectorPoint(position: CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)))
        }
    }
    
    @ViewBuilder
    private func drawShape(shape: Binding<ShapeModel>) -> some View {
        ZStack {
            Path { path in
                if shape.wrappedValue.type == .circle {
                    let center = shape.wrappedValue.points[0].position
                    let edge = shape.wrappedValue.points[1].position
                    let radius = abs(center.x - edge.x)
                    path.addEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
                } else if shape.wrappedValue.type == .oval {
                    let center = shape.wrappedValue.points[0].position
                    let hEdge = shape.wrappedValue.points[1].position
                    let vEdge = shape.wrappedValue.points[2].position
                    let width = abs(center.x - hEdge.x) * 2
                    let height = abs(center.y - vEdge.y) * 2
                    path.addEllipse(in: CGRect(x: center.x - width / 2, y: center.y - height / 2, width: width, height: height))
                } else if shape.wrappedValue.type == .square || shape.wrappedValue.type == .rectangle {
                    path.move(to: shape.wrappedValue.points[0].position)
                    for point in shape.wrappedValue.points.dropFirst() {
                        path.addLine(to: point.position)
                    }
                    path.closeSubpath()
                } else if shape.wrappedValue.type == .star {
                    path.move(to: shape.wrappedValue.points[0].position)
                    for point in shape.wrappedValue.points.dropFirst() {
                        path.addLine(to: point.position)
                    }
                    path.closeSubpath()
                }
            }
            .fill(isFilled ? borderColor : Color.blue.opacity(0.0))
            .stroke(borderColor, style: StrokeStyle(
                lineWidth: 2,
                lineCap: .round,   // Optional: for rounded ends
                lineJoin: .round,  // Optional: for rounded corners
                dash: [isDhased ? 1 : borderWidth, isDhased ? 0 : borderWidth]      // Dash pattern: 10 points on, 5 points off
            ))
            
            ForEach(shape.points.indices, id: \ .self) { index in
                Circle()
                    .fill(Color.red)
                    .frame(width: 15, height: 15)
                    .position(shape.points[index].position.wrappedValue)
                    .gesture(DragGesture()
                        .onChanged { value in
                            shape.points[index].position.wrappedValue = value.location
                        }
                    )
            }
        }
    }
}

struct ShapeView_Previews: PreviewProvider {
    static var previews: some View {
        ShapeView()
    }
}

struct ShapeAlongPathView: View {
    let text: String
    let path: [CGPoint]
    @Binding  var letterSpacing: CGFloat
    @Binding var fontSize: CGFloat

    var body: some View {
        ZStack {
            ForEach(0..<calculateNumberOfCharacters(), id: \.self) { index in
                if let positionAndAngle = calculatePositionAndAngle(at: index) {
                    
                    let characterIndex = text.index(text.startIndex, offsetBy: index % text.count)
                    
                    let character = text[characterIndex]
                    Text(String(character))
                        .font(.system(size: fontSize, weight: .bold))
                        .foregroundColor(.white)
                        .rotationEffect(.radians(positionAndAngle.angle))
                        .position(positionAndAngle.position)
                }
            }
        }
    }
    
    private func calculateNumberOfCharacters() -> Int {
        let pathLength = calculatePathLength()
        return Int(pathLength / letterSpacing)
    }
    
    private func calculatePathLength() -> CGFloat {
        var length: CGFloat = 0
        for i in 1..<path.count {
            let start = path[i - 1]
            let end = path[i]
            length += hypot(end.x - start.x, end.y - start.y)
        }
        return length
    }
    
    
    // MARK: - Calculate Position and Angle
    private func calculatePositionAndAngle(at index: Int) -> (position: CGPoint, angle: CGFloat)? {
        guard path.count > 1 else { return nil }

        let segmentLength = CGFloat(index) * letterSpacing
        var accumulatedLength: CGFloat = 0

        for i in 1..<path.count {
            let start = path[i - 1]
            let end = path[i]
            let segmentDist = hypot(end.x - start.x, end.y - start.y)

            if accumulatedLength + segmentDist >= segmentLength {
                let ratio = (segmentLength - accumulatedLength) / segmentDist
                let x = start.x + ratio * (end.x - start.x)
                let y = start.y + ratio * (end.y - start.y)

                let dx = end.x - start.x
                let dy = end.y - start.y
                let angle = atan2(dy, dx)

                return (position: CGPoint(x: x, y: y), angle: angle)
            }
            accumulatedLength += segmentDist
        }

        return nil
    }
}

The issue: The text does not always follow the shape's path correctly. For example:

I would appreciate any help on:

this is the result what I got right now

enter image description here

maybe change needs in ShapeAlongPathView class

Upvotes: 1

Views: 33

Answers (0)

Related Questions