当填充形状超出第一个字符的位置(例如:“HELLO”中的“H”)时,如何动态更新
titleView
的前景色从黑色变为白色?到目前为止,我尝试使用 determineTitleStartAngle()
确定第一个字符的位置,但它仅在某些情况下有效
目前,动画看起来像这样(但文本应该是黑色而不是白色,因为填充形状位于第一个字符的位置之前):
如果填充形状经过第一个字符的位置,动画应该是这样的:
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)
}
}
显示文本的函数内部已知文本起始角度,因此如果让此函数在达到阈值时应用颜色更改,效果最佳。
但是,这个问题最难的部分是将文本颜色的动画与形状的动画分离。通常,当某些内容被动画化时,SwiftUI 会检查开始状态和结束状态,然后在它们之间进行插值。此处,开始状态具有黑色文本,结束状态具有白色文本。如果完整的动画由 SwiftUI 执行,您会看到颜色从第一时刻开始变化,而不是从进度角度位于文本开始时开始变化。我想你也发现了这一点,所以你给彩色动画添加了延迟!
为了仅在达到阈值时发生颜色变化,我认为需要
Animatable
视图修改器。至少,我设法让它以这种方式工作。
给你:
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 thresholdDegrees: CGFloat
let foregroundBegin: Color
let foregroundThreshold: Color
private var progressDegrees = CGFloat.zero
init(
foregroundBegin: Color,
foregroundThreshold: Color,
thresholdDegrees: CGFloat,
progressDegrees: CGFloat
) {
self.foregroundBegin = foregroundBegin
self.foregroundThreshold = foregroundThreshold
self.thresholdDegrees = thresholdDegrees
self.progressDegrees = progressDegrees
}
/// 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)
}
}
}