Reputation: 41
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
maybe change needs in ShapeAlongPathView class
Upvotes: 1
Views: 33