Голосовые сообщения - анимация кнопки микрофона + панель записи с таймером
This commit is contained in:
398
Rosetta/DesignSystem/Components/VoiceBlobView.swift
Normal file
398
Rosetta/DesignSystem/Components/VoiceBlobView.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
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
|
||||
shapeLayer.bounds = CGRect(origin: .zero, size: size)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user