Swift UI - 动画 - 糟糕的表现

发布于 2025-01-10 18:19:20 字数 5497 浏览 0 评论 0原文

我正在尝试使用 Swift UI 显示带有动画的自动滚动文本(选取框?)。
当鼠标悬停在文本上时,动画停止(这就是我存储动画当前状态的原因)。
使用最新的 M1 MBP 之一,这个简单的动画占用了高达 10% 的 CPU,我正在尝试了解原因。 Swift UI 不是为像这样的动画而设计的吗?还是我做错了什么?最后,它只是一个移动 x 偏移的动画。

这是我的字幕的代码。

import SwiftUI

private enum MarqueeState {
    case idle
    case animating
}

struct GeometryBackground: View {
    var body: some View {
        GeometryReader { geometry in
            Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
        }
    }
}

struct WidthKey: PreferenceKey {
    static var defaultValue = CGFloat(0)
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
    
    typealias Value = CGFloat
}

extension View {
    func myOffset(x: CGFloat, y: CGFloat) -> some View {
        return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
    }
    
    func myOffset(_ offset: CGSize) -> some View {
        return modifier(_OffsetEffect(offset: offset))
    }
}

struct PausableOffsetX: GeometryEffect {
    @Binding var currentOffset: CGFloat
    @Binding var contentWidth: CGFloat
    private var targetOffset: CGFloat = 0.0;
    
    var animatableData: CGFloat {
        get { targetOffset }
        set { targetOffset = newValue }
    }
    
    init(targetOffset: CGFloat, currentOffset: Binding<CGFloat>, contentWidth: Binding<CGFloat>) {
        self.targetOffset = targetOffset
        self._currentOffset = currentOffset
        self._contentWidth = contentWidth
    }
    
    
    public func effectValue(size: CGSize) -> ProjectionTransform {
        DispatchQueue.main.async {
            self.currentOffset = targetOffset
        }
        let relativeOffset = targetOffset.truncatingRemainder(dividingBy: contentWidth)
        let transform = CGAffineTransform(translationX: relativeOffset, y: 0)
        
        return ProjectionTransform(transform)
    }
}

struct Marquee<Content: View> : View {
    @State private var isOver: Bool = false
    
    private var content: () -> Content
    
    @State private var state: MarqueeState = .idle
    @State private var contentWidth: CGFloat = 0
    @State private var isAppear = false
    @State private var targetOffsetX: CGFloat = 0
    @State private var currentOffsetX: CGFloat
    
    public init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.currentOffsetX = 0
    }
    
    private func getAnimation() -> Animation {
        let duration = contentWidth / 30
        print("animation with duration of ", duration)
        return Animation.linear(duration: duration).repeatForever(autoreverses: false);
    }
    
    public var body : some View {
        GeometryReader { proxy in
            HStack(alignment: .center, spacing: 0) {
                if isAppear {
                    content()
                        .overlay(GeometryBackground())
                        .fixedSize()
                    
                    content()
                        .overlay(GeometryBackground())
                        .fixedSize()
                }
            }
            
            .modifier(PausableOffsetX(targetOffset: targetOffsetX, currentOffset: $currentOffsetX, contentWidth: $contentWidth))
            .onPreferenceChange(WidthKey.self, perform: { value in
                if value != self.contentWidth {
                    self.contentWidth = value
                    print("Content width = \(value)")
                    resetAnimation()
                }
            })
            .onAppear {
                self.isAppear = true
                resetAnimation()
            }
            .onDisappear {
                self.isAppear = false
            }
            .onHover(perform: { isOver in
                self.isOver = isOver
                checkAnimation()
            })
        }
        .frame(width: 400)
        .clipped()
    }
    
    private func getOffsetX() -> CGFloat {
        switch self.state {
            case .idle:
                return self.currentOffsetX
            case .animating:
                return -self.contentWidth + currentOffsetX
        }
    }
    
    private func checkAnimation() {
        if isOver{
            if self.state != .idle {
                pauseAnimation()
            }
        } else {
            if self.state != .animating {
                resumeAnimation()
            }
        }
    }
    
    private func pauseAnimation() {
        withAnimation(.linear(duration: 0)) {
            self.state = .idle
            self.targetOffsetX = getOffsetX()
        }
    }
    
    private func resumeAnimation() {
        print("Resume animation");
        withAnimation(getAnimation()) {
            self.state = .animating
            self.targetOffsetX = getOffsetX()
        }
    }
    
    private func resetAnimation() {
        withAnimation(.linear(duration: 0)) {
            self.currentOffsetX = 0
            self.targetOffsetX = 0
            self.state = .idle
        }
        resumeAnimation()
    }
}

我们可以按如下方式使用它:

Marquee {
            Text("Hello, world! Hello, world! Hello, world! Hello, world!").padding().fixedSize()
        }.frame(width: 300)

编辑 我最终使用了 Core Animation,而不是 Swift UI 中内置的动画。对 CPU/能源的影响绝对为零。因此,我不建议使用 Swift UI 动画来实现持久或持久的动画。

I'm trying to display an automatic scrolling text (marquee?) with an animation using Swift UI.
When the mouse is over the text, the animation stops (that's why I store the current state of the animation).
Using one of the latest M1 MBP, this simple animation is using up to 10% of CPU and I'm trying to understand why. Is Swift UI not made for animations like this one or am I doing something wrong? At the end, it's just an animation moving the x offset.

Here is the code of my Marquee.

import SwiftUI

private enum MarqueeState {
    case idle
    case animating
}

struct GeometryBackground: View {
    var body: some View {
        GeometryReader { geometry in
            Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
        }
    }
}

struct WidthKey: PreferenceKey {
    static var defaultValue = CGFloat(0)
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
    
    typealias Value = CGFloat
}

extension View {
    func myOffset(x: CGFloat, y: CGFloat) -> some View {
        return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
    }
    
    func myOffset(_ offset: CGSize) -> some View {
        return modifier(_OffsetEffect(offset: offset))
    }
}

struct PausableOffsetX: GeometryEffect {
    @Binding var currentOffset: CGFloat
    @Binding var contentWidth: CGFloat
    private var targetOffset: CGFloat = 0.0;
    
    var animatableData: CGFloat {
        get { targetOffset }
        set { targetOffset = newValue }
    }
    
    init(targetOffset: CGFloat, currentOffset: Binding<CGFloat>, contentWidth: Binding<CGFloat>) {
        self.targetOffset = targetOffset
        self._currentOffset = currentOffset
        self._contentWidth = contentWidth
    }
    
    
    public func effectValue(size: CGSize) -> ProjectionTransform {
        DispatchQueue.main.async {
            self.currentOffset = targetOffset
        }
        let relativeOffset = targetOffset.truncatingRemainder(dividingBy: contentWidth)
        let transform = CGAffineTransform(translationX: relativeOffset, y: 0)
        
        return ProjectionTransform(transform)
    }
}

struct Marquee<Content: View> : View {
    @State private var isOver: Bool = false
    
    private var content: () -> Content
    
    @State private var state: MarqueeState = .idle
    @State private var contentWidth: CGFloat = 0
    @State private var isAppear = false
    @State private var targetOffsetX: CGFloat = 0
    @State private var currentOffsetX: CGFloat
    
    public init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.currentOffsetX = 0
    }
    
    private func getAnimation() -> Animation {
        let duration = contentWidth / 30
        print("animation with duration of ", duration)
        return Animation.linear(duration: duration).repeatForever(autoreverses: false);
    }
    
    public var body : some View {
        GeometryReader { proxy in
            HStack(alignment: .center, spacing: 0) {
                if isAppear {
                    content()
                        .overlay(GeometryBackground())
                        .fixedSize()
                    
                    content()
                        .overlay(GeometryBackground())
                        .fixedSize()
                }
            }
            
            .modifier(PausableOffsetX(targetOffset: targetOffsetX, currentOffset: $currentOffsetX, contentWidth: $contentWidth))
            .onPreferenceChange(WidthKey.self, perform: { value in
                if value != self.contentWidth {
                    self.contentWidth = value
                    print("Content width = \(value)")
                    resetAnimation()
                }
            })
            .onAppear {
                self.isAppear = true
                resetAnimation()
            }
            .onDisappear {
                self.isAppear = false
            }
            .onHover(perform: { isOver in
                self.isOver = isOver
                checkAnimation()
            })
        }
        .frame(width: 400)
        .clipped()
    }
    
    private func getOffsetX() -> CGFloat {
        switch self.state {
            case .idle:
                return self.currentOffsetX
            case .animating:
                return -self.contentWidth + currentOffsetX
        }
    }
    
    private func checkAnimation() {
        if isOver{
            if self.state != .idle {
                pauseAnimation()
            }
        } else {
            if self.state != .animating {
                resumeAnimation()
            }
        }
    }
    
    private func pauseAnimation() {
        withAnimation(.linear(duration: 0)) {
            self.state = .idle
            self.targetOffsetX = getOffsetX()
        }
    }
    
    private func resumeAnimation() {
        print("Resume animation");
        withAnimation(getAnimation()) {
            self.state = .animating
            self.targetOffsetX = getOffsetX()
        }
    }
    
    private func resetAnimation() {
        withAnimation(.linear(duration: 0)) {
            self.currentOffsetX = 0
            self.targetOffsetX = 0
            self.state = .idle
        }
        resumeAnimation()
    }
}

And we can use it as follow:

Marquee {
            Text("Hello, world! Hello, world! Hello, world! Hello, world!").padding().fixedSize()
        }.frame(width: 300)

EDIT
I ended up using Core Animation instead of the one built in Swift UI. The cpu / Energy impact is an absolute zero. So I wouldn't recommend using Swift UI animation for long lasting or persistant animations.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

养猫人 2025-01-17 18:19:20

改进建议包括优化视图层次结构、避免不必要的状态更改、优化图像、利用硬件加速、解决内存管理问题、使用 Instruments 进行分析、在各种设备上进行测试、考虑使用 Core Animation 进行复杂动画以及确保使用最新的 SwiftUI版本和 Xcode。

Suggestions for improvement include optimizing the view hierarchy, avoiding unnecessary state changes, optimizing images, utilizing hardware acceleration, addressing memory management issues, profiling with Instruments, testing on various devices, considering Core Animation for complex animations, and ensuring the use of the latest SwiftUI versions and Xcode.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文