mirror of
https://github.com/yattee/yattee.git
synced 2026-11-01 09:27:18 +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
|
// MARK: - Scrolling Content
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
private var scrollingContent: some View {
|
private var scrollingContent: some View {
|
||||||
// Paused outside the active scene or when the container says it is
|
// Paused outside the active scene or when the container says it is
|
||||||
// hidden: the timeline otherwise re-renders every frame regardless.
|
// 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
|
// MARK: - Text View
|
||||||
|
|
||||||
private var textView: some View {
|
private var textView: some View {
|
||||||
Text(text)
|
plainTextView
|
||||||
.font(font.weight(fontWeight))
|
|
||||||
.foregroundStyle(foregroundStyle)
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize()
|
|
||||||
.measureWidth { width in
|
.measureWidth { width in
|
||||||
if textWidth != width {
|
if textWidth != width {
|
||||||
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
|
// MARK: - Text Height
|
||||||
|
|
||||||
private var textHeight: CGFloat {
|
private var textHeight: CGFloat {
|
||||||
|
|||||||
Reference in New Issue
Block a user