最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - Overlaying Text Along a Shape Path in SwiftUI — Issues with Text Following the Shape Border - Stack Overflow

programmeradmin0浏览0评论

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:

  • I’m using Path to create the shapes and their respective borders.
  • I’ve implemented a ShapeAlongPathView that uses the calculatePositionAndAngle method to place text characters along the path.
  • I’ve used a drag gesture to allow the user to move the shapes and the text positions accordingly.
  • The text string is intended to follow the shape’s border, but it doesn't work for some shapes like stars or ovals.

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:

  • For squares and rectangles, the text wraps correctly around the edges.
  • For stars and ovals, the text gets distorted or doesn't follow the path smoothly.

I would appreciate any help on:

  • Suggestions on how I can ensure the text aligns properly with any arbitrary shape's border.
  • Any best practices or tips to improve the accuracy of this text placement along complex paths.
  • Suggestions for handling dynamic shapes where the user can drag the shape around and the text should adjust accordingly.

this is the result what I got right now

maybe change needs in ShapeAlongPathView class

发布评论

评论列表(0)

  1. 暂无评论