From bebb62068a3c4d098d8eaaab1dc21a1d2e4b0e77 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 26 Oct 2026 23:13:29 +0100 Subject: [PATCH] Scroll mini player marquee with Core Animation on iOS and tvOS The TimelineView re-rendered both mini player lines every frame (~17% of the main thread on an iPhone 12 mini, even while paused). The text is now drawn once in a hosted view and a repeating keyframe animation slides its layer in the render server. macOS keeps the TimelineView. --- .../Components/MarqueeLayerScroller.swift | 130 ++++++++++++++++++ Yattee/Views/Components/MarqueeText.swift | 39 +++++- 2 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 Yattee/Views/Components/MarqueeLayerScroller.swift diff --git a/Yattee/Views/Components/MarqueeLayerScroller.swift b/Yattee/Views/Components/MarqueeLayerScroller.swift new file mode 100644 index 00000000..5c7e2767 --- /dev/null +++ b/Yattee/Views/Components/MarqueeLayerScroller.swift @@ -0,0 +1,130 @@ +// +// MarqueeLayerScroller.swift +// Yattee +// +// Scrolls SwiftUI content with a Core Animation animation, which runs in +// the render server: no body evaluations or app CPU per frame. +// + +#if os(iOS) || os(tvOS) +import SwiftUI +import UIKit + +/// Shows `content` at the leading edge, then repeatedly waits `delay`, +/// scrolls it left by `distance` over `scrollDuration` and jumps back. +/// The content is expected to repeat itself after `distance` so the jump +/// back is seamless. +struct MarqueeLayerScroller: UIViewRepresentable { + let content: Content + let contentWidth: CGFloat + let distance: CGFloat + let scrollDuration: Double + let delay: Double + let isPaused: Bool + /// Restarts the cycle from the beginning when it changes (e.g. new text). + let restartToken: String + + func makeUIView(context: Context) -> MarqueeLayerScrollerView { + MarqueeLayerScrollerView(content: content) + } + + func updateUIView(_ view: MarqueeLayerScrollerView, context: Context) { + view.update( + content: content, + configuration: .init( + contentWidth: contentWidth, + distance: distance, + scrollDuration: scrollDuration, + delay: delay, + restartToken: restartToken + ), + isPaused: isPaused + ) + } +} + +final class MarqueeLayerScrollerView: UIView { + struct Configuration: Equatable { + var contentWidth: CGFloat + var distance: CGFloat + var scrollDuration: Double + var delay: Double + var restartToken: String + } + + private static var animationKey: String { "marquee" } + + private let hostingController: UIHostingController + private var configuration: Configuration? + private var isPaused = true + + init(content: Content) { + hostingController = UIHostingController(rootView: content) + // Laid out at a fixed frame; safe areas and size changes don't apply + hostingController.safeAreaRegions = [] + hostingController.sizingOptions = [] + super.init(frame: .zero) + clipsToBounds = true + // Taps go to the SwiftUI view around the marquee (e.g. a Button label) + isUserInteractionEnabled = false + + let hostView = hostingController.view! + hostView.backgroundColor = .clear + hostView.isUserInteractionEnabled = false + addSubview(hostView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(content: Content, configuration newConfiguration: Configuration, isPaused newIsPaused: Bool) { + hostingController.rootView = content + + let needsRestart = newConfiguration != configuration || newIsPaused != isPaused + configuration = newConfiguration + isPaused = newIsPaused + setNeedsLayout() + if needsRestart { + restartAnimation() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + guard let configuration else { return } + hostingController.view.frame = CGRect( + x: 0, y: 0, + width: configuration.contentWidth, + height: bounds.height + ) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + // Core Animation drops animations of layers that leave the window + restartAnimation() + } + + private func restartAnimation() { + let layer = hostingController.view.layer + layer.removeAnimation(forKey: Self.animationKey) + + guard window != nil, !isPaused, let configuration, + configuration.distance > 0, configuration.scrollDuration > 0 else { return } + + // Hold at the start for `delay`, then scroll linearly; repeating jumps + // back to the start, where the second copy of the content was. + let cycle = configuration.delay + configuration.scrollDuration + let animation = CAKeyframeAnimation(keyPath: "transform.translation.x") + animation.values = [0, 0, -configuration.distance] + animation.keyTimes = [0, NSNumber(value: configuration.delay / cycle), 1] + animation.calculationMode = .linear + animation.duration = cycle + animation.repeatCount = .infinity + animation.isRemovedOnCompletion = false + layer.add(animation, forKey: Self.animationKey) + } +} +#endif diff --git a/Yattee/Views/Components/MarqueeText.swift b/Yattee/Views/Components/MarqueeText.swift index 4f69b39f..24bd1a95 100644 --- a/Yattee/Views/Components/MarqueeText.swift +++ b/Yattee/Views/Components/MarqueeText.swift @@ -71,6 +71,7 @@ struct MarqueeText: View { // MARK: - Scrolling Content + #if os(macOS) private var scrollingContent: some View { // Paused outside the active scene or when the container says it is // hidden: the timeline otherwise re-renders every frame regardless. @@ -88,15 +89,35 @@ struct MarqueeText: View { } } } + #else + private var scrollingContent: some View { + // The text is drawn once and Core Animation slides it, so scrolling + // costs no per-frame work in the app. A TimelineView here re-rendered + // the text every frame (~17% of the main thread on an iPhone 12 mini + // for the mini player's two lines). + MarqueeLayerScroller( + content: HStack(spacing: spacing) { + plainTextView + plainTextView + }, + contentWidth: textWidth + totalScrollDistance, + distance: totalScrollDistance, + scrollDuration: totalScrollDistance / velocity, + delay: delayBeforeScrolling, + isPaused: scenePhase != .active || isMarqueePaused, + restartToken: text + ) + // Keeps measuring the text in this view tree (sizes the scroller) + .background(alignment: .leading) { + textView.hidden() + } + } + #endif // MARK: - Text View private var textView: some View { - Text(text) - .font(font.weight(fontWeight)) - .foregroundStyle(foregroundStyle) - .lineLimit(1) - .fixedSize() + plainTextView .measureWidth { width in if textWidth != width { textWidth = width @@ -104,6 +125,14 @@ struct MarqueeText: View { } } + private var plainTextView: some View { + Text(text) + .font(font.weight(fontWeight)) + .foregroundStyle(foregroundStyle) + .lineLimit(1) + .fixedSize() + } + // MARK: - Text Height private var textHeight: CGFloat {