jsdw_ios/QuickLocation/Section/Home/Bubble/BubbleHeroView.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()
}
}