弹幕飘屏

swift版本

920 发布: 2026/8/12 10:26 本文总阅读量

使用方法

// 测试按钮
func addTestBtn() {
    let startBtn = testBtn(txt: "Start")
    startBtn.addTarget(self, action: #selector(clickStart), for: .touchUpInside)
    view.addSubview(startBtn)

    let stopBtn = testBtn(txt: "stop")
    stopBtn.addTarget(self, action: #selector(clickStop), for: .touchUpInside)
    view.addSubview(stopBtn)

    startBtn.frame = CGRectMake(20, 300, 50, 30)
    stopBtn.frame = CGRectMake(120, 300, 50, 30)
}

// 显示容器
@objc func clickStart() {
    HNBarrage.shared.showBarrage()
}

// 隐藏容器
@objc func clickStop() {
    HNBarrage.shared.hideBarrage()
}

func testBtn(txt: String) -> UIButton {
    let btn = BaseButton(type: .custom)
    btn.setTitle(txt, for: .normal)
    btn.backgroundColor = .hex("#ff0000")
    return btn
}

// 添加弹幕数据
override func doReturn() {
    HNBarrage.shared.show(data: [
        "content": "🔥 用户130****2345 充值了¥999",
        "des": "描述",
        "userId": 10001,
    ])

    HNBarrage.shared.show(dataList: [
        ["content": "第一条", "id": 1, "des": "描述"],
        ["content": "第二条", "id": 2, "des": "描述"],
        ["content": "第3条", "id": 3, "des": "描述"],
        ["content": "第4条", "id": 4, "des": ""],
        ["content": "第5条", "id": 5, "des": "描述"],
    ])
}

管理类文件

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