jsdw_ios/QuickLocation/Section/Home/ReceiveMessagePopView.swift

637 lines
21 KiB
Swift

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