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