962 lines
31 KiB
Swift
962 lines
31 KiB
Swift
//
|
|
// ReceiveMessagePopView.swift
|
|
// QuickLocation
|
|
//
|
|
|
|
import UIKit
|
|
import AVFoundation
|
|
|
|
enum ReceiveMessageKind {
|
|
case image
|
|
case voice
|
|
}
|
|
|
|
struct ReceiveMessageDisplayItem {
|
|
let senderName: String
|
|
let avatar: UIImage?
|
|
let image: UIImage?
|
|
let message: String
|
|
let kind: ReceiveMessageKind
|
|
let duration: TimeInterval
|
|
let audioURL: URL?
|
|
|
|
init(
|
|
senderName: String,
|
|
avatar: UIImage?,
|
|
image: UIImage?,
|
|
message: String,
|
|
kind: ReceiveMessageKind = .image,
|
|
duration: TimeInterval = 0,
|
|
audioURL: URL? = nil
|
|
) {
|
|
self.senderName = senderName
|
|
self.avatar = avatar
|
|
self.image = image
|
|
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 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
|
|
) {
|
|
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
|
|
popup.reportButton.alpha = 0
|
|
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) {
|
|
popup.overlayView.alpha = 1
|
|
popup.reportButton.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 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(reportButton)
|
|
overlayView.layoutChain.edges()
|
|
|
|
reportButton.layoutChain
|
|
.top(kStatusBarHeight + 6)
|
|
.right(12)
|
|
.width(52)
|
|
.height(58)
|
|
|
|
letterView.layoutChain
|
|
.edgesHorzontal(26)
|
|
.centerY()
|
|
|
|
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)
|
|
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.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()
|
|
}
|
|
|
|
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 else { return }
|
|
isBurning = true
|
|
burnButton.isUserInteractionEnabled = false
|
|
voiceOverlay.isUserInteractionEnabled = false
|
|
stopVoicePlayback(reset: 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.reportButton.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()
|
|
}
|
|
}
|
|
}
|