// // ReceiveMessagePopView.swift // QuickLocation // import UIKit struct ReceiveMessageDisplayItem { let senderName: String let avatar: UIImage? let image: UIImage? let message: String } final class ReceiveMessagePopView: UIView { private static let shared = ReceiveMessagePopView( frame: CGRect(origin: .zero, size: kScreenSize) ) private var messages: [ReceiveMessageDisplayItem] = [] private var item: ReceiveMessageDisplayItem? private var onBurn: ((ReceiveMessageDisplayItem) -> Void)? private var isBurning = false private var effectLayers: [CALayer] = [] private var burnDisplayLink: CADisplayLink? private var burnStartTime: CFTimeInterval = 0 private let burnDuration: CFTimeInterval = 1.05 private var burnLetterFrame = CGRect.zero private var burnMaskLayer: CAShapeLayer? private var charEdgeLayer: CAShapeLayer? private var fireEdgeLayer: CAShapeLayer? private var hotEdgeLayer: CAShapeLayer? private var burnEmitterLayer: CAEmitterLayer? static func show( item: ReceiveMessageDisplayItem, onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil ) { show(items: [item], onBurn: onBurn) } static func show( items: [ReceiveMessageDisplayItem], onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil ) { guard let firstItem = items.first, let window = kKeyWindow else { return } let popup = ReceiveMessagePopView.shared popup.removeFromSuperview() popup.resetBurnState() popup.messages = items popup.item = firstItem popup.onBurn = onBurn popup.configure(item: firstItem) window.addSubview(popup) window.bringSubviewToFront(popup) popup.layoutIfNeeded() popup.overlayView.alpha = 0 popup.letterView.alpha = 0 UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { popup.overlayView.alpha = 1 } popup.animateInitialFlyIn() } private let overlayView: UIView = { let view = UIView() view.backgroundColor = UIColor.black.withAlphaComponent(0.62) return view }() private let letterView = UIView() private let cardBackgroundView: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/receive_message_card")) view.contentMode = .scaleToFill return view }() private let titleImageView: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/receive_message_title")) view.contentMode = .scaleAspectFit return view }() private let avatarView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 16 view.layer.borderColor = UIColor.white.cgColor view.layer.borderWidth = 4 return view }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .semibold) label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center return label }() private let relationLabel: UILabel = { let label = UILabel() label.text = "💕" label.font = .systemFont(ofSize: 15) return label }() private let messageImageView: UIImageView = { let view = UIImageView() view.backgroundColor = UIColor(hexStr: "#D9D9D9") view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 36 return view }() private let messageLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .medium) label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center label.numberOfLines = 0 label.lineBreakMode = .byTruncatingTail return label }() private let burnButton: UIButton = { let button = UIButton(type: .custom) button.setTitle("阅后即焚", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = FontManager.boboBold(18) button.backgroundColor = UIColor(hexStr: "#293445") button.layer.cornerRadius = 20 return button }() override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { addSubview(overlayView) addSubview(letterView) overlayView.layoutChain.edges() letterView.layoutChain .edgesHorzontal(26) .centerY() letterView.addSubview(cardBackgroundView) letterView.addSubview(titleImageView) letterView.addSubview(avatarView) letterView.addSubview(nameLabel) letterView.addSubview(messageImageView) letterView.addSubview(messageLabel) letterView.addSubview(burnButton) cardBackgroundView.layoutChain .top(38) .edgesHorzontal() .heightToWidth(480/323) .bottom() titleImageView.layoutChain .top() .left(18) .width(180) .height(56) avatarView.layoutChain .topToBottomOfView(titleImageView, offset: 5) .centerX() .width(40) .height(40) nameLabel.layoutChain .topToBottomOfView(avatarView, offset: 5) .centerX() messageImageView.layoutChain .topToBottomOfView(nameLabel, offset: 16) .centerX() .width(200) .height(224) messageLabel.layoutChain .topToBottomOfView(messageImageView, offset: 13) .edgesHorzontal(30) burnButton.layoutChain .bottom(25) .edgesHorzontal(24) .height(56) burnButton.addTarget(self, action: #selector(tapBurn), for: .touchUpInside) } private func configure(item: ReceiveMessageDisplayItem) { nameLabel.text = item.senderName.isEmpty ? "圈子成员" : item.senderName avatarView.image = item.avatar ?? UIImage(named: "UserIcon/1") messageImageView.image = item.image messageLabel.text = String(item.message.prefix(20)) } private func animateInitialFlyIn() { burnButton.isUserInteractionEnabled = false letterView.alpha = 1 letterView.transform = .identity letterView.layer.transform = CATransform3DIdentity letterView.layer.shadowColor = UIColor.black.cgColor letterView.layer.shadowRadius = 18 letterView.layer.shadowOffset = CGSize(width: 0, height: 12) letterView.layer.shadowOpacity = 0 letterView.layer.shouldRasterize = true letterView.layer.rasterizationScale = UIScreen.main.scale func flightTransform( x: CGFloat, y: CGFloat, z: CGFloat, scale: CGFloat, rotateZ: CGFloat, rotateY: CGFloat ) -> CATransform3D { var transform = CATransform3DIdentity transform.m34 = -1 / 800 transform = CATransform3DTranslate(transform, x, y, z) transform = CATransform3DRotate(transform, rotateY, 0, 1, 0) transform = CATransform3DRotate(transform, rotateZ, 0, 0, 1) return CATransform3DScale(transform, scale, scale, 1) } let rotationSteps = 16 let startX = min(bounds.width * 0.75, 280) let startY = min(bounds.height * 0.48, 370) let transformValues: [NSValue] = (0...rotationSteps).map { index in guard index < rotationSteps else { return NSValue(caTransform3D: CATransform3DIdentity) } let progress = CGFloat(index) / CGFloat(rotationSteps) let remaining = 1 - progress let distanceProgress = remaining * remaining var x = -startX * distanceProgress var y = -startY * distanceProgress var z = -650 * distanceProgress var scale = 0.88 + 0.12 * (1 - distanceProgress) if index == rotationSteps - 1 { x = -5 y = 4 z = 28 scale = 1.018 } return NSValue( caTransform3D: flightTransform( x: x, y: y, z: z, scale: scale, rotateZ: -.pi * 8 * remaining, rotateY: -.pi * 0.12 * remaining ) ) } let transformKeyTimes: [NSNumber] = (0...rotationSteps).map { NSNumber(value: Double($0) / Double(rotationSteps)) } let transformAnimation = CAKeyframeAnimation(keyPath: "transform") transformAnimation.values = transformValues transformAnimation.keyTimes = transformKeyTimes transformAnimation.timingFunctions = Array( repeating: CAMediaTimingFunction(name: .linear), count: rotationSteps ) let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity") opacityAnimation.values = [0.35, 0.6, 0.82, 1, 1, 1] opacityAnimation.keyTimes = [0, 0.22, 0.45, 0.68, 0.86, 1] let shadowAnimation = CAKeyframeAnimation(keyPath: "shadowOpacity") shadowAnimation.values = [0.02, 0.04, 0.1, 0.18, 0.3, 0] shadowAnimation.keyTimes = [0, 0.22, 0.45, 0.68, 0.86, 1] let group = CAAnimationGroup() group.animations = [transformAnimation, opacityAnimation, shadowAnimation] group.duration = 0.72 group.isRemovedOnCompletion = true letterView.layer.add(group, forKey: "initialFlight") DispatchQueue.main.asyncAfter(deadline: .now() + 0.74) { [weak self] in guard let self, !self.isBurning else { return } self.letterView.layer.shouldRasterize = false self.letterView.layer.shadowOpacity = 0 self.letterView.layer.shadowRadius = 0 self.letterView.layer.shadowOffset = .zero self.burnButton.isUserInteractionEnabled = true } } @objc private func tapBurn() { guard !isBurning else { return } isBurning = true burnButton.isUserInteractionEnabled = false playBurnAnimation() } private func playBurnAnimation() { layoutIfNeeded() burnLetterFrame = letterView.convert(letterView.bounds, to: self) let maskLayer = CAShapeLayer() maskLayer.frame = letterView.bounds maskLayer.fillColor = UIColor.white.cgColor letterView.layer.mask = maskLayer burnMaskLayer = maskLayer addBurningEdges() addBurnParticles() burnStartTime = CACurrentMediaTime() updateBurn(progress: 0, timestamp: burnStartTime) let displayLink = CADisplayLink(target: self, selector: #selector(updateBurnFrame(_:))) displayLink.add(to: .main, forMode: .common) burnDisplayLink = displayLink } @objc private func updateBurnFrame(_ displayLink: CADisplayLink) { let elapsed = max(0, displayLink.timestamp - burnStartTime) let progress = min(1, elapsed / burnDuration) updateBurn(progress: CGFloat(progress), timestamp: displayLink.timestamp) guard progress >= 1 else { return } displayLink.invalidate() burnDisplayLink = nil burnEmitterLayer?.birthRate = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in self?.finishBurn() } } private func updateBurn(progress: CGFloat, timestamp: CFTimeInterval) { let localPoints = burnBoundaryPoints(progress: progress, timestamp: timestamp) let maskPath = UIBezierPath() maskPath.move(to: .zero) maskPath.addLine(to: CGPoint(x: letterView.bounds.width, y: 0)) for point in localPoints.reversed() { maskPath.addLine(to: point) } maskPath.close() let edgePath = UIBezierPath() for (index, point) in localPoints.enumerated() { let converted = CGPoint( x: burnLetterFrame.minX + point.x, y: burnLetterFrame.minY + point.y ) if index == 0 { edgePath.move(to: converted) } else { edgePath.addLine(to: converted) } } let averageY = localPoints.reduce(CGFloat.zero) { $0 + $1.y } / CGFloat(max(localPoints.count, 1)) let edgeOpacity = Float(min(1, max(0, (1 - progress) * 5))) CATransaction.begin() CATransaction.setDisableActions(true) burnMaskLayer?.path = maskPath.cgPath charEdgeLayer?.path = edgePath.cgPath fireEdgeLayer?.path = edgePath.cgPath hotEdgeLayer?.path = edgePath.cgPath charEdgeLayer?.opacity = edgeOpacity fireEdgeLayer?.opacity = edgeOpacity hotEdgeLayer?.opacity = edgeOpacity burnEmitterLayer?.emitterPosition = CGPoint( x: burnLetterFrame.midX, y: burnLetterFrame.minY + averageY ) CATransaction.commit() } private func burnBoundaryPoints(progress: CGFloat, timestamp: CFTimeInterval) -> [CGPoint] { let width = letterView.bounds.width let height = letterView.bounds.height if progress >= 0.995 { return stride(from: CGFloat.zero, through: width, by: 9).map { CGPoint(x: $0, y: 0) } } let baseY = height * (1 - progress) let phase = CGFloat(timestamp) * 13 var points: [CGPoint] = [] var x: CGFloat = 0 while x < width { let wideWave = sin(x * 0.052 + phase) * 6 let smallWave = sin(x * 0.17 - phase * 1.4) * 2.8 let flicker = sin((x + phase * 18) * 0.31) * 1.8 let y = min(height, max(0, baseY + wideWave + smallWave + flicker)) points.append(CGPoint(x: x, y: y)) x += 9 } points.append(CGPoint(x: width, y: min(height, max(0, baseY)))) return points } private func addBurningEdges() { let charLayer = makeEdgeLayer( color: UIColor(hexStr: "#2A1710", alpha: 0.8), width: 10 ) let fireLayer = makeEdgeLayer( color: UIColor(hexStr: "#FF5A20"), width: 5.5 ) fireLayer.shadowColor = UIColor(hexStr: "#FF4B16").cgColor fireLayer.shadowOpacity = 0.85 fireLayer.shadowRadius = 7 fireLayer.shadowOffset = .zero let hotLayer = makeEdgeLayer( color: UIColor(hexStr: "#FFD75A"), width: 1.8 ) layer.addSublayer(charLayer) layer.addSublayer(fireLayer) layer.addSublayer(hotLayer) effectLayers.append(contentsOf: [charLayer, fireLayer, hotLayer]) charEdgeLayer = charLayer fireEdgeLayer = fireLayer hotEdgeLayer = hotLayer } private func makeEdgeLayer(color: UIColor, width: CGFloat) -> CAShapeLayer { let shapeLayer = CAShapeLayer() shapeLayer.frame = bounds shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.strokeColor = color.cgColor shapeLayer.lineWidth = width shapeLayer.lineCap = .round shapeLayer.lineJoin = .round return shapeLayer } private func addBurnParticles() { let emitter = CAEmitterLayer() emitter.frame = bounds emitter.emitterShape = .line emitter.emitterMode = .surface emitter.emitterSize = CGSize(width: burnLetterFrame.width - 40, height: 1) emitter.emitterPosition = CGPoint(x: burnLetterFrame.midX, y: burnLetterFrame.maxY) let ember = CAEmitterCell() ember.contents = makeEmberImage() ember.birthRate = 38 ember.lifetime = 0.58 ember.lifetimeRange = 0.18 ember.velocity = 62 ember.velocityRange = 34 ember.emissionLongitude = -.pi / 2 ember.emissionRange = .pi / 2.8 ember.scale = 0.65 ember.scaleRange = 0.3 ember.spinRange = .pi * 2 ember.alphaSpeed = -1.5 ember.yAcceleration = -22 let ash = CAEmitterCell() ash.contents = makeAshImage() ash.birthRate = 22 ash.lifetime = 0.8 ash.lifetimeRange = 0.2 ash.velocity = 38 ash.velocityRange = 20 ash.emissionLongitude = -.pi / 2 ash.emissionRange = .pi / 2 ash.scale = 0.7 ash.scaleRange = 0.3 ash.spin = 1.4 ash.spinRange = .pi * 2 ash.alphaSpeed = -1 let smoke = CAEmitterCell() smoke.contents = makeParticleImage( color: UIColor(hexStr: "#696969", alpha: 0.22), size: CGSize(width: 11, height: 11) ) smoke.birthRate = 8 smoke.lifetime = 0.9 smoke.lifetimeRange = 0.25 smoke.velocity = 30 smoke.velocityRange = 16 smoke.emissionLongitude = -.pi / 2 smoke.emissionRange = .pi / 3 smoke.scale = 0.55 smoke.scaleRange = 0.2 smoke.scaleSpeed = 0.12 smoke.alphaSpeed = -0.7 smoke.yAcceleration = -8 emitter.emitterCells = [ember, ash, smoke] layer.addSublayer(emitter) effectLayers.append(emitter) burnEmitterLayer = emitter } private func makeParticleImage(color: UIColor, size: CGSize) -> CGImage? { UIGraphicsImageRenderer(size: size).image { context in context.cgContext.setFillColor(color.cgColor) context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) }.cgImage } private func makeEmberImage() -> CGImage? { let size = CGSize(width: 3, height: 8) return UIGraphicsImageRenderer(size: size).image { _ in UIColor(hexStr: "#FF982A").setFill() UIBezierPath( roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 1.5 ).fill() }.cgImage } private func makeAshImage() -> CGImage? { let size = CGSize(width: 5, height: 3) return UIGraphicsImageRenderer(size: size).image { _ in UIColor(hexStr: "#4B403A", alpha: 0.72).setFill() let path = UIBezierPath() path.move(to: CGPoint(x: 0, y: 2.4)) path.addLine(to: CGPoint(x: 1.3, y: 0)) path.addLine(to: CGPoint(x: 5, y: 1.1)) path.addLine(to: CGPoint(x: 3.8, y: 3)) path.close() path.fill() }.cgImage } private func finishBurn() { guard isBurning, let item else { return } let completion = onBurn if !messages.isEmpty { messages.removeFirst() } guard let nextItem = messages.first else { dismiss(animated: false) completion?(item) return } resetBurnState() self.item = nextItem configure(item: nextItem) letterView.alpha = 0 letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { self.letterView.alpha = 1 self.letterView.transform = .identity } completion?(item) } private func dismiss(animated: Bool) { guard superview != nil else { return } let completion = { [weak self] in guard let self else { return } self.removeFromSuperview() self.resetBurnState() self.messages.removeAll() self.item = nil self.onBurn = nil } guard animated else { completion() return } UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { self.overlayView.alpha = 0 self.letterView.alpha = 0 self.letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) } completion: { _ in completion() } } private func resetBurnState() { burnDisplayLink?.invalidate() burnDisplayLink = nil isBurning = false burnButton.isUserInteractionEnabled = true letterView.alpha = 1 letterView.transform = .identity letterView.layer.transform = CATransform3DIdentity letterView.layer.mask = nil letterView.layer.removeAllAnimations() letterView.layer.shouldRasterize = false letterView.layer.shadowOpacity = 0 letterView.layer.shadowRadius = 0 letterView.layer.shadowOffset = .zero for layer in effectLayers { layer.removeAllAnimations() layer.removeFromSuperlayer() } effectLayers.removeAll() burnMaskLayer = nil charEdgeLayer = nil fireEdgeLayer = nil hotEdgeLayer = nil burnEmitterLayer = nil burnLetterFrame = .zero } }