jsdw_ios/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift

536 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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