// // BubbleHeroView.swift // QuickLocation // import UIKit /// 绘制气泡 + 当前用户头像在圆内持续碰撞回弹 final class BubbleHeroView: UIView { private let bubbleContainer = UIView() private let gradientLayer = CAGradientLayer() private let bubbleMaskLayer = CAShapeLayer() private let borderLayer = CAShapeLayer() private let avatarView = UIImageView() private let decoBubble1 = UIView() private let decoBubble2 = UIView() private var displayLink: CADisplayLink? private var avatarCenter = CGPoint.zero private var velocity = CGPoint(x: 42, y: 36) private var avatarAngle: CGFloat = 0 private var lastTimestamp: CFTimeInterval = 0 private var sameDirectionBounceCount = 0 private var lastBounceNormal = CGPoint.zero private var hasRandomizedMotion = false private let avatarSize: CGFloat = 64 private let moveSpeed: CGFloat = 85 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() resetAvatarCenter(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 decoBubble1.frame = CGRect(x: bubbleFrame.minX + diameter * 0.12, y: bubbleFrame.maxY + 8, width: 32, height: 32) decoBubble2.frame = CGRect(x: bubbleFrame.maxX - diameter * 0.22, y: bubbleFrame.maxY + 18, width: 20, height: 20) decoBubble1.layer.cornerRadius = 16 decoBubble2.layer.cornerRadius = 10 if avatarCenter == .zero || !bubbleContainer.bounds.contains(avatarCenter) { resetAvatarCenter(randomize: true) } layoutAvatar() } 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) avatarView.contentMode = .scaleAspectFill avatarView.clipsToBounds = true avatarView.layer.cornerRadius = 16 avatarView.layer.borderWidth = 3 avatarView.layer.borderColor = UIColor.white.cgColor bubbleContainer.addSubview(avatarView) decoBubble1.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.85) decoBubble2.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.65) addSubview(decoBubble1) addSubview(decoBubble2) } private func randomizeVelocity() { let angle = CGFloat.random(in: 0...(2 * .pi)) velocity = CGPoint(x: cos(angle) * moveSpeed, y: sin(angle) * moveSpeed) hasRandomizedMotion = true } private func normalizeVelocity() { let speed = hypot(velocity.x, velocity.y) guard speed > 0.001 else { randomizeVelocity() return } let scale = moveSpeed / speed velocity.x *= scale velocity.y *= scale } private func resetAvatarCenter(randomize: Bool) { let bounds = bubbleContainer.bounds guard bounds.width > 0 else { return } let offsetRange = bounds.width * 0.08 avatarCenter = CGPoint( x: bounds.midX + CGFloat.random(in: -offsetRange...offsetRange), y: bounds.midY + CGFloat.random(in: -offsetRange...offsetRange) ) if randomize { avatarAngle = CGFloat.random(in: -0.21...0.21) randomizeVelocity() sameDirectionBounceCount = 0 lastBounceNormal = .zero } layoutAvatar() } private func layoutAvatar() { avatarView.bounds = CGRect(x: 0, y: 0, width: avatarSize, height: avatarSize) avatarView.center = avatarCenter avatarView.transform = CGAffineTransform(rotationAngle: avatarAngle) } private func startAnimation() { guard displayLink == nil else { return } if !hasRandomizedMotion { randomizeVelocity() } 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 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 maxDist = max(8, bubbleRadius - avatarSize / 2 - 4) let center = CGPoint(x: bubbleContainer.bounds.midX, y: bubbleContainer.bounds.midY) avatarCenter.x += velocity.x * dt avatarCenter.y += velocity.y * dt let offset = CGPoint(x: avatarCenter.x - center.x, y: avatarCenter.y - center.y) let dist = hypot(offset.x, offset.y) if dist > maxDist { let nx = offset.x / max(dist, 0.001) let ny = offset.y / max(dist, 0.001) avatarCenter.x = center.x + nx * maxDist avatarCenter.y = center.y + ny * maxDist if abs(nx - lastBounceNormal.x) < 0.15 && abs(ny - lastBounceNormal.y) < 0.15 { sameDirectionBounceCount += 1 } else { sameDirectionBounceCount = 0 } lastBounceNormal = CGPoint(x: nx, y: ny) let dot = velocity.x * nx + velocity.y * ny velocity.x = velocity.x - 2 * dot * nx velocity.y = 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 velocity.x += tx * jitter velocity.y += ty * jitter normalizeVelocity() avatarAngle += CGFloat.random(in: -0.05...0.05) if sameDirectionBounceCount >= 3 { randomizeVelocity() sameDirectionBounceCount = 0 lastBounceNormal = .zero } } layoutAvatar() } }