I'm trying to create a circular text path in SwiftUI using a custom CircularTextPath view. The text is being drawn on an arc, and I want to add a background color behind the path, as shown in another image I have (for example, adding a solid background color to the entire text arc).
CircularTextPath:
import SwiftUI
struct CircularTextPath: View {
let text: String
let arcAngle: Angle
var body: some View {
if arcAngle.radians != 0.0 {
textAsPath
.fixedSize()
.hidden()
.overlay {
GeometryReader { fullText in
let textWidth = fullText.size.width
let startAngle = -(arcAngle.radians / 2)
let radius = arcAngle.radians > 0 ? textWidth * 1.5 / arcAngle.radians : textWidth * 2.0 / arcAngle.radians
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
Text(String(character))
.hidden()
.overlay {
GeometryReader { charSpace in
let midX = charSpace.frame(in: .named("FullText")).midX
let fraction = midX / textWidth
let angle = startAngle + (fraction * arcAngle.radians)
let xOffset = (textWidth / 2) - midX
TextPath(character: String(character))
.scaleEffect(x: 1, y: 1) // Flip horizontally to correct mirroring
.offset(y: -radius)
.rotationEffect(.radians(angle))
.offset(x: xOffset, y: radius)
}
}
}
}
.fixedSize()
.frame(width: textWidth)
}
}
.coordinateSpace(name: "FullText")
} else {
textAsPath
}
}
private var textAsPath: some View {
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
TextPath(character: String(character))
.scaleEffect(x: 1, y: 1) // Flip horizontally to correct mirroring
}
}
}
}
struct TextPath: View {
let character: String
var body: some View {
let path = characterToPath(character)
return path.fill(Color.primary)
}
private func characterToPath(_ character: String) -> Path {
let font = UIFont.systemFont(ofSize: 20)
let attributedString = NSAttributedString(string: character, attributes: [.font: font])
let line = CTLineCreateWithAttributedString(attributedString)
let path = CGMutablePath()
for run in (CTLineGetGlyphRuns(line) as! [CTRun]) {
for index in 0..<CTRunGetGlyphCount(run) {
var glyph = CGGlyph()
var position = CGPoint()
CTRunGetGlyphs(run, CFRangeMake(index, 1), &glyph)
CTRunGetPositions(run, CFRangeMake(index, 1), &position)
if let letterPath = CTFontCreatePathForGlyph(font, glyph, nil) {
let transform = CGAffineTransform(translationX: position.x, y: position.y).scaledBy(x: 1, y: -1) // Flip horizontally
path.addPath(letterPath, transform: transform)
}
}
}
return Path(path)
}
}
What I want to achieve: I want to add a background color behind the path, specifically behind the entire circular arc of text. How can I modify this code to add a solid background color to the path, similar to the effect shown in the other image I have?
What I've tried so far:
what output I got
output I want
what I want to do
when two or three lines
Converting a path that contains the outline of letters to an arc may be difficult. However, maybe there is an easier way.
If you know the arc angle and the radius then you can simply trim the path of a circle. Your example includes these values, so you should be able to stroke the trimmed circle without having to convert the text to a path.
Here is an example to show it working. I am using CurvedText
for this, as I was suggesting you use in my answer to your earlier question.
let arcDegrees: Double = 320
let radius: CGFloat = 90
let fontSize: CGFloat = 50
let strokeWidth: CGFloat = 70
var body: some View {
ZStack {
let endTrimFraction = (360.0 - arcDegrees) / (2 * 360.0)
Circle()
.trim(from: endTrimFraction, to: 1 - endTrimFraction)
.stroke(.black, lineWidth: strokeWidth)
.rotationEffect(.degrees(-90))
.padding(fontSize / 2)
// See https://stackoverflow.com/a/77280669/20386264
CurvedText(string: "I am he who remains", radius: -radius)
.offset(y: radius)
.font(.system(size: fontSize, weight: .black))
.foregroundStyle(.white)
}
.frame(width: (2 * radius) + fontSize, height: (2 * radius) + fontSize)
}
EDIT If I understand correctly, what you are aiming for is a way to vary the arc angle of some text dynamically, exactly as was being done in your earlier question, but also to show a colored background behind the text. The background should fit the text as the angle is changed.
So, based on the approach shown above, here is an adaption of the answer I previously provided to your other question. The main changes:
CircularText
now accepts a binding to a variable which is used to record the computed radius (4 lines of code added, no other changes).CircularTextView
displays the background behind the curved text.I found that it looks best if the background is slightly wider than the text. The easiest way to achieve this is to add leading and trailing spaces to the text being shown.
struct CircularTextView: View {
@State private var arcDegrees: Double = 0
@State private var textRadius = CGFloat.zero
let text: String = " I am he who remains " // 👈 with leading + trailing spaces
let fontSize: CGFloat = 30
let fontWeight: Font.Weight = .black
let textColor: Color = .white
let strokeColor: Color = .blue
let strokeWidth: CGFloat = 50
var body: some View {
VStack {
CircularText(text: text, arcAngle: .degrees(arcDegrees), textRadius: $textRadius)
.font(.system(size: fontSize, weight: fontWeight))
.foregroundStyle(textColor)
.background {
if arcDegrees == 0 {
strokeColor
.frame(height: strokeWidth)
} else {
let endTrimFraction = max(0, (360.0 - abs(arcDegrees)) / (2 * 360.0))
let diameter = 2 * abs(textRadius)
Circle()
.trim(from: endTrimFraction, to: 1 - endTrimFraction)
.stroke(strokeColor, lineWidth: strokeWidth)
.rotationEffect(.degrees(arcDegrees > 0 ? 90 : -90))
.frame(width: diameter, height: diameter)
.offset(y: textRadius)
}
}
.frame(height: 300)
.border(Color.gray.opacity(0.3))
Slider(value: $arcDegrees, in: -360...360, step: 1)
.padding(.horizontal)
Text("Arc Degrees: \(Int(arcDegrees))")
.font(.subheadline)
}
}
}
struct CircularText: View {
let text: String
let arcAngle: Angle
@Binding var textRadius: CGFloat // 👈 added
var body: some View {
if arcAngle.radians == 0.0 {
textAsChars
} else {
textAsChars
.fixedSize()
.hidden()
.overlay {
GeometryReader { fullText in
let textWidth = fullText.size.width
let startAngle = -(arcAngle.radians / 2)
let radius = textWidth / arcAngle.radians
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
Text(String(character))
.hidden()
.overlay {
GeometryReader { charSpace in
let midX = charSpace.frame(in: .named("FullText")).midX
let fraction = midX / textWidth
let angle = startAngle + (fraction * arcAngle.radians)
let xOffset = (textWidth / 2) - midX
Text(String(character))
.offset(y: -radius)
.rotationEffect(.radians(angle))
.offset(x: xOffset, y: radius)
}
}
}
}
.fixedSize()
.frame(width: textWidth)
.onChange(of: radius, initial: true) { _, newVal in // 👈 added
textRadius = newVal
}
}
}
.coordinateSpace(name: "FullText")
}
}
private var textAsChars: some View {
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
Text(String(character))
}
}
}
}