How can I dynamically update the foreground color of the titleView
to change from black to white when the filled shape extends beyond the position of the first character (ex: 'H' in 'HELLO')? So far, I tried to determine the position of the first character using determineTitleStartAngle()
but it only works in some cases
Currently, the animation looks like this (but the text should be black instead of white since the fill shape is before the position of the first character):
This is what the animation should look like if the fill shape passes the position of the first character:
struct ContentView: View {
let size: CGFloat = 300
let startAngle: CGFloat = 180
let endAngle: CGFloat = 240
var midpointAngle: CGFloat {
(startAngle + endAngle) / 2
}
@State private var progressAngle: CGFloat = 180
@State private var filledEndAngle: CGFloat = 195
@State private var titleColor: Color = .black
var body: some View {
ZStack {
// background shape
ArcShape(start: startAngle, end: endAngle)
.foregroundColor(.gray)
// fill shape
ArcShape(start: startAngle, end: progressAngle)
.onAppear {
withAnimation(.easeInOut(duration: 0.6)) {
self.progressAngle = filledEndAngle
}
}
.foregroundColor(.green)
titleView(string: "HELLO")
.rotationEffect(.degrees((midpointAngle + 90)))
.font(.custom("AvenirNext-DemiBold", size: 12))
.foregroundColor(titleColor)
.onChange(of: progressAngle) { _, newValue in
if newValue >= determineTitleStartAngle() {
withAnimation(.easeInOut.delay(0.3)) {
titleColor = .white
}
}
}
}
.frame(width: size)
}
func determineTitleStartAngle() -> CGFloat {
let midpointAngle = (startAngle + endAngle) / 2
let titleStartAngle = (startAngle + midpointAngle) / 2
return titleStartAngle
}
func titleView(string: String) -> some View {
HStack(spacing: 2) {
ForEach(Array(string.enumerated()), id: \.offset) { index, character in
Text(String(character))
}
}
.hidden()
.overlay {
GeometryReader { fullText in
let textWidth = fullText.size.width
let radius = size * 0.36
let arcAngle = textWidth / radius
let startAngle = -(arcAngle / 2)
HStack(spacing: 2) {
ForEach(Array(string.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)
let xOffset = (textWidth / 2) - midX
Text(String(character))
.offset(y: -radius)
.rotationEffect(.radians(angle))
.offset(x: xOffset)
}
}
}
}
}
}
.coordinateSpace(name: "FullText")
}
}
struct ArcShape: Shape {
var start: CGFloat
var end: CGFloat
var animatableData: CGFloat {
get { end }
set { end = newValue }
}
func path(in rect: CGRect) -> Path {
let shorterLength = min(rect.width, rect.height)
let path = UIBezierPath(
roundedArcCenter: rect.center,
innerRadius: (shorterLength / 2) * 0.54,
outerRadius: (shorterLength / 2) * 0.90,
startAngle: Angle(degrees: start),
endAngle: Angle(degrees: end),
cornerRadiusPercentage: 0.01
)
return Path(path.cgPath)
}
}
#Preview {
ContentView()
}
extension UIBezierPath {
public convenience init(roundedArcCenter center: CGPoint, innerRadius: CGFloat, outerRadius: CGFloat, startAngle: Angle, endAngle: Angle, cornerRadiusPercentage: CGFloat) {
let maxCornerRadiusBasedOnInnerArcLength = abs((endAngle - startAngle).radians) * innerRadius / 2
let maxCornerRadiusBasedOnOuterArcLength = abs((endAngle - startAngle).radians) * outerRadius / 2
let maxCornerRadiusBasedOnEndCapLength = (outerRadius - innerRadius) / 2
let outerCornerRadius = min(2 * .pi * outerRadius * cornerRadiusPercentage, maxCornerRadiusBasedOnOuterArcLength, maxCornerRadiusBasedOnEndCapLength)
let outerCornerRadiusPercentage = outerCornerRadius / (2 * .pi * outerRadius)
let innerCornerRadius = min(2 * .pi * innerRadius * outerCornerRadiusPercentage, maxCornerRadiusBasedOnInnerArcLength, maxCornerRadiusBasedOnEndCapLength)
let innerInsetAngle = Angle(radians: innerCornerRadius / innerRadius)
let outerInsetAngle = Angle(radians: outerCornerRadius / outerRadius)
self.init()
var arcStartAngle = (startAngle + outerInsetAngle).radians
var arcEndAngle = (endAngle - outerInsetAngle).radians
addArc(
withCenter: .zero,
radius: outerRadius,
startAngle: min(arcStartAngle, arcEndAngle),
endAngle: max(arcStartAngle, arcEndAngle),
clockwise: true
)
addCorner(
to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: endAngle),
controlPoint: .pointOnCircle(radius: outerRadius, angle: endAngle)
)
addLine(to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: endAngle))
addCorner(
to: .pointOnCircle(radius: innerRadius, angle: endAngle - innerInsetAngle),
controlPoint: .pointOnCircle(radius: innerRadius, angle: endAngle)
)
arcStartAngle = (endAngle - innerInsetAngle).radians
arcEndAngle = (startAngle + innerInsetAngle).radians
addArc(
withCenter: .zero,
radius: innerRadius,
startAngle: max(arcStartAngle, arcEndAngle),
endAngle: min(arcStartAngle, arcEndAngle),
clockwise: false
)
addCorner(
to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: startAngle),
controlPoint: .pointOnCircle(radius: innerRadius, angle: startAngle)
)
addLine(to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: startAngle))
addCorner(
to: .pointOnCircle(radius: outerRadius, angle: startAngle + outerInsetAngle),
controlPoint: .pointOnCircle(radius: outerRadius, angle: startAngle)
)
apply(.init(translationX: center.x, y: center.y))
}
private func addCorner(to: CGPoint, controlPoint: CGPoint) {
let circleApproximationConstant = 0.551915
addCurve(
to: to,
controlPoint1: currentPoint + (controlPoint - currentPoint) * circleApproximationConstant,
controlPoint2: to + (controlPoint - to) * circleApproximationConstant
)
}
}
private extension CGPoint {
static func pointOnCircle(radius: CGFloat, angle: Angle) -> CGPoint {
CGPoint(x: radius * Darwin.cos(angle.radians), y: radius * Darwin.sin(angle.radians))
}
static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
}
}
public extension CGRect {
var center: CGPoint {
CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
}
The start-of-text angle is known inside the function that shows the text, so it works best if you let this function apply the color change when the threshold is reached.
However, the hardest part of this problem is to detach the animation of the text color from the animation of the shape. Normally, when something is animated, SwiftUI inspects the start state and the end state and then interpolates between them. Here, the start state has dark text and the end state has white text. If the full animation is performed by SwiftUI, you see the color changing from the first moment, not from the moment when the progress angle is at start-of-text. I think you must have discovered this too, which is perhaps why you added a delay to the color animation!
In order that the color change only happens when the threshold is reached, I think an Animatable
view modifier is required. At least, I managed to get it working this way.
Here you go:
struct ContentView: View {
let size: CGFloat = 300
let startAngle: CGFloat = 180
let endAngle: CGFloat = 240
var midpointAngle: CGFloat {
(startAngle + endAngle) / 2
}
@State private var progressAngle: CGFloat = 180
@State private var titleColor: Color = .black
var body: some View {
ZStack {
// background shape
ArcShape(start: startAngle, end: endAngle)
.foregroundColor(.gray)
// fill shape
ArcShape(start: startAngle, end: progressAngle)
.onAppear {
withAnimation(.linear(duration: 3)) {
self.progressAngle = endAngle
}
}
.foregroundColor(.green)
titleView(string: "HELLO")
.rotationEffect(.degrees((midpointAngle + 90)))
.font(.custom("AvenirNext-DemiBold", size: 12))
}
.frame(width: size)
}
// Ref. https://stackoverflow.com/q/77277211/20386264
func titleView(string: String) -> some View {
HStack(spacing: 2) {
ForEach(Array(string.enumerated()), id: \.offset) { index, character in
Text(String(character))
}
}
.hidden()
.overlay {
GeometryReader { fullText in
let textWidth = fullText.size.width
let radius = size * 0.36
let arcAngle = textWidth / radius
let startAngle = -(arcAngle / 2)
HStack(spacing: 2) {
ForEach(Array(string.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)
let xOffset = (textWidth / 2) - midX
Text(String(character))
.offset(y: -radius)
.rotationEffect(.radians(angle))
.offset(x: xOffset)
}
}
}
}
.modifier(
ForegroundColorModifier(
foregroundBegin: titleColor,
foregroundThreshold: .white,
thresholdDegrees: startAngle * 180 / CGFloat.pi,
progressDegrees: progressAngle - midpointAngle
)
)
}
}
.coordinateSpace(name: "FullText")
}
struct ForegroundColorModifier: ViewModifier, Animatable {
let foregroundBegin: Color
let foregroundThreshold: Color
let thresholdDegrees: CGFloat
var progressDegrees: CGFloat
/// Implementation of protocol property
var animatableData: CGFloat {
get { progressDegrees }
set { progressDegrees = newValue }
}
private var isThresholdReached: Bool {
progressDegrees >= thresholdDegrees
}
func body(content: Content) -> some View {
content
.foregroundColor(
isThresholdReached ? foregroundThreshold : foregroundBegin
)
.animation(
.easeInOut(duration: 0.15),
value: isThresholdReached
)
}
}
}
EDIT You might notice that the change of color seems to happen a bit late. This is because, the animated change only begins when the start-of-text angle is reached, so by the time the animation has completed, the progress has already gone past start-of-text. It looks a bit better if you allow the animation to start ahead of time by giving "advance warning" of progress.
If you wanted the animation to be finished by the time the progress reaches start-of-text, then the advance warning would be calculated as:
advance warning degrees =
(angle degrees) * (color animation time) / (arc animation time)
So if the color animation time would be increased to 0.3s, the arc animation time is 3.0s and the angle is 60 degrees, the advance warning would be 6 degrees.
I found that it works quite well if you use just ⅔ of this value. To try it, change the function isThresholdReached
to the following (and increase the duration of the color animation from 0.15 to 0.3):
private var isThresholdReached: Bool {
let advanceWarning = (0.3 / 3.0) * (60 * 2 / 3)
return (progressDegrees + advanceWarning) >= thresholdDegrees
}
It would probably be best if the arc angle and maybe even the arc animation time would be passed as init parameters to ForegroundColorModifier
.