//
// HNBarrage.swift
// iyz
//
import UIKit
/// 全局飘屏管理器。数据会保留到对应飘屏动画结束。
@objcMembers
final class HNBarrage: NSObject {
static let shared = HNBarrage()
/// 飘屏行数,最小为 1。
var rowCount: Int = 2 {
didSet { rowCount = max(1, rowCount) }
}
/// 飘屏速度,单位为 point/秒。
var speed: CGFloat = 90 {
didSet { speed = max(1, speed) }
}
/// 当前 item 的最右侧离开屏幕右侧后,与下一条之间保留的距离,单位为 point。
var itemSpacing: CGFloat = 40 {
didSet { itemSpacing = max(0, itemSpacing) }
}
/// 两条轨道之间的垂直间距,单位为 point。
var rowSpacing: CGFloat = 20 {
didSet { rowSpacing = max(0, rowSpacing) }
}
private var pendingItems: [[String: Any]] = []
private var displayingItems: [UUID: [String: Any]] = [:]
private var occupiedRows = Set<Int>()
private var barrageView: HNBarrageContainerView?
private var isBarrageHidden = false
override private init() {
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 添加一条飘屏数据。
/// 文案依次读取 `content`、`text`、`message`、`title` 字段。
func show(data: [String: Any]) {
DispatchQueue.main.async { [weak self] in
self?.pendingItems.append(data)
self?.displayIfPossible()
}
}
/// 批量添加飘屏数据。
func show(dataList: [[String: Any]]) {
DispatchQueue.main.async { [weak self] in
self?.pendingItems.append(contentsOf: dataList)
self?.displayIfPossible()
}
}
/// 隐藏飘屏视图。隐藏期间队列和动画仍正常运行。
func hideBarrage() {
DispatchQueue.main.async { [weak self] in
self?.isBarrageHidden = true
self?.barrageView?.isHidden = true
}
}
/// 显示飘屏视图;没有未完成的数据时保持隐藏。
func showBarrage() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isBarrageHidden = false
guard self.hasUnfinishedData else {
self.barrageView?.isHidden = true
return
}
self.displayIfPossible()
self.barrageView?.isHidden = false
}
}
private func displayIfPossible() {
guard !pendingItems.isEmpty,
let window = Self.topWindow else { return }
let container = prepareContainer(in: window)
while !pendingItems.isEmpty,
let row = (0 ..< rowCount).first(where: { !occupiedRows.contains($0) }) {
let data = pendingItems.removeFirst()
let identifier = UUID()
occupiedRows.insert(row)
displayingItems[identifier] = data
container.display(
data: data,
row: row,
speed: speed,
itemSpacing: itemSpacing,
rowSpacing: rowSpacing,
identifier: identifier,
rowAvailable: { [weak self] row in
guard let self else { return }
self.occupiedRows.remove(row)
self.displayIfPossible()
},
completion: { [weak self] identifier in
guard let self else { return }
self.displayingItems.removeValue(forKey: identifier)
self.hideContainerIfNeeded()
}
)
}
}
private func prepareContainer(in window: UIWindow) -> HNBarrageContainerView {
if let barrageView, barrageView.superview === window {
barrageView.frame = window.bounds
barrageView.isHidden = isBarrageHidden
window.bringSubviewToFront(barrageView)
return barrageView
}
let container = barrageView ?? HNBarrageContainerView(frame: window.bounds)
container.frame = window.bounds
container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(container)
container.isHidden = isBarrageHidden
window.bringSubviewToFront(container)
barrageView = container
return container
}
private func hideContainerIfNeeded() {
guard pendingItems.isEmpty, displayingItems.isEmpty else { return }
barrageView?.isHidden = true
}
private var hasUnfinishedData: Bool {
!pendingItems.isEmpty || !displayingItems.isEmpty
}
/// 当前前台场景中层级最高的普通业务 Window。
private static var topWindow: UIWindow? {
if #available(iOS 13.0, *) {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
let windows = scenes.flatMap(\.windows).filter { !$0.isHidden && $0.alpha > 0 }
return windows.max { lhs, rhs in
if lhs.windowLevel == rhs.windowLevel {
return !lhs.isKeyWindow && rhs.isKeyWindow
}
return lhs.windowLevel < rhs.windowLevel
}
}
return UIApplication.shared.windows
.filter { !$0.isHidden && $0.alpha > 0 }
.max { $0.windowLevel < $1.windowLevel }
}
}
private final class HNBarrageContainerView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
addGestureRecognizer(tap)
}
required init?(coder: NSCoder) {
return nil
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
subviews.contains { item in
guard !item.isHidden, item.alpha > 0 else { return false }
return (item.layer.presentation()?.frame ?? item.frame).contains(point)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
self.point(inside: point, with: event) ? self : nil
}
@objc private func handleTap(_ tap: UITapGestureRecognizer) {
let point = tap.location(in: self)
guard let item = subviews.reversed().compactMap({ $0 as? HNBarrageItem }).first(where: {
($0.layer.presentation()?.frame ?? $0.frame).contains(point)
}) else { return }
item.handleTap()
}
func display(
data: [String: Any],
row: Int,
speed: CGFloat,
itemSpacing: CGFloat,
rowSpacing: CGFloat,
identifier: UUID,
rowAvailable: @escaping (Int) -> Void,
completion: @escaping (UUID) -> Void
) {
layoutIfNeeded()
let item = HNBarrageItem(data: data)
addSubview(item)
let safeTop = safeAreaInsets.top
let itemHeight: CGFloat = 36
let firstRowTop = safeTop + 55
let itemSize = item.sizeThatFits(
CGSize(width: max(80, bounds.width - 24), height: itemHeight)
)
let itemWidth = min(max(80, itemSize.width), max(80, bounds.width - 24))
let y = firstRowTop + CGFloat(row) * (itemHeight + rowSpacing)
item.frame = CGRect(x: bounds.width, y: y, width: itemWidth, height: itemHeight)
let distance = bounds.width + itemWidth
let duration = TimeInterval(distance / max(1, speed))
// item 初始左侧位于屏幕右边缘;需先移动自身宽度使其右侧进入屏幕,
// 再移动 itemSpacing,下一条才从同一轨道的屏幕右侧出现。
let rowOccupiedDuration = TimeInterval((itemWidth + itemSpacing) / max(1, speed))
DispatchQueue.main.asyncAfter(deadline: .now() + rowOccupiedDuration) {
rowAvailable(row)
}
UIView.animate(
withDuration: duration,
delay: 0,
options: [.curveLinear, .allowUserInteraction],
animations: { item.frame.origin.x = -itemWidth },
completion: { _ in
item.removeFromSuperview()
completion(identifier)
}
)
}
}
private final class HNBarrageItem: UIControl {
private let data: [String: Any]
private let gradientLayer = CAGradientLayer()
private let whiteOverlayLayer = CALayer()
private let iconLabel = UILabel()
private let contentLabel = UILabel()
private let desLabel = UILabel()
init(data: [String: Any]) {
self.data = data
super.init(frame: .zero)
configureUI()
}
required init?(coder: NSCoder) {
return nil
}
private func configureUI() {
backgroundColor = .clear
layer.cornerRadius = 8
layer.borderWidth = 1
layer.borderColor = UIColor.hex("#8F5FFF", 0.2).cgColor
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.1
layer.shadowRadius = 3
layer.shadowOffset = CGSize(width: 0, height: 1)
gradientLayer.colors = [
UIColor.hex("#6F07F4").cgColor,
UIColor.hex("#AA07F1").cgColor,
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.cornerRadius = 8
layer.insertSublayer(gradientLayer, at: 0)
whiteOverlayLayer.backgroundColor = UIColor.hex("#FFFFFF", 0.9).cgColor
whiteOverlayLayer.cornerRadius = 8
layer.insertSublayer(whiteOverlayLayer, above: gradientLayer)
iconLabel.font = .systemFont(ofSize: 16)
iconLabel.textAlignment = .center
iconLabel.text = "🔥"
contentLabel.font = .systemFont(ofSize: 13)
contentLabel.textColor = UIColor(white: 0.38, alpha: 1)
contentLabel.lineBreakMode = .byTruncatingTail
contentLabel.text = Self.text(from: data)
let des = data["des"] as? String ?? ""
desLabel.font = .systemFont(ofSize: 13)
desLabel.textColor = UIColor.hex("#8F45F2")
desLabel.lineBreakMode = .byTruncatingTail
desLabel.text = des
desLabel.isHidden = des.isEmpty
addSubview(iconLabel)
addSubview(contentLabel)
addSubview(desLabel)
}
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
gradientLayer.frame = bounds
whiteOverlayLayer.frame = bounds
CATransaction.commit()
let horizontalInset: CGFloat = 10
let iconWidth: CGFloat = 20
let elementSpacing: CGFloat = 4
let desSpacing: CGFloat = desLabel.isHidden ? 0 : 12
let desWidth = desLabel.isHidden ? 0 : ceil(desLabel.intrinsicContentSize.width)
iconLabel.frame = CGRect(
x: horizontalInset,
y: 0,
width: iconWidth,
height: bounds.height
)
contentLabel.frame = CGRect(
x: iconLabel.frame.maxX + elementSpacing,
y: 0,
width: max(
0,
bounds.width - iconLabel.frame.maxX - elementSpacing - desSpacing - desWidth - horizontalInset
),
height: bounds.height
)
desLabel.frame = CGRect(
x: contentLabel.frame.maxX + desSpacing,
y: 0,
width: desWidth,
height: bounds.height
)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let horizontalInsets: CGFloat = 20
let iconWidth: CGFloat = 20
let elementSpacing: CGFloat = 4
let desSpacing: CGFloat = desLabel.isHidden ? 0 : 12
let desWidth = desLabel.isHidden ? 0 : ceil(desLabel.intrinsicContentSize.width)
let fixedWidth = horizontalInsets + iconWidth + elementSpacing + desSpacing + desWidth
let labelSize = contentLabel.sizeThatFits(
CGSize(width: max(0, size.width - fixedWidth), height: size.height)
)
return CGSize(width: ceil(labelSize.width) + fixedWidth, height: size.height)
}
func handleTap() {
debug.log("HNBarrage item data: \(data)")
}
private static func text(from data: [String: Any]) -> String {
for key in ["content", "text", "message", "title"] {
if let text = data[key] as? String, !text.isEmpty {
return text
}
}
return String(describing: data)
}
}