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.
This commit is contained in:
Arkadiusz Fal
2026-10-26 23:13:29 +01:00
parent c09e66cb5a
commit bebb62068a
2 changed files with 164 additions and 5 deletions

View File

@@ -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<Content: View>: 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<Content> {
MarqueeLayerScrollerView(content: content)
}
func updateUIView(_ view: MarqueeLayerScrollerView<Content>, context: Context) {
view.update(
content: content,
configuration: .init(
contentWidth: contentWidth,
distance: distance,
scrollDuration: scrollDuration,
delay: delay,
restartToken: restartToken
),
isPaused: isPaused
)
}
}
final class MarqueeLayerScrollerView<Content: View>: 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<Content>
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

View File

@@ -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 {