403 lines
13 KiB
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))
|
|
}
|
|
}
|
|
}
|