// // ReceiveMessagePopView.swift // QuickLocation // import UIKit import AVFoundation import Kingfisher enum ReceiveMessageKind { case image case voice } struct ReceiveMessageDisplayItem { let messageUUID: String let senderName: String let avatar: UIImage? let image: UIImage? let imageURL: URL? let message: String let kind: ReceiveMessageKind let duration: TimeInterval let audioURL: URL? let relationIdx: String init( messageUUID: String = "", senderName: String, avatar: UIImage?, image: UIImage?, imageURL: URL? = nil, message: String, kind: ReceiveMessageKind = .image, duration: TimeInterval = 0, audioURL: URL? = nil, relationIdx: String = "" ) { self.messageUUID = messageUUID self.senderName = senderName self.avatar = avatar self.image = image self.imageURL = imageURL self.message = message self.kind = kind self.duration = duration self.audioURL = audioURL self.relationIdx = relationIdx } } 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 onRequestBurn: ((ReceiveMessageDisplayItem, @escaping (Bool) -> Void) -> Void)? 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? private var isVoicePlaying = false private var voiceElapsed: TimeInterval = 0 private var voiceDuration: TimeInterval = 10 private var voiceTimer: Timer? private var audioPlayer: AVAudioPlayer? static func show( item: ReceiveMessageDisplayItem, onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil ) { show(items: [item], onBurn: onBurn) } static func show( items: [ReceiveMessageDisplayItem], onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil, onRequestBurn: ((ReceiveMessageDisplayItem, @escaping (Bool) -> Void) -> 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.onRequestBurn = onRequestBurn popup.onBurn = onBurn popup.configure(item: firstItem) window.addSubview(popup) window.bringSubviewToFront(popup) popup.layoutIfNeeded() popup.overlayView.alpha = 0 popup.letterView.alpha = 0 popup.reportButton.alpha = 0 popup.closeButton.alpha = 0 UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { popup.overlayView.alpha = 1 popup.reportButton.alpha = 1 popup.closeButton.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 closeButton: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "Group/close"), for: .normal) button.extendEdgeInsets = UIEdgeInsets(top: 15, left: 20, bottom: 20, right: 20) return button }() private let reportButton: UIButton = { let button = UIButton(type: .custom) var configuration = UIButton.Configuration.plain() configuration.image = UIImage(named: "Home/receive_message_report") configuration.title = "举报" configuration.imagePlacement = .top configuration.imagePadding = 2 configuration.baseForegroundColor = .white configuration.contentInsets = .zero var titleAttributes = AttributeContainer() titleAttributes.font = UIFont.systemFont(ofSize: 11, weight: .medium) configuration.attributedTitle = AttributedString("举报", attributes: titleAttributes) button.configuration = configuration return button }() 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 = 12 view.backgroundColor = UIColor(hexStr: "#D9D9D9") 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.font = .systemFont(ofSize: 13, weight: .medium) label.textColor = UIColor(hexStr: "#FF6B9D") return label }() private let nameRow = UIStackView() private let messageImageView: UIImageView = { let view = UIImageView() view.backgroundColor = .clear view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 30 view.isUserInteractionEnabled = true return view }() private let captionBackground: UIView = { let view = UIView() view.backgroundColor = UIColor.white.withAlphaComponent(0.9) view.layer.cornerRadius = 16 view.clipsToBounds = true return view }() private let messageLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center label.numberOfLines = 2 label.lineBreakMode = .byTruncatingTail return label }() private let voiceOverlay: UIView = { let view = UIView() view.backgroundColor = UIColor.white.withAlphaComponent(0.92) view.layer.cornerRadius = 18 view.clipsToBounds = true view.isUserInteractionEnabled = true return view }() private let playButton: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "PigeonMessage/history_play"), for: .normal) button.setImage(UIImage(named: "PigeonMessage/history_pause"), for: .selected) button.adjustsImageWhenHighlighted = false button.isUserInteractionEnabled = false return button }() private let waveformView = ReceiveVoiceWaveformView() private let burnButton: UIButton = { let button = UIButton(type: .custom) let title = NSAttributedString( string: "阅后即焚", attributes: [ .font: FontManager.boboBold(18), .foregroundColor: UIColor.white, .kern: 4 ] ) button.setAttributedTitle(title, for: .normal) 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) addSubview(closeButton) addSubview(reportButton) overlayView.layoutChain.edges() reportButton.layoutChain .top(kStatusBarHeight + 6) .right(12) .width(52) .height(58) letterView.layoutChain .edgesHorzontal(26) .centerY() closeButton.layoutChain .topToBottomOfView(letterView, offset: 16) .centerX() .width(22) .height(22) letterView.addSubview(cardBackgroundView) letterView.addSubview(titleImageView) letterView.addSubview(avatarView) letterView.addSubview(nameRow) letterView.addSubview(messageImageView) letterView.addSubview(burnButton) messageImageView.addSubview(captionBackground) captionBackground.addSubview(messageLabel) messageImageView.addSubview(voiceOverlay) voiceOverlay.addSubview(playButton) voiceOverlay.addSubview(waveformView) nameRow.axis = .horizontal nameRow.alignment = .center nameRow.spacing = 4 nameRow.addArrangedSubview(nameLabel) nameRow.addArrangedSubview(relationLabel) cardBackgroundView.layoutChain .top(38) .edgesHorzontal() .heightToWidth(480/323) .bottom() titleImageView.layoutChain .top() .left(18) .width(180) .height(56) avatarView.layoutChain .topToBottomOfView(titleImageView, offset: 1) .centerX() .width(40) .height(40) nameRow.layoutChain .topToBottomOfView(avatarView, offset: 11) .centerX() burnButton.layoutChain .bottom(25) .edgesHorzontal(24) .height(56) messageImageView.layoutChain .topToBottomOfView(nameRow, offset: 19) .edgesHorzontal(61) .heightToWidth(270/200) captionBackground.layoutChain .left(16) .right(16) .bottom(16) messageLabel.layoutChain .top(10) .left(12) .right(12) .bottom(10) voiceOverlay.layoutChain .left(16) .right(16) .bottom(16) .height(48) playButton.layoutChain .left(10) .centerY() .width(24) .height(24) waveformView.layoutChain .leftToRightOfView(playButton, offset: 8) .right(14) .centerY() .height(22) burnButton.addTarget(self, action: #selector(tapBurn), for: .touchUpInside) reportButton.addTarget(self, action: #selector(tapReport), for: .touchUpInside) closeButton.addTarget(self, action: #selector(tapClose), for: .touchUpInside) let overlayTap = UITapGestureRecognizer(target: self, action: #selector(tapPlay)) voiceOverlay.addGestureRecognizer(overlayTap) } private func configure(item: ReceiveMessageDisplayItem) { stopVoicePlayback(reset: true) nameLabel.text = item.senderName.isEmpty ? "圈子成员" : item.senderName applyRelation(item.relationIdx) avatarView.image = item.avatar ?? UIImage(named: "UserIcon/1") messageImageView.kf.cancelDownloadTask() if let imageURL = item.imageURL { messageImageView.kf.setImage(with: imageURL, placeholder: item.image) } else { messageImageView.image = item.image } messageLabel.text = String(item.message.prefix(20)) let isVoice = item.kind == .voice let caption = String(item.message.prefix(20)) voiceDuration = max(1, item.duration > 0 ? item.duration : 10) captionBackground.isHidden = isVoice || caption.isEmpty voiceOverlay.isHidden = !isVoice playButton.isSelected = false if isVoice { waveformView.reset() } } private func applyRelation(_ idx: String) { if RelationStore.shared.showsHeart(for: idx) { relationLabel.text = "💕" relationLabel.isHidden = false } else if let name = RelationStore.shared.name(for: idx) { relationLabel.text = name relationLabel.isHidden = false } else { relationLabel.text = nil relationLabel.isHidden = true } } private func animateInitialFlyIn() { burnButton.isUserInteractionEnabled = false voiceOverlay.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 self.voiceOverlay.isUserInteractionEnabled = true } } @objc private func tapPlay() { guard item?.kind == .voice, !isBurning else { return } if isVoicePlaying { pauseVoicePlayback() } else { startVoicePlayback() } } @objc private func tapReport() { ReceiveMessageReportPopView.show() } @objc private func tapClose() { dismiss(animated: true) } private func startVoicePlayback() { if audioPlayer == nil { prepareAudioPlayer() } isVoicePlaying = true playButton.isSelected = true waveformView.progress = 1 if let player = audioPlayer { if player.currentTime >= player.duration { player.currentTime = 0 voiceElapsed = 0 } try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try? AVAudioSession.sharedInstance().setActive(true) player.play() } voiceTimer?.invalidate() let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in self?.tickVoicePlayback() } RunLoop.main.add(timer, forMode: .common) voiceTimer = timer } private func pauseVoicePlayback() { isVoicePlaying = false playButton.isSelected = false audioPlayer?.pause() voiceTimer?.invalidate() voiceTimer = nil } private func stopVoicePlayback(reset: Bool) { pauseVoicePlayback() audioPlayer?.stop() audioPlayer = nil if reset { voiceElapsed = 0 waveformView.reset() } } private func prepareAudioPlayer() { guard let url = resolvedAudioURL() else { return } do { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self player.isMeteringEnabled = true player.prepareToPlay() audioPlayer = player if player.duration > 0 { voiceDuration = player.duration } } catch { audioPlayer = nil } } private func resolvedAudioURL() -> URL? { if let audioURL = item?.audioURL { return audioURL } return Bundle.main.url(forResource: "sos", withExtension: "mp3") ?? Bundle.main.url(forResource: "sos", withExtension: "mp3", subdirectory: "sound") } private func tickVoicePlayback() { if let player = audioPlayer { voiceElapsed = player.currentTime player.updateMeters() let level = pow(10, CGFloat(player.averagePower(forChannel: 0)) / 38) waveformView.push(level: level) guard !player.isPlaying, voiceElapsed >= max(player.duration - 0.05, 0) else { return } stopVoicePlayback(reset: true) return } voiceElapsed = min(voiceDuration, voiceElapsed + 0.05) let phase = CGFloat(voiceElapsed * 7.2) let synthetic = 0.18 + 0.72 * abs(sin(phase)) * (0.45 + 0.55 * abs(sin(phase * 0.37))) waveformView.push(level: synthetic) guard voiceElapsed >= voiceDuration else { return } stopVoicePlayback(reset: true) } @objc private func tapBurn() { guard !isBurning, let item else { return } burnButton.isUserInteractionEnabled = false voiceOverlay.isUserInteractionEnabled = false stopVoicePlayback(reset: false) let start = { [weak self] in guard let self else { return } self.isBurning = true self.playBurnAnimation() } guard let onRequestBurn else { start() return } onRequestBurn(item) { success in if success { start() } else { self.burnButton.isUserInteractionEnabled = true self.voiceOverlay.isUserInteractionEnabled = true } } } 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.onRequestBurn = 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.reportButton.alpha = 0 self.closeButton.alpha = 0 self.letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) } completion: { _ in completion() } } private func resetBurnState() { stopVoicePlayback(reset: true) burnDisplayLink?.invalidate() burnDisplayLink = nil isBurning = false burnButton.isUserInteractionEnabled = true voiceOverlay.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 } } extension ReceiveMessagePopView: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { stopVoicePlayback(reset: true) } } final class ReceiveVoiceWaveformView: UIView { private static let idleLevels: [CGFloat] = [ 0.12, 0.12, 0.14, 0.14, 0.16, 0.14, 0.18, 0.22, 0.18, 0.30, 0.42, 0.22, 0.34, 0.92, 0.46, 0.28, 0.22, 0.18, 0.24, 0.18, 0.16, 0.14, 0.14, 0.12, 0.12, 0.12 ] private var levels = ReceiveVoiceWaveformView.idleLevels var progress: CGFloat = 0 { didSet { setNeedsDisplay() } } func push(level: CGFloat) { levels.removeFirst() levels.append(min(1, max(0.12, level))) setNeedsDisplay() } func reset() { levels = Self.idleLevels progress = 0 setNeedsDisplay() } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return } let playedColor = UIColor(hexStr: "#00ADFE") let idleColor = UIColor(hexStr: "#86D9F8") let spacing = rect.width / CGFloat(levels.count) let playedIndex = Int(round(progress * CGFloat(levels.count - 1))) for (index, level) in levels.enumerated() { let color = index <= playedIndex && progress > 0 ? playedColor : idleColor let x = CGFloat(index) * spacing + spacing / 2 if level <= 0.18 { let radius = min(2, spacing * 0.3) context.setFillColor(color.cgColor) context.fillEllipse( in: CGRect( x: x - radius, y: rect.midY - radius, width: radius * 2, height: radius * 2 ) ) continue } let height = max(4, rect.height * level) context.setStrokeColor(color.cgColor) context.setLineWidth(min(3, spacing * 0.48)) context.setLineCap(.round) context.move(to: CGPoint(x: x, y: rect.midY - height / 2)) context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2)) context.strokePath() } } }