536 lines
18 KiB
Swift
536 lines
18 KiB
Swift
//
|
||
// BubbleHeroView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
|
||
/// 绘制气泡 + 头像与装饰切图在圆内持续碰撞回弹
|
||
final class BubbleHeroView: UIView {
|
||
|
||
private struct DecoSpec {
|
||
let name: String
|
||
let size: CGFloat
|
||
let origin: CGPoint
|
||
}
|
||
|
||
private final class Particle {
|
||
let view: UIView
|
||
var center: CGPoint
|
||
var velocity: CGPoint
|
||
var angle: CGFloat
|
||
let radius: CGFloat
|
||
let size: CGSize
|
||
let speed: CGFloat
|
||
let originUnit: CGPoint
|
||
var sameDirectionBounceCount = 0
|
||
var lastBounceNormal = CGPoint.zero
|
||
|
||
init(
|
||
view: UIView,
|
||
center: CGPoint,
|
||
velocity: CGPoint,
|
||
angle: CGFloat,
|
||
radius: CGFloat,
|
||
size: CGSize,
|
||
speed: CGFloat,
|
||
originUnit: CGPoint
|
||
) {
|
||
self.view = view
|
||
self.center = center
|
||
self.velocity = velocity
|
||
self.angle = angle
|
||
self.radius = radius
|
||
self.size = size
|
||
self.speed = speed
|
||
self.originUnit = originUnit
|
||
}
|
||
}
|
||
|
||
private let decoSpecs: [DecoSpec] = [
|
||
DecoSpec(name: "Bubble/deco_flower", size: 50, origin: CGPoint(x: 0.62, y: 0.22)),
|
||
DecoSpec(name: "Bubble/deco_phone", size: 50, origin: CGPoint(x: 0.78, y: 0.36)),
|
||
DecoSpec(name: "Bubble/deco_pin", size: 50, origin: CGPoint(x: 0.52, y: 0.46)),
|
||
DecoSpec(name: "Bubble/deco_pencil", size: 50, origin: CGPoint(x: 0.80, y: 0.56)),
|
||
DecoSpec(name: "Bubble/deco_ghost", size: 68, origin: CGPoint(x: 0.55, y: 0.74))
|
||
]
|
||
|
||
private let bubbleContainer = UIView()
|
||
private let gradientLayer = CAGradientLayer()
|
||
private let bubbleMaskLayer = CAShapeLayer()
|
||
private let borderLayer = CAShapeLayer()
|
||
private let avatarWrap = UIView()
|
||
private let avatarView = UIImageView()
|
||
private let avatarCornerRadius: CGFloat = 16
|
||
private let decoBubble1 = UIView()
|
||
private let decoBubble2 = UIView()
|
||
private let decoBubble1Gradient = CAGradientLayer()
|
||
private let decoBubble2Gradient = CAGradientLayer()
|
||
private var decoViews: [UIImageView] = []
|
||
|
||
private var displayLink: CADisplayLink?
|
||
private var particles: [Particle] = []
|
||
private var lastTimestamp: CFTimeInterval = 0
|
||
private var hasRandomizedMotion = false
|
||
private var needsScatter = true
|
||
|
||
private let avatarSize: CGFloat = 64
|
||
private let moveSpeed: CGFloat = 85
|
||
|
||
/// 成功页圆外小粉泡;设置页关闭以免压到标题
|
||
var showsOuterDecorBubbles: Bool = true {
|
||
didSet {
|
||
decoBubble1.isHidden = !showsOuterDecorBubbles
|
||
decoBubble2.isHidden = !showsOuterDecorBubbles
|
||
}
|
||
}
|
||
|
||
/// 设置页仅头像碰撞;成功页再加入花/手机/图针/铅笔/小鬼
|
||
var showsCollisionDecorations: Bool = false {
|
||
didSet {
|
||
guard oldValue != showsCollisionDecorations else { return }
|
||
applyDecorationVisibility()
|
||
rebuildParticlesPreservingAvatar()
|
||
}
|
||
}
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
clipsToBounds = false
|
||
setupUI()
|
||
reloadAvatar()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
deinit { stopAnimation() }
|
||
|
||
override func didMoveToWindow() {
|
||
super.didMoveToWindow()
|
||
if window != nil {
|
||
if UIAccessibility.isReduceMotionEnabled {
|
||
stopAnimation()
|
||
scatterParticles(randomize: false)
|
||
} else {
|
||
startAnimation()
|
||
}
|
||
} else {
|
||
stopAnimation()
|
||
}
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let diameter = bounds.width
|
||
guard diameter > 0 else { return }
|
||
|
||
let bubbleFrame = CGRect(x: 0, y: 0, width: diameter, height: diameter)
|
||
bubbleContainer.frame = bubbleFrame
|
||
gradientLayer.frame = bubbleContainer.bounds
|
||
bubbleMaskLayer.path = UIBezierPath(ovalIn: bubbleContainer.bounds).cgPath
|
||
bubbleMaskLayer.frame = bubbleContainer.bounds
|
||
borderLayer.path = UIBezierPath(ovalIn: bubbleContainer.bounds).cgPath
|
||
borderLayer.frame = bubbleContainer.bounds
|
||
|
||
let largeBubbleSize = diameter * 0.19
|
||
let smallBubbleSize = diameter * 0.105
|
||
decoBubble1.frame = CGRect(
|
||
x: diameter * 0.82,
|
||
y: diameter - largeBubbleSize * 0.5,
|
||
width: largeBubbleSize,
|
||
height: largeBubbleSize
|
||
)
|
||
decoBubble2.frame = CGRect(
|
||
x: diameter * 0.66,
|
||
y: diameter + smallBubbleSize * 0.7,
|
||
width: smallBubbleSize,
|
||
height: smallBubbleSize
|
||
)
|
||
decoBubble1.layer.cornerRadius = largeBubbleSize / 2
|
||
decoBubble2.layer.cornerRadius = smallBubbleSize / 2
|
||
decoBubble1Gradient.frame = decoBubble1.bounds
|
||
decoBubble2Gradient.frame = decoBubble2.bounds
|
||
|
||
ensureParticles()
|
||
if needsScatter {
|
||
scatterParticles(randomize: !UIAccessibility.isReduceMotionEnabled)
|
||
}
|
||
applyParticleFrames()
|
||
}
|
||
|
||
func reloadAvatar() {
|
||
let icon = AppContextManager.shared.avaterIcon
|
||
avatarView.image = icon.size.width > 0 ? icon : UIImage(named: "Common/default_avatar")
|
||
}
|
||
|
||
private func setupUI() {
|
||
bubbleContainer.clipsToBounds = true
|
||
addSubview(bubbleContainer)
|
||
|
||
gradientLayer.type = .radial
|
||
gradientLayer.colors = [
|
||
UIColor.white.cgColor,
|
||
UIColor.white.cgColor,
|
||
UIColor(hexStr: "#FFE8F2").cgColor,
|
||
UIColor(hexStr: "#FFC9EF").cgColor
|
||
]
|
||
gradientLayer.locations = [0, 0.58, 0.82, 1]
|
||
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.46)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
||
bubbleContainer.layer.addSublayer(gradientLayer)
|
||
|
||
bubbleMaskLayer.fillColor = UIColor.white.cgColor
|
||
bubbleContainer.layer.mask = bubbleMaskLayer
|
||
|
||
borderLayer.fillColor = UIColor.clear.cgColor
|
||
borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.3).cgColor
|
||
borderLayer.lineWidth = 1
|
||
bubbleContainer.layer.addSublayer(borderLayer)
|
||
|
||
avatarWrap.clipsToBounds = false
|
||
avatarWrap.layer.shadowColor = UIColor.black.withAlphaComponent(0.2).cgColor
|
||
avatarWrap.layer.shadowOpacity = 1
|
||
avatarWrap.layer.shadowOffset = CGSize(width: 0, height: 0)
|
||
avatarWrap.layer.shadowRadius = 6
|
||
bubbleContainer.addSubview(avatarWrap)
|
||
|
||
avatarView.contentMode = .scaleAspectFill
|
||
avatarView.clipsToBounds = true
|
||
avatarView.layer.cornerRadius = avatarCornerRadius
|
||
avatarView.layer.borderWidth = 3
|
||
avatarView.layer.borderColor = UIColor.white.cgColor
|
||
avatarWrap.addSubview(avatarView)
|
||
|
||
for spec in decoSpecs {
|
||
let view = UIImageView(image: UIImage(named: spec.name))
|
||
view.contentMode = .scaleAspectFit
|
||
view.isUserInteractionEnabled = false
|
||
view.isHidden = true
|
||
bubbleContainer.addSubview(view)
|
||
decoViews.append(view)
|
||
}
|
||
|
||
configureOuterBubble(decoBubble1Gradient, in: decoBubble1, edgeColor: "#FFC9EF")
|
||
configureOuterBubble(decoBubble2Gradient, in: decoBubble2, edgeColor: "#FBD4EA")
|
||
addSubview(decoBubble1)
|
||
addSubview(decoBubble2)
|
||
}
|
||
|
||
private func configureOuterBubble(_ gradient: CAGradientLayer, in view: UIView, edgeColor: String) {
|
||
view.layer.masksToBounds = true
|
||
gradient.type = .radial
|
||
gradient.colors = [
|
||
UIColor.white.withAlphaComponent(0.92).cgColor,
|
||
UIColor(hexStr: edgeColor).withAlphaComponent(0.9).cgColor
|
||
]
|
||
gradient.locations = [0, 1]
|
||
gradient.startPoint = CGPoint(x: 0.42, y: 0.4)
|
||
gradient.endPoint = CGPoint(x: 1, y: 1)
|
||
view.layer.insertSublayer(gradient, at: 0)
|
||
}
|
||
|
||
private func applyDecorationVisibility() {
|
||
for view in decoViews {
|
||
view.isHidden = !showsCollisionDecorations
|
||
}
|
||
}
|
||
|
||
private func makeAvatarParticle() -> Particle {
|
||
Particle(
|
||
view: avatarWrap,
|
||
center: .zero,
|
||
velocity: CGPoint(x: 42, y: 36),
|
||
angle: 0,
|
||
radius: avatarSize / 2,
|
||
size: CGSize(width: avatarSize, height: avatarSize),
|
||
speed: moveSpeed,
|
||
originUnit: CGPoint(x: 0.28, y: 0.42)
|
||
)
|
||
}
|
||
|
||
private func makeDecoParticles() -> [Particle] {
|
||
decoSpecs.enumerated().map { index, spec in
|
||
Particle(
|
||
view: decoViews[index],
|
||
center: .zero,
|
||
velocity: .zero,
|
||
angle: 0,
|
||
radius: spec.size * 0.44,
|
||
size: CGSize(width: spec.size, height: spec.size),
|
||
speed: CGFloat.random(in: 68...92),
|
||
originUnit: spec.origin
|
||
)
|
||
}
|
||
}
|
||
|
||
private func ensureParticles() {
|
||
if particles.isEmpty {
|
||
particles.append(makeAvatarParticle())
|
||
}
|
||
let hasDeco = particles.count > 1
|
||
if showsCollisionDecorations, !hasDeco {
|
||
particles.append(contentsOf: makeDecoParticles())
|
||
} else if !showsCollisionDecorations, hasDeco {
|
||
particles = [particles[0]]
|
||
}
|
||
}
|
||
|
||
private func rebuildParticlesPreservingAvatar() {
|
||
let avatar = particles.first
|
||
particles.removeAll()
|
||
particles.append(makeAvatarParticle())
|
||
if let avatar {
|
||
let restored = particles[0]
|
||
restored.center = avatar.center
|
||
restored.velocity = avatar.velocity
|
||
restored.angle = avatar.angle
|
||
restored.sameDirectionBounceCount = avatar.sameDirectionBounceCount
|
||
restored.lastBounceNormal = avatar.lastBounceNormal
|
||
}
|
||
if showsCollisionDecorations {
|
||
particles.append(contentsOf: makeDecoParticles())
|
||
placeNewDecorations(randomize: !UIAccessibility.isReduceMotionEnabled)
|
||
}
|
||
applyParticleFrames()
|
||
}
|
||
|
||
private func randomizeVelocity(_ particle: Particle) {
|
||
let angle = CGFloat.random(in: 0...(2 * .pi))
|
||
particle.velocity = CGPoint(x: cos(angle) * particle.speed, y: sin(angle) * particle.speed)
|
||
hasRandomizedMotion = true
|
||
}
|
||
|
||
private func normalizeVelocity(_ particle: Particle) {
|
||
let speed = hypot(particle.velocity.x, particle.velocity.y)
|
||
guard speed > 0.001 else {
|
||
randomizeVelocity(particle)
|
||
return
|
||
}
|
||
let scale = particle.speed / speed
|
||
particle.velocity.x *= scale
|
||
particle.velocity.y *= scale
|
||
}
|
||
|
||
private func scatterParticles(randomize: Bool) {
|
||
let bounds = bubbleContainer.bounds
|
||
guard bounds.width > 0 else { return }
|
||
ensureParticles()
|
||
|
||
for particle in particles {
|
||
place(particle, randomize: randomize)
|
||
}
|
||
|
||
for _ in 0..<4 {
|
||
resolvePairwise()
|
||
for particle in particles {
|
||
clampToCircle(particle)
|
||
}
|
||
}
|
||
|
||
needsScatter = false
|
||
applyParticleFrames()
|
||
}
|
||
|
||
private func placeNewDecorations(randomize: Bool) {
|
||
guard bubbleContainer.bounds.width > 0 else { return }
|
||
for particle in particles where particle.view !== avatarWrap {
|
||
place(particle, randomize: randomize)
|
||
}
|
||
for _ in 0..<4 {
|
||
resolvePairwise()
|
||
for particle in particles {
|
||
clampToCircle(particle)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func place(_ particle: Particle, randomize: Bool) {
|
||
let bounds = bubbleContainer.bounds
|
||
if particle.view === avatarWrap && !showsCollisionDecorations {
|
||
let offsetRange = bounds.width * 0.08
|
||
particle.center = CGPoint(
|
||
x: bounds.midX + CGFloat.random(in: -offsetRange...offsetRange),
|
||
y: bounds.midY + CGFloat.random(in: -offsetRange...offsetRange)
|
||
)
|
||
} else {
|
||
particle.center = CGPoint(
|
||
x: bounds.width * particle.originUnit.x,
|
||
y: bounds.height * particle.originUnit.y
|
||
)
|
||
}
|
||
clampToCircle(particle)
|
||
if randomize {
|
||
particle.angle = CGFloat.random(in: -0.21...0.21)
|
||
randomizeVelocity(particle)
|
||
particle.sameDirectionBounceCount = 0
|
||
particle.lastBounceNormal = .zero
|
||
} else {
|
||
particle.velocity = .zero
|
||
}
|
||
}
|
||
|
||
private func clampToCircle(_ particle: Particle) {
|
||
let bounds = bubbleContainer.bounds
|
||
guard bounds.width > 0 else { return }
|
||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||
let bubbleRadius = min(bounds.width, bounds.height) / 2
|
||
let maxDist = max(8, bubbleRadius - particle.radius - 4)
|
||
let offset = CGPoint(x: particle.center.x - center.x, y: particle.center.y - center.y)
|
||
let dist = hypot(offset.x, offset.y)
|
||
guard dist > maxDist else { return }
|
||
let nx = offset.x / max(dist, 0.001)
|
||
let ny = offset.y / max(dist, 0.001)
|
||
particle.center.x = center.x + nx * maxDist
|
||
particle.center.y = center.y + ny * maxDist
|
||
}
|
||
|
||
private func applyParticleFrames() {
|
||
for particle in particles {
|
||
particle.view.bounds = CGRect(origin: .zero, size: particle.size)
|
||
particle.view.center = particle.center
|
||
particle.view.transform = CGAffineTransform(rotationAngle: particle.angle)
|
||
}
|
||
avatarView.frame = avatarWrap.bounds
|
||
avatarWrap.layer.shadowPath = UIBezierPath(
|
||
roundedRect: avatarWrap.bounds,
|
||
cornerRadius: avatarCornerRadius
|
||
).cgPath
|
||
}
|
||
|
||
private func startAnimation() {
|
||
guard displayLink == nil else { return }
|
||
if !hasRandomizedMotion {
|
||
for particle in particles {
|
||
randomizeVelocity(particle)
|
||
}
|
||
}
|
||
lastTimestamp = 0
|
||
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
|
||
link.add(to: .main, forMode: .common)
|
||
displayLink = link
|
||
}
|
||
|
||
private func stopAnimation() {
|
||
displayLink?.invalidate()
|
||
displayLink = nil
|
||
lastTimestamp = 0
|
||
}
|
||
|
||
@objc private func tick(_ link: CADisplayLink) {
|
||
guard bubbleContainer.bounds.width > 0, !particles.isEmpty else { return }
|
||
let dt = lastTimestamp > 0 ? CGFloat(link.timestamp - lastTimestamp) : CGFloat(1.0 / 60.0)
|
||
lastTimestamp = link.timestamp
|
||
guard dt > 0, dt < 0.05 else { return }
|
||
|
||
let bubbleRadius = min(bubbleContainer.bounds.width, bubbleContainer.bounds.height) / 2
|
||
let center = CGPoint(x: bubbleContainer.bounds.midX, y: bubbleContainer.bounds.midY)
|
||
|
||
for particle in particles {
|
||
particle.center.x += particle.velocity.x * dt
|
||
particle.center.y += particle.velocity.y * dt
|
||
}
|
||
|
||
resolvePairwise()
|
||
|
||
for particle in particles {
|
||
bounceOffWall(particle, center: center, bubbleRadius: bubbleRadius)
|
||
}
|
||
|
||
applyParticleFrames()
|
||
}
|
||
|
||
private func bounceOffWall(_ particle: Particle, center: CGPoint, bubbleRadius: CGFloat) {
|
||
let maxDist = max(8, bubbleRadius - particle.radius - 4)
|
||
let offset = CGPoint(x: particle.center.x - center.x, y: particle.center.y - center.y)
|
||
let dist = hypot(offset.x, offset.y)
|
||
guard dist > maxDist else { return }
|
||
|
||
let nx = offset.x / max(dist, 0.001)
|
||
let ny = offset.y / max(dist, 0.001)
|
||
particle.center.x = center.x + nx * maxDist
|
||
particle.center.y = center.y + ny * maxDist
|
||
|
||
if abs(nx - particle.lastBounceNormal.x) < 0.15 && abs(ny - particle.lastBounceNormal.y) < 0.15 {
|
||
particle.sameDirectionBounceCount += 1
|
||
} else {
|
||
particle.sameDirectionBounceCount = 0
|
||
}
|
||
particle.lastBounceNormal = CGPoint(x: nx, y: ny)
|
||
|
||
let dot = particle.velocity.x * nx + particle.velocity.y * ny
|
||
particle.velocity.x = particle.velocity.x - 2 * dot * nx
|
||
particle.velocity.y = particle.velocity.y - 2 * dot * ny
|
||
|
||
let tx = -ny
|
||
let ty = nx
|
||
let jitterSign: CGFloat = Bool.random() ? 1 : -1
|
||
let jitter = CGFloat.random(in: 16...26) * jitterSign
|
||
particle.velocity.x += tx * jitter
|
||
particle.velocity.y += ty * jitter
|
||
normalizeVelocity(particle)
|
||
particle.angle += CGFloat.random(in: -0.05...0.05)
|
||
|
||
if particle.sameDirectionBounceCount >= 3 {
|
||
randomizeVelocity(particle)
|
||
particle.sameDirectionBounceCount = 0
|
||
particle.lastBounceNormal = .zero
|
||
}
|
||
}
|
||
|
||
private func resolvePairwise() {
|
||
let count = particles.count
|
||
guard count > 1 else { return }
|
||
for _ in 0..<2 {
|
||
for i in 0..<(count - 1) {
|
||
for j in (i + 1)..<count {
|
||
collide(particles[i], particles[j])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func collide(_ a: Particle, _ b: Particle) {
|
||
let dx = b.center.x - a.center.x
|
||
let dy = b.center.y - a.center.y
|
||
let dist = hypot(dx, dy)
|
||
let minDist = a.radius + b.radius
|
||
guard dist < minDist else { return }
|
||
|
||
let nx: CGFloat
|
||
let ny: CGFloat
|
||
if dist < 0.001 {
|
||
let angle = CGFloat.random(in: 0...(2 * .pi))
|
||
nx = cos(angle)
|
||
ny = sin(angle)
|
||
} else {
|
||
nx = dx / dist
|
||
ny = dy / dist
|
||
}
|
||
|
||
let overlap = minDist - max(dist, 0.001)
|
||
let massA = max(a.radius * a.radius, 1)
|
||
let massB = max(b.radius * b.radius, 1)
|
||
let total = massA + massB
|
||
a.center.x -= nx * overlap * (massB / total)
|
||
a.center.y -= ny * overlap * (massB / total)
|
||
b.center.x += nx * overlap * (massA / total)
|
||
b.center.y += ny * overlap * (massA / total)
|
||
|
||
let relativeX = b.velocity.x - a.velocity.x
|
||
let relativeY = b.velocity.y - a.velocity.y
|
||
let velAlongNormal = relativeX * nx + relativeY * ny
|
||
guard velAlongNormal < 0 else { return }
|
||
|
||
let restitution: CGFloat = 0.92
|
||
let impulse = -(1 + restitution) * velAlongNormal / (1 / massA + 1 / massB)
|
||
a.velocity.x -= impulse / massA * nx
|
||
a.velocity.y -= impulse / massA * ny
|
||
b.velocity.x += impulse / massB * nx
|
||
b.velocity.y += impulse / massB * ny
|
||
normalizeVelocity(a)
|
||
normalizeVelocity(b)
|
||
a.angle += CGFloat.random(in: -0.04...0.04)
|
||
b.angle += CGFloat.random(in: -0.04...0.04)
|
||
}
|
||
}
|