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") } }