mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
916 lines
37 KiB
Swift
916 lines
37 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
|
|
private let titleFont = Font.regular(17.0)
|
|
private let subtitleFont = Font.regular(13.0)
|
|
|
|
private let blue = UIColor(rgb: 0x0078ff)
|
|
private let lightBlue = UIColor(rgb: 0x59c7f8)
|
|
private let green = UIColor(rgb: 0x33c659)
|
|
|
|
private let deviceScale = UIScreen.main.scale
|
|
|
|
private let radialMaskImage = generateImage(CGSize(width: 100.0, height: 100.0), contextGenerator: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
var locations: [CGFloat] = [0.0, 1.0]
|
|
let maskColors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.75).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
|
|
let maskGradient = CGGradient(colorsSpace: colorSpace, colors: maskColors as CFArray, locations: &locations)!
|
|
let maskGradientCenter = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
|
|
|
context.drawRadialGradient(maskGradient, startCenter: maskGradientCenter, startRadius: 0.0, endCenter: maskGradientCenter, endRadius: size.width / 2.0, options: .drawsAfterEndLocation)
|
|
}, opaque: false, scale: deviceScale)!
|
|
|
|
enum VoiceChatActionButtonState {
|
|
enum ActiveState {
|
|
case cantSpeak
|
|
case muted
|
|
case on
|
|
}
|
|
|
|
case connecting
|
|
case active(state: ActiveState)
|
|
}
|
|
|
|
private enum VoiceChatActionButtonBackgroundNodeType {
|
|
case connecting
|
|
case disabled
|
|
case blob
|
|
}
|
|
|
|
private protocol VoiceChatActionButtonBackgroundNodeState: NSObjectProtocol {
|
|
var blueGradient: UIImage? { get set }
|
|
var greenGradient: UIImage? { get set }
|
|
|
|
var frameInterval: Int { get }
|
|
var isAnimating: Bool { get }
|
|
var type: VoiceChatActionButtonBackgroundNodeType { get }
|
|
func updateAnimations()
|
|
}
|
|
|
|
private final class VoiceChatActionButtonBackgroundNodeConnectingState: NSObject, VoiceChatActionButtonBackgroundNodeState {
|
|
var blueGradient: UIImage?
|
|
var greenGradient: UIImage?
|
|
|
|
var isAnimating: Bool {
|
|
return true
|
|
}
|
|
|
|
var frameInterval: Int {
|
|
return 1
|
|
}
|
|
|
|
var type: VoiceChatActionButtonBackgroundNodeType {
|
|
return .connecting
|
|
}
|
|
|
|
func updateAnimations() {
|
|
}
|
|
|
|
init(blueGradient: UIImage?) {
|
|
self.blueGradient = blueGradient
|
|
}
|
|
}
|
|
|
|
private final class VoiceChatActionButtonBackgroundNodeDisabledState: NSObject, VoiceChatActionButtonBackgroundNodeState {
|
|
var blueGradient: UIImage?
|
|
var greenGradient: UIImage?
|
|
|
|
var isAnimating: Bool {
|
|
return false
|
|
}
|
|
|
|
var frameInterval: Int {
|
|
return 1
|
|
}
|
|
|
|
var type: VoiceChatActionButtonBackgroundNodeType {
|
|
return .disabled
|
|
}
|
|
|
|
func updateAnimations() {
|
|
}
|
|
}
|
|
|
|
private final class Blob {
|
|
let size: CGSize
|
|
let alpha: CGFloat
|
|
|
|
let pointsCount: Int
|
|
let smoothness: CGFloat
|
|
|
|
let minRandomness: CGFloat
|
|
let maxRandomness: CGFloat
|
|
|
|
let minSpeed: CGFloat
|
|
let maxSpeed: CGFloat
|
|
|
|
var currentScale: CGFloat = 1.0
|
|
var minScale: CGFloat
|
|
var maxScale: CGFloat
|
|
let scaleSpeed: CGFloat
|
|
|
|
private var speedLevel: CGFloat = 0.0
|
|
private var lastSpeedLevel: CGFloat = 0.0
|
|
|
|
private var fromPoints: [CGPoint]?
|
|
private var toPoints: [CGPoint]?
|
|
|
|
private var currentPoints: [CGPoint]? {
|
|
guard let fromPoints = self.fromPoints, let toPoints = self.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)
|
|
}
|
|
}
|
|
|
|
var currentShape: UIBezierPath?
|
|
private var transition: CGFloat = 0 {
|
|
didSet {
|
|
if let currentPoints = self.currentPoints {
|
|
self.currentShape = UIBezierPath.smoothCurve(through: currentPoints, length: size.width, smoothness: smoothness)
|
|
}
|
|
}
|
|
}
|
|
|
|
var level: CGFloat = 0.0 {
|
|
didSet {
|
|
self.currentScale = self.minScale + (self.maxScale - self.minScale) * self.level
|
|
}
|
|
}
|
|
|
|
private var transitionArguments: (startTime: Double, duration: Double)?
|
|
|
|
var loop: Bool = true {
|
|
didSet {
|
|
if let _ = transitionArguments {
|
|
} else {
|
|
self.animateToNewShape()
|
|
}
|
|
}
|
|
}
|
|
|
|
init(
|
|
size: CGSize,
|
|
alpha: CGFloat,
|
|
pointsCount: Int,
|
|
minRandomness: CGFloat,
|
|
maxRandomness: CGFloat,
|
|
minSpeed: CGFloat,
|
|
maxSpeed: CGFloat,
|
|
minScale: CGFloat,
|
|
maxScale: CGFloat,
|
|
scaleSpeed: CGFloat
|
|
) {
|
|
self.size = size
|
|
self.alpha = alpha
|
|
self.pointsCount = pointsCount
|
|
self.minRandomness = minRandomness
|
|
self.maxRandomness = maxRandomness
|
|
self.minSpeed = minSpeed
|
|
self.maxSpeed = maxSpeed
|
|
self.minScale = minScale
|
|
self.maxScale = maxScale
|
|
self.scaleSpeed = scaleSpeed
|
|
|
|
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
|
|
self.smoothness = ((4 / 3) * tan(angle / 4)) / sin(angle / 2) / 2
|
|
|
|
self.currentScale = minScale
|
|
|
|
self.animateToNewShape()
|
|
}
|
|
|
|
func updateSpeedLevel(to newSpeedLevel: CGFloat) {
|
|
self.speedLevel = max(self.speedLevel, newSpeedLevel)
|
|
|
|
if abs(lastSpeedLevel - newSpeedLevel) > 0.3 {
|
|
animateToNewShape()
|
|
}
|
|
}
|
|
|
|
private func animateToNewShape() {
|
|
if let _ = self.transitionArguments {
|
|
self.fromPoints = self.currentPoints
|
|
self.toPoints = nil
|
|
self.transition = 0.0
|
|
self.transitionArguments = nil
|
|
}
|
|
|
|
if self.fromPoints == nil {
|
|
self.fromPoints = generateNextBlob(for: self.size)
|
|
}
|
|
if self.toPoints == nil {
|
|
self.toPoints = generateNextBlob(for: self.size)
|
|
}
|
|
|
|
let duration: Double = 1.0 / Double(minSpeed + (maxSpeed - minSpeed) * speedLevel)
|
|
self.transitionArguments = (CACurrentMediaTime(), duration)
|
|
|
|
self.lastSpeedLevel = self.speedLevel
|
|
self.speedLevel = 0
|
|
|
|
self.updateAnimations()
|
|
}
|
|
|
|
func updateAnimations() {
|
|
var animate = false
|
|
let timestamp = CACurrentMediaTime()
|
|
|
|
// if let (startTime, duration) = self.gradientTransitionArguments, duration > 0.0 {
|
|
// if let fromLoop = self.fromLoop {
|
|
// if fromLoop {
|
|
// self.gradientTransition = max(0.0, min(1.0, CGFloat((timestamp - startTime) / duration)))
|
|
// } else {
|
|
// self.gradientTransition = max(0.0, min(1.0, 1.0 - CGFloat((timestamp - startTime) / duration)))
|
|
// }
|
|
// }
|
|
// if self.gradientTransition < 1.0 {
|
|
// animate = true
|
|
// } else {
|
|
// self.gradientTransitionArguments = nil
|
|
// }
|
|
// }
|
|
|
|
if let (startTime, duration) = self.transitionArguments, duration > 0.0 {
|
|
self.transition = max(0.0, min(1.0, CGFloat((timestamp - startTime) / duration)))
|
|
if self.transition < 1.0 {
|
|
animate = true
|
|
} else {
|
|
if self.loop {
|
|
self.animateToNewShape()
|
|
} else {
|
|
self.fromPoints = self.currentPoints
|
|
self.toPoints = nil
|
|
self.transition = 0.0
|
|
self.transitionArguments = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// let gradientMovementStartTime: Double
|
|
// let gradientMovementDuration: Double
|
|
// let gradientMovementReverse: Bool
|
|
// if let (startTime, duration, reverse) = self.gradientMovementTransitionArguments, duration > 0.0 {
|
|
// gradientMovementStartTime = startTime
|
|
// gradientMovementDuration = duration
|
|
// gradientMovementReverse = reverse
|
|
// } else {
|
|
// gradientMovementStartTime = CACurrentMediaTime()
|
|
// gradientMovementDuration = 1.0
|
|
// gradientMovementReverse = false
|
|
// self.gradientMovementTransitionArguments = (gradientMovementStartTime, gradientMovementStartTime, gradientMovementReverse)
|
|
// }
|
|
// let movementT = CGFloat((timestamp - gradientMovementStartTime) / gradientMovementDuration)
|
|
// self.gradientMovementTransition = gradientMovementReverse ? 1.0 - movementT : movementT
|
|
// if gradientMovementReverse && self.gradientMovementTransition <= 0.0 {
|
|
// self.gradientMovementTransitionArguments = (CACurrentMediaTime(), 1.0, false)
|
|
// } else if !gradientMovementReverse && self.gradientMovementTransition >= 1.0 {
|
|
// self.gradientMovementTransitionArguments = (CACurrentMediaTime(), 1.0, true)
|
|
// }
|
|
}
|
|
|
|
private func generateNextBlob(for size: CGSize) -> [CGPoint] {
|
|
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
|
|
return blob(pointsCount: pointsCount, randomness: randomness).map {
|
|
return CGPoint(x: size.width / 2.0 + $0.x * CGFloat(size.width), y: size.height / 2.0 + $0.y * CGFloat(size.height))
|
|
}
|
|
}
|
|
|
|
private 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.0 / (1.0 + randomness / 10.0)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, VoiceChatActionButtonBackgroundNodeState {
|
|
var blueGradient: UIImage?
|
|
var greenGradient: UIImage?
|
|
|
|
var isAnimating: Bool {
|
|
return true
|
|
}
|
|
|
|
var frameInterval: Int {
|
|
return 2
|
|
}
|
|
|
|
var type: VoiceChatActionButtonBackgroundNodeType {
|
|
return .blob
|
|
}
|
|
|
|
typealias BlobRange = (min: CGFloat, max: CGFloat)
|
|
let blobs: [Blob]
|
|
|
|
var active: Bool
|
|
var activeTransitionArguments: (startTime: Double, duration: Double)?
|
|
|
|
init(size: CGSize, active: Bool, blueGradient: UIImage, greenGradient: UIImage) {
|
|
self.active = active
|
|
self.blueGradient = blueGradient
|
|
self.greenGradient = greenGradient
|
|
|
|
let mediumBlobRange: BlobRange = (0.69, 0.87)
|
|
let bigBlobRange: BlobRange = (0.71, 1.00)
|
|
|
|
let mediumBlob = Blob(size: size, alpha: 0.55, pointsCount: 8, minRandomness: 1, maxRandomness: 1, minSpeed: 1.5, maxSpeed: 7, minScale: mediumBlobRange.min, maxScale: mediumBlobRange.max, scaleSpeed: 0.2)
|
|
let largeBlob = Blob(size: size, alpha: 0.35, pointsCount: 8, minRandomness: 1, maxRandomness: 1, minSpeed: 1.5, maxSpeed: 7, minScale: bigBlobRange.min, maxScale: bigBlobRange.max, scaleSpeed: 0.2)
|
|
|
|
self.blobs = [largeBlob, mediumBlob]
|
|
}
|
|
|
|
func update(with state: VoiceChatActionButtonBackgroundNodeBlobState) {
|
|
if self.active != state.active {
|
|
self.active = state.active
|
|
|
|
self.activeTransitionArguments = (CACurrentMediaTime(), 0.3)
|
|
}
|
|
}
|
|
|
|
func updateAnimations() {
|
|
let timestamp = CACurrentMediaTime()
|
|
|
|
if let (startTime, duration) = self.activeTransitionArguments, duration > 0.0 {
|
|
let transition = max(0.0, min(1.0, CGFloat((timestamp - startTime) / duration)))
|
|
if transition < 1.0 {
|
|
} else {
|
|
self.activeTransitionArguments = nil
|
|
}
|
|
}
|
|
|
|
for blob in self.blobs {
|
|
blob.updateAnimations()
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class VoiceChatActionButtonBackgroundNodeTransition {
|
|
let startTime: Double
|
|
let duration: Double
|
|
let previousState: VoiceChatActionButtonBackgroundNodeState?
|
|
|
|
init(startTime: Double, duration: Double, previousState: VoiceChatActionButtonBackgroundNodeState?) {
|
|
self.startTime = startTime
|
|
self.duration = duration
|
|
self.previousState = previousState
|
|
}
|
|
|
|
func progress(time: Double) -> CGFloat {
|
|
if duration > 0.0 {
|
|
return CGFloat(max(0.0, min(1.0, (time - startTime) / duration)))
|
|
} else {
|
|
return 0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
private class VoiceChatActionButtonBackgroundNodeDrawingState: NSObject {
|
|
let timestamp: Double
|
|
let state: VoiceChatActionButtonBackgroundNodeState
|
|
let transition: VoiceChatActionButtonBackgroundNodeTransition?
|
|
|
|
init(timestamp: Double, state: VoiceChatActionButtonBackgroundNodeState, transition: VoiceChatActionButtonBackgroundNodeTransition?) {
|
|
self.timestamp = timestamp
|
|
self.state = state
|
|
self.transition = transition
|
|
}
|
|
}
|
|
|
|
private class VoiceChatActionButtonBackgroundNode: ASDisplayNode {
|
|
private var state: VoiceChatActionButtonBackgroundNodeState
|
|
private var hasState = false
|
|
private var transition: VoiceChatActionButtonBackgroundNodeTransition?
|
|
|
|
var audioLevel: CGFloat = 0.0 {
|
|
didSet {
|
|
if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState {
|
|
for blob in blobsState.blobs {
|
|
blob.loop = audioLevel.isZero
|
|
blob.updateSpeedLevel(to: self.audioLevel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private var presentationAudioLevel: CGFloat = 0.0
|
|
|
|
private var animator: ConstantDisplayLinkAnimator?
|
|
|
|
override init() {
|
|
self.state = VoiceChatActionButtonBackgroundNodeConnectingState(blueGradient: nil)
|
|
|
|
super.init()
|
|
|
|
self.isOpaque = false
|
|
self.displaysAsynchronously = true
|
|
}
|
|
|
|
func update(state: VoiceChatActionButtonBackgroundNodeState, animated: Bool) {
|
|
var animated = animated
|
|
var hadState = true
|
|
if !self.hasState {
|
|
hadState = false
|
|
self.hasState = true
|
|
animated = false
|
|
}
|
|
|
|
if state.type != self.state.type || !hadState {
|
|
if animated {
|
|
self.transition = VoiceChatActionButtonBackgroundNodeTransition(startTime: CACurrentMediaTime(), duration: 0.3, previousState: self.state)
|
|
}
|
|
self.state = state
|
|
self.animator?.frameInterval = state.frameInterval
|
|
} else if let blobState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState, let nextState = state as? VoiceChatActionButtonBackgroundNodeBlobState {
|
|
blobState.update(with: nextState)
|
|
}
|
|
|
|
self.updateAnimations()
|
|
}
|
|
|
|
private func updateAnimations() {
|
|
var animate = false
|
|
let timestamp = CACurrentMediaTime()
|
|
|
|
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + max(0.1, self.audioLevel) * 0.1
|
|
if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState {
|
|
for blob in blobsState.blobs {
|
|
blob.level = self.presentationAudioLevel
|
|
}
|
|
}
|
|
|
|
if let transition = self.transition {
|
|
if transition.startTime + transition.duration < timestamp {
|
|
self.transition = nil
|
|
} else {
|
|
animate = true
|
|
}
|
|
}
|
|
|
|
if self.state.isAnimating {
|
|
animate = true
|
|
self.state.updateAnimations()
|
|
}
|
|
|
|
if animate {
|
|
let animator: ConstantDisplayLinkAnimator
|
|
if let current = self.animator {
|
|
animator = current
|
|
} else {
|
|
animator = ConstantDisplayLinkAnimator(update: { [weak self] in
|
|
self?.updateAnimations()
|
|
})
|
|
animator.frameInterval = 2
|
|
self.animator = animator
|
|
}
|
|
animator.isPaused = false
|
|
} else {
|
|
self.animator?.isPaused = true
|
|
}
|
|
|
|
self.setNeedsDisplay()
|
|
}
|
|
|
|
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
|
|
return VoiceChatActionButtonBackgroundNodeDrawingState(timestamp: CACurrentMediaTime(), state: self.state, transition: self.transition)
|
|
}
|
|
|
|
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
|
|
let context = UIGraphicsGetCurrentContext()!
|
|
|
|
let drawStart = CACurrentMediaTime()
|
|
|
|
if !isRasterizing {
|
|
context.setBlendMode(.copy)
|
|
context.setFillColor(UIColor.clear.cgColor)
|
|
context.fill(bounds)
|
|
}
|
|
|
|
guard let parameters = parameters as? VoiceChatActionButtonBackgroundNodeDrawingState else {
|
|
return
|
|
}
|
|
|
|
let greyColor = UIColor(rgb: 0x1c1c1e)
|
|
let buttonSize = CGSize(width: 144.0, height: 144.0)
|
|
let radius = buttonSize.width / 2.0
|
|
|
|
var gradientCenter = CGPoint(x: bounds.size.width - 30.0, y: 50.0)
|
|
|
|
var gradientTransition: CGFloat = 0.0
|
|
var gradientImage: UIImage? = parameters.state.blueGradient
|
|
let gradientSize: CGFloat = bounds.width * 2.0
|
|
|
|
context.interpolationQuality = .low
|
|
|
|
var appearanceProgress: CGFloat = 1.0
|
|
if let transition = parameters.transition, transition.previousState is VoiceChatActionButtonBackgroundNodeConnectingState {
|
|
appearanceProgress = transition.progress(time: parameters.timestamp)
|
|
}
|
|
|
|
if let blobsState = parameters.state as? VoiceChatActionButtonBackgroundNodeBlobState {
|
|
gradientTransition = blobsState.active ? 1.0 : 0.0
|
|
if let transition = blobsState.activeTransitionArguments {
|
|
gradientTransition = CGFloat((parameters.timestamp - transition.startTime) / transition.duration)
|
|
if !blobsState.active {
|
|
gradientTransition = 1.0 - gradientTransition
|
|
}
|
|
}
|
|
gradientImage = gradientTransition.isZero ? blobsState.blueGradient : blobsState.greenGradient
|
|
if gradientTransition > 0.0 && gradientTransition < 1.0 {
|
|
gradientImage = generateImage(CGSize(width: 100.0, height: 100.0), contextGenerator: { size, context in
|
|
context.interpolationQuality = .low
|
|
if let image = blobsState.blueGradient?.cgImage {
|
|
context.draw(image, in: CGRect(origin: CGPoint(), size: CGSize(width: 100.0, height: 100.0)))
|
|
}
|
|
|
|
context.setAlpha(gradientTransition)
|
|
if let image = blobsState.greenGradient?.cgImage {
|
|
context.draw(image, in: CGRect(origin: CGPoint(), size: CGSize(width: 100.0, height: 100.0)))
|
|
}
|
|
}, opaque: true, scale: deviceScale)!
|
|
}
|
|
|
|
context.saveGState()
|
|
var maskBounds = bounds
|
|
if let transition = parameters.transition, transition.previousState is VoiceChatActionButtonBackgroundNodeConnectingState {
|
|
let progress = 1.0 - appearanceProgress
|
|
maskBounds = maskBounds.insetBy(dx: bounds.width / 3.0 * progress, dy: bounds.width / 3.0 * progress)
|
|
}
|
|
context.clip(to: maskBounds, mask: radialMaskImage.cgImage!)
|
|
|
|
if let gradient = gradientImage?.cgImage {
|
|
context.draw(gradient, in: CGRect(origin: CGPoint(x: gradientCenter.x - gradientSize / 2.0, y: gradientCenter.y - gradientSize / 2.0), size: CGSize(width: gradientSize, height: gradientSize)))
|
|
}
|
|
context.restoreGState()
|
|
}
|
|
|
|
context.saveGState()
|
|
|
|
if let blobsState = parameters.state as? VoiceChatActionButtonBackgroundNodeBlobState {
|
|
for blob in blobsState.blobs {
|
|
if let path = blob.currentShape, let uiPath = path.copy() as? UIBezierPath {
|
|
let toOrigin = CGAffineTransform(translationX: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0)
|
|
let fromOrigin = CGAffineTransform(translationX: bounds.size.width / 2.0, y: bounds.size.height / 2.0)
|
|
|
|
uiPath.apply(toOrigin)
|
|
uiPath.apply(CGAffineTransform(scaleX: blob.currentScale * appearanceProgress, y: blob.currentScale * appearanceProgress))
|
|
uiPath.apply(fromOrigin)
|
|
|
|
context.addPath(uiPath.cgPath)
|
|
context.clip()
|
|
|
|
context.setAlpha(blob.alpha)
|
|
|
|
if let gradient = gradientImage?.cgImage {
|
|
context.draw(gradient, in: CGRect(origin: CGPoint(x: gradientCenter.x - gradientSize / 2.0, y: gradientCenter.y - gradientSize / 2.0), size: CGSize(width: gradientSize, height: gradientSize)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
context.restoreGState()
|
|
|
|
context.setFillColor(greyColor.cgColor)
|
|
|
|
let buttonRect = bounds.insetBy(dx: (bounds.width - 144.0) / 2.0, dy: (bounds.height - 144.0) / 2.0)
|
|
context.fillEllipse(in: buttonRect)
|
|
|
|
var drawGradient = false
|
|
let lineWidth = 3.0 + UIScreenPixel
|
|
if parameters.state is VoiceChatActionButtonBackgroundNodeConnectingState || parameters.transition?.previousState is VoiceChatActionButtonBackgroundNodeConnectingState {
|
|
var globalAngle: CGFloat = CGFloat(parameters.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0))
|
|
globalAngle *= 4.0
|
|
globalAngle = CGFloat(globalAngle.truncatingRemainder(dividingBy: CGFloat.pi * 2.0))
|
|
|
|
var timestamp = parameters.timestamp
|
|
if let transition = parameters.transition {
|
|
timestamp = transition.startTime
|
|
}
|
|
|
|
var skip = false
|
|
var progress = CGFloat(1.0 + timestamp.remainder(dividingBy: 2.0))
|
|
if let transition = parameters.transition {
|
|
var transitionProgress = transition.progress(time: parameters.timestamp)
|
|
if parameters.state is VoiceChatActionButtonBackgroundNodeBlobState {
|
|
transitionProgress = min(1.0, transitionProgress / 0.5)
|
|
progress = progress + (2.0 - progress) * transitionProgress
|
|
if transitionProgress >= 1.0 {
|
|
skip = true
|
|
}
|
|
} else if parameters.state is VoiceChatActionButtonBackgroundNodeDisabledState {
|
|
progress = progress + (1.0 - progress) * transition.progress(time: parameters.timestamp)
|
|
if transitionProgress >= 1.0 {
|
|
skip = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !skip {
|
|
var startAngle = -CGFloat.pi / 2.0 + globalAngle
|
|
var endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle
|
|
if progress > 1.0 {
|
|
let tmp = startAngle
|
|
startAngle = endAngle
|
|
endAngle = 2.0 * CGFloat.pi + tmp
|
|
}
|
|
|
|
let path = CGMutablePath()
|
|
path.addArc(center: CGPoint(x: bounds.width / 2.0, y: bounds.height / 2.0), radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
|
|
|
|
let filledPath = path.copy(strokingWithWidth: lineWidth, lineCap: .round, lineJoin: .miter, miterLimit: 10)
|
|
context.addPath(filledPath)
|
|
context.clip()
|
|
|
|
drawGradient = true
|
|
}
|
|
}
|
|
|
|
var clearInside: CGFloat?
|
|
if parameters.state is VoiceChatActionButtonBackgroundNodeBlobState {
|
|
let path = CGMutablePath()
|
|
path.addEllipse(in: buttonRect.insetBy(dx: -lineWidth / 2.0, dy: -lineWidth / 2.0))
|
|
context.addPath(path)
|
|
context.clip()
|
|
if let transition = parameters.transition, transition.previousState is VoiceChatActionButtonBackgroundNodeConnectingState || transition.previousState is VoiceChatActionButtonBackgroundNodeDisabledState, transition.progress(time: parameters.timestamp) > 0.5 {
|
|
let progress = (transition.progress(time: parameters.timestamp) - 0.5) / 0.5
|
|
clearInside = progress
|
|
}
|
|
|
|
drawGradient = true
|
|
}
|
|
|
|
if drawGradient, let gradient = gradientImage?.cgImage {
|
|
context.draw(gradient, in: CGRect(origin: CGPoint(x: gradientCenter.x - gradientSize / 2.0, y: gradientCenter.y - gradientSize / 2.0), size: CGSize(width: gradientSize, height: gradientSize)))
|
|
}
|
|
|
|
if let clearInside = clearInside {
|
|
context.setFillColor(greyColor.cgColor)
|
|
context.fillEllipse(in: buttonRect.insetBy(dx: clearInside * radius, dy: clearInside * radius))
|
|
}
|
|
}
|
|
}
|
|
|
|
final class VoiceChatActionButton: HighlightTrackingButtonNode {
|
|
private let containerNode: ASDisplayNode
|
|
private let backgroundNode: VoiceChatActionButtonBackgroundNode
|
|
let iconNode: VoiceChatMicrophoneNode
|
|
let titleLabel: ImmediateTextNode
|
|
let subtitleLabel: ImmediateTextNode
|
|
|
|
let blueGradient: UIImage
|
|
let greenGradient: UIImage
|
|
|
|
private var currentParams: (size: CGSize, buttonSize: CGSize, state: VoiceChatActionButtonState, title: String, subtitle: String)?
|
|
|
|
var pressing: Bool = false {
|
|
didSet {
|
|
if self.pressing {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: self.containerNode, scale: 0.9)
|
|
} else {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: self.containerNode, scale: 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
init() {
|
|
self.containerNode = ASDisplayNode()
|
|
self.backgroundNode = VoiceChatActionButtonBackgroundNode()
|
|
self.iconNode = VoiceChatMicrophoneNode()
|
|
|
|
self.titleLabel = ImmediateTextNode()
|
|
self.subtitleLabel = ImmediateTextNode()
|
|
|
|
self.blueGradient = generateImage(CGSize(width: 180.0, height: 180.0), contextGenerator: { size, context in
|
|
let firstColor = lightBlue
|
|
let secondColor = blue
|
|
|
|
var locations: [CGFloat] = [0.0, 1.0]
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
|
|
let gradientCenter = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
|
let gradientStartRadius: CGFloat = 0.0
|
|
let gradientEndRadius: CGFloat = 85.0
|
|
|
|
let colors: [CGColor] = [firstColor.cgColor, secondColor.cgColor]
|
|
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
|
|
context.drawRadialGradient(gradient, startCenter: gradientCenter, startRadius: gradientStartRadius, endCenter: gradientCenter, endRadius: gradientEndRadius, options: .drawsAfterEndLocation)
|
|
}, opaque: true, scale: min(2.0, deviceScale))!
|
|
|
|
self.greenGradient = generateImage(CGSize(width: 180.0, height: 180.0), contextGenerator: { size, context in
|
|
let firstColor = blue
|
|
let secondColor = green
|
|
|
|
var locations: [CGFloat] = [0.0, 1.0]
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
|
|
let gradientCenter = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
|
let gradientStartRadius: CGFloat = 0.0
|
|
let gradientEndRadius: CGFloat = 85.0
|
|
|
|
let colors: [CGColor] = [firstColor.cgColor, secondColor.cgColor]
|
|
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
|
|
context.drawRadialGradient(gradient, startCenter: gradientCenter, startRadius: gradientStartRadius, endCenter: gradientCenter, endRadius: gradientEndRadius, options: .drawsAfterEndLocation)
|
|
}, opaque: true, scale: min(2.0, deviceScale))!
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.titleLabel)
|
|
self.addSubnode(self.subtitleLabel)
|
|
|
|
self.addSubnode(self.containerNode)
|
|
self.containerNode.addSubnode(self.backgroundNode)
|
|
self.containerNode.addSubnode(self.iconNode)
|
|
|
|
self.highligthedChanged = { [weak self] highlighted in
|
|
if let strongSelf = self {
|
|
if highlighted {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: strongSelf.containerNode, scale: 0.9)
|
|
} else if !strongSelf.pressing {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: strongSelf.containerNode, scale: 1.0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateLevel(_ level: CGFloat) {
|
|
let maxLevel: CGFloat = 1.0
|
|
let normalizedLevel = min(1, max(level / maxLevel, 0))
|
|
|
|
self.backgroundNode.audioLevel = normalizedLevel
|
|
}
|
|
|
|
func update(size: CGSize, buttonSize: CGSize, state: VoiceChatActionButtonState, title: String, subtitle: String, animated: Bool = false) {
|
|
let updatedTitle = self.currentParams?.title != title
|
|
let updatedSubtitle = self.currentParams?.subtitle != subtitle
|
|
|
|
self.currentParams = (size, buttonSize, state, title, subtitle)
|
|
|
|
self.titleLabel.attributedText = NSAttributedString(string: title, font: titleFont, textColor: .white)
|
|
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: subtitleFont, textColor: .white)
|
|
|
|
let blobSize: CGSize = CGSize(width: 244.0, height: 244.0)
|
|
|
|
var iconMuted = true
|
|
var iconColor: UIColor = .white
|
|
var backgroundState: VoiceChatActionButtonBackgroundNodeState
|
|
switch state {
|
|
case let .active(state):
|
|
switch state {
|
|
case .on:
|
|
iconMuted = false
|
|
backgroundState = VoiceChatActionButtonBackgroundNodeBlobState(size: blobSize, active: true, blueGradient: self.blueGradient, greenGradient: self.greenGradient)
|
|
case .muted:
|
|
backgroundState = VoiceChatActionButtonBackgroundNodeBlobState(size: blobSize, active: false, blueGradient: self.blueGradient, greenGradient: self.greenGradient)
|
|
case .cantSpeak:
|
|
iconColor = UIColor(rgb: 0xff3b30)
|
|
backgroundState = VoiceChatActionButtonBackgroundNodeDisabledState()
|
|
}
|
|
case .connecting:
|
|
backgroundState = VoiceChatActionButtonBackgroundNodeConnectingState(blueGradient: self.blueGradient)
|
|
}
|
|
self.backgroundNode.update(state: backgroundState, animated: true)
|
|
|
|
if animated {
|
|
if let snapshotView = self.titleLabel.view.snapshotContentTree(), updatedTitle {
|
|
self.titleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.titleLabel.view)
|
|
snapshotView.frame = self.titleLabel.frame
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
self.titleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
|
}
|
|
if let snapshotView = self.subtitleLabel.view.snapshotContentTree(), updatedSubtitle {
|
|
self.subtitleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.subtitleLabel.view)
|
|
snapshotView.frame = self.subtitleLabel.frame
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
self.subtitleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
|
}
|
|
}
|
|
|
|
let titleSize = self.titleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
|
|
let subtitleSize = self.subtitleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
|
|
let totalHeight = titleSize.height + subtitleSize.height + 1.0
|
|
|
|
self.titleLabel.frame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor(size.height + 16.0 - totalHeight / 2.0) - 56.0), size: titleSize)
|
|
self.subtitleLabel.frame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: self.titleLabel.frame.maxY + 1.0), size: subtitleSize)
|
|
|
|
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
let iconSize = CGSize(width: 90.0, height: 90.0)
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize)
|
|
|
|
self.iconNode.update(state: VoiceChatMicrophoneNode.State(muted: iconMuted, color: iconColor), animated: true)
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
var hitRect = self.bounds
|
|
if let (_, buttonSize, _, _, _) = self.currentParams {
|
|
hitRect = self.bounds.insetBy(dx: (self.bounds.width - buttonSize.width) / 2.0, dy: (self.bounds.height - buttonSize.height) / 2.0)
|
|
}
|
|
let result = super.hitTest(point, with: event)
|
|
if !hitRect.contains(point) {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|