231 lines
8.0 KiB
Swift
231 lines
8.0 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|