168 lines
5.8 KiB
Swift
168 lines
5.8 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
struct ScoresTickerView: View {
|
|
var gamesOverride: [Game]? = nil
|
|
|
|
@Environment(GamesViewModel.self) private var viewModel
|
|
|
|
private var entries: [Game] {
|
|
gamesOverride ?? viewModel.games
|
|
}
|
|
|
|
var body: some View {
|
|
if !entries.isEmpty {
|
|
TickerMarqueeView(text: tickerText)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.frame(height: 22)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(.black.opacity(0.72))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
}
|
|
)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private var tickerText: String {
|
|
entries.map(tickerSummary(for:)).joined(separator: " | ")
|
|
}
|
|
|
|
private func tickerSummary(for game: Game) -> String {
|
|
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
|
|
|
if game.isLive, let linescore = game.linescore {
|
|
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
|
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
|
let outs = linescore.outs ?? 0
|
|
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
|
}
|
|
|
|
if game.isFinal {
|
|
return "\(matchup) FINAL"
|
|
}
|
|
|
|
if let startTime = game.startTime {
|
|
return "\(matchup) \(startTime.uppercased())"
|
|
}
|
|
|
|
return "\(matchup) \(game.status.label.uppercased())"
|
|
}
|
|
}
|
|
|
|
private struct TickerMarqueeView: UIViewRepresentable {
|
|
let text: String
|
|
|
|
func makeUIView(context: Context) -> TickerMarqueeContainerView {
|
|
let view = TickerMarqueeContainerView()
|
|
view.setText(text)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
|
|
uiView.setText(text)
|
|
}
|
|
}
|
|
|
|
private final class TickerMarqueeContainerView: UIView {
|
|
private let trackView = UIView()
|
|
private let primaryLabel = UILabel()
|
|
private let secondaryLabel = UILabel()
|
|
private let spacing: CGFloat = 48
|
|
private let pointsPerSecond: CGFloat = 64
|
|
|
|
private var currentText = ""
|
|
private var previousBoundsWidth: CGFloat = 0
|
|
private var previousContentWidth: CGFloat = 0
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
clipsToBounds = true
|
|
isUserInteractionEnabled = false
|
|
|
|
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
|
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
|
|
|
[primaryLabel, secondaryLabel].forEach { label in
|
|
label.font = roundedFont
|
|
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
|
label.numberOfLines = 1
|
|
label.lineBreakMode = .byClipping
|
|
trackView.addSubview(label)
|
|
}
|
|
|
|
addSubview(trackView)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setText(_ text: String) {
|
|
guard currentText != text else { return }
|
|
currentText = text
|
|
primaryLabel.text = text
|
|
secondaryLabel.text = text
|
|
previousContentWidth = 0
|
|
setNeedsLayout()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
|
|
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
|
let contentHeight = bounds.height
|
|
|
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
|
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
|
|
|
let cycleWidth = contentWidth + spacing
|
|
|
|
if contentWidth <= bounds.width {
|
|
trackView.layer.removeAllAnimations()
|
|
secondaryLabel.isHidden = true
|
|
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
|
primaryLabel.textAlignment = .left
|
|
previousBoundsWidth = bounds.width
|
|
previousContentWidth = contentWidth
|
|
return
|
|
}
|
|
|
|
primaryLabel.textAlignment = .left
|
|
secondaryLabel.isHidden = false
|
|
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
|
|
|
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
|
|| abs(previousContentWidth - contentWidth) > 0.5
|
|
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
|
|
|
|
previousBoundsWidth = bounds.width
|
|
previousContentWidth = contentWidth
|
|
|
|
guard shouldRestart else { return }
|
|
|
|
trackView.layer.removeAllAnimations()
|
|
trackView.transform = .identity
|
|
|
|
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
|
animation.fromValue = 0
|
|
animation.toValue = -cycleWidth
|
|
animation.duration = Double(cycleWidth / pointsPerSecond)
|
|
animation.repeatCount = .infinity
|
|
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
|
animation.isRemovedOnCompletion = false
|
|
|
|
trackView.layer.add(animation, forKey: "tickerMarquee")
|
|
}
|
|
}
|