import Foundation import UIKit import Display import LegacyComponents private enum Constants { static let maxLevel: CGFloat = 5 } final class VoiceBlobView: UIView, TGModernConversationInputMicButtonDecoration { private let smallBlob = BlobView( pointsCount: 8, minRandomness: 0.1, maxRandomness: 1, idleSize: 1, minSpeed: 0.2, maxSpeed: 1 ) private let mediumBlob = BlobView( pointsCount: 8, minRandomness: 1, maxRandomness: 2, idleSize: 0.667, minSpeed: 1, maxSpeed: 8 ) private let bigBlob = BlobView( pointsCount: 8, minRandomness: 1, maxRandomness: 2, idleSize: 0.667, minSpeed: 1, maxSpeed: 8 ) override init(frame: CGRect) { super.init(frame: frame) addSubview(bigBlob) addSubview(mediumBlob) addSubview(smallBlob) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setColor(_ color: UIColor) { smallBlob.setColor(color) mediumBlob.setColor(color.withAlphaComponent(0.3)) bigBlob.setColor(color.withAlphaComponent(0.15)) } func updateLevel(_ level: CGFloat) { let normalizedLevel = min(1, max(level / Constants.maxLevel, 0)) smallBlob.level = normalizedLevel mediumBlob.level = normalizedLevel bigBlob.level = normalizedLevel } func tick(_ level: CGFloat) { } override func layoutSubviews() { super.layoutSubviews() func frameForBlobSize(_ blobSize: CGFloat) -> CGRect { let blobWidth = bounds.width * blobSize let blobHeight = bounds.height * blobSize return CGRect( x: (bounds.width - blobWidth) / 2, y: (bounds.height - blobHeight) / 2, width: blobWidth, height: blobHeight ) } let isInitial = smallBlob.frame == .zero smallBlob.frame = frameForBlobSize(0.56) mediumBlob.frame = frameForBlobSize(0.9) bigBlob.frame = frameForBlobSize(1) if isInitial { smallBlob.animateToNewShape() mediumBlob.animateToNewShape() bigBlob.animateToNewShape() } } } final class BlobView: UIView { let pointsCount: Int let idleSize: CGFloat let smoothness: CGFloat let minRandomness: CGFloat let maxRandomness: CGFloat let minSpeed: CGFloat let maxSpeed: CGFloat var level: CGFloat = 0 { didSet { let shouldInterruptAnimation = abs(level - oldValue) > 0.3 debouncedLevel = max(level, debouncedLevel) if shouldInterruptAnimation { animateToNewShape() } } } private var debouncedLevel: CGFloat = 0 private let shapeLayer: CAShapeLayer = { let layer = CAShapeLayer() layer.strokeColor = nil return layer }() private var transition: CGFloat = 0 { didSet { guard let currentPoints = currentPoints else { return } shapeLayer.path = UIBezierPath.smoothCurve(through: currentPoints, length: bounds.width, smoothness: smoothness).cgPath } } private var fromPoints: [CGPoint]? private var toPoints: [CGPoint]? private var currentPoints: [CGPoint]? { guard let fromPoints = fromPoints, let toPoints = toPoints else { return nil } return fromPoints.enumerated().map { offset, fromPoint in let toPoint = toPoints[offset] return CGPoint( x: fromPoint.x + (toPoint.x - fromPoint.x) * transition, y: fromPoint.y + (toPoint.y - fromPoint.y) * transition ) } } init( pointsCount: Int, minRandomness: CGFloat, maxRandomness: CGFloat, idleSize: CGFloat, minSpeed: CGFloat, maxSpeed: CGFloat ) { self.pointsCount = pointsCount self.minRandomness = minRandomness self.maxRandomness = maxRandomness self.idleSize = idleSize self.minSpeed = minSpeed self.maxSpeed = maxSpeed let angle = (CGFloat.pi * 2) / CGFloat(pointsCount) self.smoothness = ((4 / 3) * tan(angle / 4)) / sin(angle / 2) / 2 super.init(frame: .zero) layer.addSublayer(shapeLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setColor(_ color: UIColor) { shapeLayer.fillColor = color.cgColor } func animateToNewShape() { let minSize = CGSize( width: bounds.size.width * idleSize, height: bounds.size.height * idleSize ) let currentSize = CGSize( width: minSize.width + (bounds.size.width - minSize.width) * debouncedLevel, height: minSize.height + (bounds.size.height - minSize.height) * debouncedLevel ) if pop_animation(forKey: "blob") != nil { fromPoints = currentPoints toPoints = nil pop_removeAllAnimations() } if fromPoints == nil { fromPoints = generateNextBlob(for: currentSize) } if toPoints == nil { toPoints = generateNextBlob(for: currentSize) } let animation = POPBasicAnimation() animation.property = POPAnimatableProperty.property(withName: "blob.transition", initializer: { property in property?.readBlock = { blobView, values in guard let blobView = blobView as? BlobView, let values = values else { return } values.pointee = blobView.transition } property?.writeBlock = { blobView, values in guard let blobView = blobView as? BlobView, let values = values else { return } blobView.transition = values.pointee } }) as? POPAnimatableProperty animation.completionBlock = { [weak self] animation, finished in if finished { self?.fromPoints = self?.currentPoints self?.toPoints = nil self?.animateToNewShape() } } animation.duration = CFTimeInterval(1 / (minSpeed + (maxSpeed - minSpeed) * debouncedLevel)) animation.timingFunction = CAMediaTimingFunction(name: .linear) animation.fromValue = 0 animation.toValue = 1 pop_add(animation, forKey: "blob") debouncedLevel = 0 } // MARK: Helpers private func generateNextBlob(for size: CGSize) -> [CGPoint] { let randomness = minRandomness + (maxRandomness - minRandomness) * debouncedLevel return blob(pointsCount: pointsCount, randomness: randomness) .map { return CGPoint( x: $0.x * CGFloat(size.width), y: $0.y * CGFloat(size.height) ) } } func blob(pointsCount: Int, randomness: CGFloat) -> [CGPoint] { let angle = (CGFloat.pi * 2) / CGFloat(pointsCount) let rgen = { () -> CGFloat in let accuracy: UInt32 = 1000 let random = arc4random_uniform(accuracy) return CGFloat(random) / CGFloat(accuracy) } let rangeStart: CGFloat = 1 / (1 + randomness / 10) let startAngle = angle * CGFloat(arc4random_uniform(100)) / CGFloat(100) let points = (0 ..< pointsCount).map { i -> CGPoint in let randPointOffset = (rangeStart + CGFloat(rgen()) * (1 - rangeStart)) / 2 let angleRandomness: CGFloat = angle * 0.1 let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / CGFloat(100)) - angleRandomness * 0.5) let pointX = sin(startAngle + CGFloat(i) * randAngle) let pointY = cos(startAngle + CGFloat(i) * randAngle) return CGPoint( x: pointX * randPointOffset, y: pointY * randPointOffset ) } return points } override func layoutSubviews() { super.layoutSubviews() CATransaction.begin() CATransaction.setDisableActions(true) shapeLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) CATransaction.commit() } } private extension UIBezierPath { static func smoothCurve( through points: [CGPoint], length: CGFloat, smoothness: CGFloat ) -> UIBezierPath { var smoothPoints = [SmoothPoint]() for index in (0 ..< points.count) { let prevIndex = index - 1 let prev = points[prevIndex >= 0 ? prevIndex : points.count + prevIndex] let curr = points[index] let next = points[(index + 1) % points.count] let angle: CGFloat = { let dx = next.x - prev.x let dy = -next.y + prev.y let angle = atan2(dy, dx) if angle < 0 { return abs(angle) } else { return 2 * .pi - angle } }() smoothPoints.append( SmoothPoint( point: curr, inAngle: angle + .pi, inLength: smoothness * distance(from: curr, to: prev), outAngle: angle, outLength: smoothness * distance(from: curr, to: next) ) ) } let resultPath = UIBezierPath() resultPath.move(to: smoothPoints[0].point) for index in (0 ..< smoothPoints.count) { let curr = smoothPoints[index] let next = smoothPoints[(index + 1) % points.count] let currSmoothOut = curr.smoothOut() let nextSmoothIn = next.smoothIn() resultPath.addCurve(to: next.point, controlPoint1: currSmoothOut, controlPoint2: nextSmoothIn) } resultPath.close() return resultPath } static private func distance(from fromPoint: CGPoint, to toPoint: CGPoint) -> CGFloat { return sqrt((fromPoint.x - toPoint.x) * (fromPoint.x - toPoint.x) + (fromPoint.y - toPoint.y) * (fromPoint.y - toPoint.y)) } struct SmoothPoint { let point: CGPoint let inAngle: CGFloat let inLength: CGFloat let outAngle: CGFloat let outLength: CGFloat func smoothIn() -> CGPoint { return smooth(angle: inAngle, length: inLength) } func smoothOut() -> CGPoint { return smooth(angle: outAngle, length: outLength) } private func smooth(angle: CGFloat, length: CGFloat) -> CGPoint { return CGPoint( x: point.x + length * cos(angle), y: point.y + length * sin(angle) ) } } }