mirror of
https://github.com/yattee/yattee.git
synced 2026-10-29 16:07:20 +00:00
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:
130
Yattee/Views/Components/MarqueeLayerScroller.swift
Normal file
130
Yattee/Views/Components/MarqueeLayerScroller.swift
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user