Files
mobile-ios/Rosetta/DesignSystem/Components/VoiceBlobView.swift

403 lines
13 KiB
Swift

import QuartzCore
import UIKit
// MARK: - VoiceBlobView
/// Three-layer animated blob visualization for voice recording.
/// Ported from Telegram-iOS `AudioBlob/Sources/BlobView.swift`.
///
/// Architecture:
/// - Small blob (innermost): circle, solid fill, subtle scale pulsing
/// - Medium blob: organic shape morphing, 0.3 alpha
/// - Big blob: organic shape morphing, 0.15 alpha
///
/// Audio level drives both blob scale and morph speed.
final class VoiceBlobView: UIView {
typealias BlobRange = (min: CGFloat, max: CGFloat)
private let smallBlob: BlobLayer
private let mediumBlob: BlobLayer
private let bigBlob: BlobLayer
private let maxLevel: CGFloat
private var displayLink: CADisplayLink?
private var audioLevel: CGFloat = 0
private(set) var presentationAudioLevel: CGFloat = 0
private(set) var isAnimating = false
// MARK: - Init
init(
frame: CGRect = .zero,
maxLevel: CGFloat = 1.0,
smallBlobRange: BlobRange = (min: 0.45, max: 0.55),
mediumBlobRange: BlobRange = (min: 0.52, max: 0.87),
bigBlobRange: BlobRange = (min: 0.57, max: 1.0)
) {
self.maxLevel = maxLevel
self.smallBlob = BlobLayer(
pointsCount: 8,
minRandomness: 0.1, maxRandomness: 0.5,
minSpeed: 0.2, maxSpeed: 0.6,
minScale: smallBlobRange.min, maxScale: smallBlobRange.max,
isCircle: true
)
self.mediumBlob = BlobLayer(
pointsCount: 8,
minRandomness: 1.0, maxRandomness: 1.0,
minSpeed: 0.9, maxSpeed: 4.0,
minScale: mediumBlobRange.min, maxScale: mediumBlobRange.max,
isCircle: false
)
self.bigBlob = BlobLayer(
pointsCount: 8,
minRandomness: 1.0, maxRandomness: 1.0,
minSpeed: 0.9, maxSpeed: 4.0,
minScale: bigBlobRange.min, maxScale: bigBlobRange.max,
isCircle: false
)
super.init(frame: frame)
layer.addSublayer(bigBlob.shapeLayer)
layer.addSublayer(mediumBlob.shapeLayer)
layer.addSublayer(smallBlob.shapeLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit {
displayLink?.invalidate()
}
// MARK: - Public API
func setColor(_ color: UIColor) {
smallBlob.setColor(color)
mediumBlob.setColor(color.withAlphaComponent(0.3))
bigBlob.setColor(color.withAlphaComponent(0.15))
}
func updateLevel(_ level: CGFloat, immediately: Bool = false) {
let normalized = min(1, max(level / maxLevel, 0))
smallBlob.updateSpeedLevel(to: normalized)
mediumBlob.updateSpeedLevel(to: normalized)
bigBlob.updateSpeedLevel(to: normalized)
audioLevel = normalized
if immediately {
presentationAudioLevel = normalized
}
}
func startAnimating(immediately: Bool = false) {
guard !isAnimating else { return }
isAnimating = true
if !immediately {
animateScale(of: mediumBlob.shapeLayer, from: 0.75, to: 1.0, duration: 0.35)
animateScale(of: bigBlob.shapeLayer, from: 0.75, to: 1.0, duration: 0.35)
}
updateBlobsState()
startDisplayLink()
}
func stopAnimating(duration: Double = 0.15) {
guard isAnimating else { return }
isAnimating = false
animateScale(of: mediumBlob.shapeLayer, from: 1.0, to: 0.75, duration: duration)
animateScale(of: bigBlob.shapeLayer, from: 1.0, to: 0.75, duration: duration)
updateBlobsState()
stopDisplayLink()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let size = bounds.size
smallBlob.updateBounds(size)
mediumBlob.updateBounds(size)
bigBlob.updateBounds(size)
updateBlobsState()
}
// MARK: - Display Link
private func startDisplayLink() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(displayLinkTick))
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkTick() {
presentationAudioLevel = presentationAudioLevel * 0.9 + audioLevel * 0.1
smallBlob.level = presentationAudioLevel
mediumBlob.level = presentationAudioLevel
bigBlob.level = presentationAudioLevel
}
// MARK: - Helpers
private func updateBlobsState() {
if isAnimating, bounds.size != .zero {
smallBlob.startAnimating()
mediumBlob.startAnimating()
bigBlob.startAnimating()
} else {
smallBlob.stopAnimating()
mediumBlob.stopAnimating()
bigBlob.stopAnimating()
}
}
private func animateScale(of layer: CAShapeLayer, from: CGFloat, to: CGFloat, duration: Double) {
let anim = CABasicAnimation(keyPath: "transform.scale")
anim.fromValue = from
anim.toValue = to
anim.duration = duration
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(anim, forKey: "blobScale")
}
}
// MARK: - BlobLayer
/// Single animated blob shape using CAShapeLayer + Bezier morphing.
/// Ported from Telegram's `BlobNode` (AsyncDisplayKit pure CALayer).
private final class BlobLayer {
let shapeLayer = CAShapeLayer()
let pointsCount: Int
let smoothness: CGFloat
let minRandomness: CGFloat
let maxRandomness: CGFloat
let minSpeed: CGFloat
let maxSpeed: CGFloat
let minScale: CGFloat
let maxScale: CGFloat
let isCircle: Bool
var level: CGFloat = 0 {
didSet {
guard abs(level - oldValue) > 0.01 else { return }
let lv = minScale + (maxScale - minScale) * level
CATransaction.begin()
CATransaction.setDisableActions(true)
shapeLayer.transform = CATransform3DMakeScale(lv, lv, 1)
CATransaction.commit()
}
}
private var speedLevel: CGFloat = 0
private var boundsSize: CGSize = .zero
init(
pointsCount: Int,
minRandomness: CGFloat,
maxRandomness: CGFloat,
minSpeed: CGFloat,
maxSpeed: CGFloat,
minScale: CGFloat,
maxScale: CGFloat,
isCircle: Bool
) {
self.pointsCount = pointsCount
self.minRandomness = minRandomness
self.maxRandomness = maxRandomness
self.minSpeed = minSpeed
self.maxSpeed = maxSpeed
self.minScale = minScale
self.maxScale = maxScale
self.isCircle = isCircle
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
self.smoothness = ((4.0 / 3.0) * tan(angle / 4.0)) / sin(angle / 2.0) / 2.0
shapeLayer.strokeColor = nil
shapeLayer.transform = CATransform3DMakeScale(minScale, minScale, 1)
}
func setColor(_ color: UIColor) {
shapeLayer.fillColor = color.cgColor
}
func updateSpeedLevel(to newLevel: CGFloat) {
speedLevel = max(speedLevel, newLevel)
}
func updateBounds(_ size: CGSize) {
boundsSize = size
// DON'T set shapeLayer.bounds leave at default (0,0,0,0).
// Telegram BlobNode.layout(): shapeLayer has zero-sized bounds + position at center.
// With zero bounds, local (0,0) maps to the position point = view center.
// Blob paths are centered at (0,0), so they render at the view's center. Correct!
// Setting bounds to (0,0,68,68) shifts local (0,0) to the view's top-left. Wrong!
shapeLayer.position = CGPoint(x: size.width / 2, y: size.height / 2)
if isCircle {
let hw = size.width / 2
shapeLayer.path = UIBezierPath(
roundedRect: CGRect(x: -hw, y: -hw, width: size.width, height: size.height),
cornerRadius: hw
).cgPath
}
}
func startAnimating() {
guard !isCircle else { return }
animateToNewShape()
}
func stopAnimating() {
shapeLayer.removeAnimation(forKey: "path")
}
// MARK: - Shape Animation
private func animateToNewShape() {
guard !isCircle, boundsSize != .zero else { return }
if shapeLayer.path == nil {
let points = generateBlob(for: boundsSize)
shapeLayer.path = BezierSmooth.smoothCurve(through: points, length: boundsSize.width, smoothness: smoothness).cgPath
}
let nextPoints = generateBlob(for: boundsSize)
let nextPath = BezierSmooth.smoothCurve(through: nextPoints, length: boundsSize.width, smoothness: smoothness).cgPath
let anim = CABasicAnimation(keyPath: "path")
let previous = shapeLayer.path
shapeLayer.path = nextPath
anim.fromValue = previous
anim.toValue = nextPath
anim.duration = CFTimeInterval(1.0 / (minSpeed + (maxSpeed - minSpeed) * speedLevel))
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards
anim.delegate = AnimationDelegate { [weak self] finished in
if finished {
self?.animateToNewShape()
}
}
shapeLayer.add(anim, forKey: "path")
speedLevel = 0
}
private func generateBlob(for size: CGSize) -> [CGPoint] {
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
return blobPoints(count: pointsCount, randomness: randomness).map {
CGPoint(x: $0.x * size.width, y: $0.y * size.height)
}
}
private func blobPoints(count: Int, randomness: CGFloat) -> [CGPoint] {
let angle = (CGFloat.pi * 2) / CGFloat(count)
let rangeStart: CGFloat = 1.0 / (1.0 + randomness / 10.0)
let startAngle = angle * CGFloat(arc4random_uniform(100)) / 100.0
return (0..<count).map { i in
let rng = CGFloat(arc4random_uniform(1000)) / 1000.0
let randOffset = (rangeStart + rng * (1.0 - rangeStart)) / 2.0
let angleRandomness = angle * 0.1
let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / 100.0) - angleRandomness * 0.5)
let px = sin(startAngle + CGFloat(i) * randAngle)
let py = cos(startAngle + CGFloat(i) * randAngle)
return CGPoint(x: px * randOffset, y: py * randOffset)
}
}
}
// MARK: - Animation Delegate Helper
private final class AnimationDelegate: NSObject, CAAnimationDelegate {
let completion: (Bool) -> Void
init(completion: @escaping (Bool) -> Void) {
self.completion = completion
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
completion(flag)
}
}
// MARK: - Bezier Smooth Curve
/// Generates smooth closed Bezier curves through a set of points.
/// Ported from Telegram's `UIBezierPath.smoothCurve` extension.
private enum BezierSmooth {
static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat) -> UIBezierPath {
let smoothPoints = points.enumerated().map { index, curr -> SmoothPoint in
let prevIdx = index - 1
let prev = points[prevIdx >= 0 ? prevIdx : points.count + prevIdx]
let next = points[(index + 1) % points.count]
let angle: CGFloat = {
let dx = next.x - prev.x
let dy = -next.y + prev.y
let a = atan2(dy, dx)
return a < 0 ? abs(a) : 2 * .pi - a
}()
return SmoothPoint(
point: curr,
inAngle: angle + .pi,
inLength: smoothness * distance(prev, curr),
outAngle: angle,
outLength: smoothness * distance(curr, next)
)
}
let path = UIBezierPath()
path.move(to: smoothPoints[0].point)
for i in 0..<smoothPoints.count {
let curr = smoothPoints[i]
let next = smoothPoints[(i + 1) % smoothPoints.count]
path.addCurve(to: next.point, controlPoint1: curr.smoothOut(), controlPoint2: next.smoothIn())
}
path.close()
return path
}
private static func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
}
private struct SmoothPoint {
let point: CGPoint
let inAngle: CGFloat
let inLength: CGFloat
let outAngle: CGFloat
let outLength: CGFloat
func smoothIn() -> CGPoint {
CGPoint(x: point.x + inLength * cos(inAngle), y: point.y + inLength * sin(inAngle))
}
func smoothOut() -> CGPoint {
CGPoint(x: point.x + outLength * cos(outAngle), y: point.y + outLength * sin(outAngle))
}
}
}