jsdw_ios/QuickLocation/Section/Home/ReceiveMessagePopView.swift

1017 lines
33 KiB
Swift

//
// 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?
init(
messageUUID: String = "",
senderName: String,
avatar: UIImage?,
image: UIImage?,
imageURL: URL? = nil,
message: String,
kind: ReceiveMessageKind = .image,
duration: TimeInterval = 0,
audioURL: URL? = nil
) {
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
}
}
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.text = "💕"
label.font = .systemFont(ofSize: 15)
return label
}()
private let nameRow = UIStackView()
private let messageImageView: UIImageView = {
let view = UIImageView()
view.backgroundColor = UIColor(hexStr: "#D9D9D9")
view.contentMode = .scaleAspectFill
view.clipsToBounds = true
view.layer.cornerRadius = 29
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
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 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()
}
}
}