如何确定 SwiftUI 中弯曲文本视图中第一个字符的角度?

问题描述 投票:0回答:1

当填充形状超出第一个字符的位置(例如:“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 textview
1个回答
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)
        }
    }
}

Animation

© www.soinside.com 2019 - 2024. All rights reserved.