mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1152 lines
45 KiB
Swift
1152 lines
45 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import LegacyComponents
|
|
|
|
private let titleFont = Font.regular(17.0)
|
|
private let subtitleFont = Font.regular(13.0)
|
|
|
|
private let white = UIColor(rgb: 0xffffff)
|
|
private let greyColor = UIColor(rgb: 0x2c2c2e)
|
|
private let secondaryGreyColor = UIColor(rgb: 0x1c1c1e)
|
|
private let blue = UIColor(rgb: 0x0078ff)
|
|
private let lightBlue = UIColor(rgb: 0x59c7f8)
|
|
private let green = UIColor(rgb: 0x33c659)
|
|
|
|
private let areaSize = CGSize(width: 440.0, height: 440.0)
|
|
private let blobSize = CGSize(width: 244.0, height: 244.0)
|
|
|
|
final class VoiceChatActionButton: HighlightTrackingButtonNode {
|
|
enum State {
|
|
enum ActiveState {
|
|
case cantSpeak
|
|
case muted
|
|
case on
|
|
}
|
|
|
|
case connecting
|
|
case active(state: ActiveState)
|
|
}
|
|
|
|
private let containerNode: ASDisplayNode
|
|
private let backgroundNode: VoiceChatActionButtonBackgroundNode
|
|
private let iconNode: VoiceChatMicrophoneNode
|
|
private let titleLabel: ImmediateTextNode
|
|
private let subtitleLabel: ImmediateTextNode
|
|
|
|
private var currentParams: (size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, dark: Bool, small: Bool, title: String, subtitle: String)?
|
|
|
|
private var activePromise = ValuePromise<Bool>(false)
|
|
private var outerColorPromise = ValuePromise<UIColor?>(nil)
|
|
var outerColor: Signal<UIColor?, NoError> {
|
|
return outerColorPromise.get()
|
|
}
|
|
var activeDisposable = MetaDisposable()
|
|
|
|
var pressing: Bool = false {
|
|
didSet {
|
|
if self.pressing {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: self.iconNode, scale: 0.9)
|
|
} else {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: self.iconNode, scale: 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
init() {
|
|
self.containerNode = ASDisplayNode()
|
|
self.backgroundNode = VoiceChatActionButtonBackgroundNode()
|
|
self.iconNode = VoiceChatMicrophoneNode()
|
|
|
|
self.titleLabel = ImmediateTextNode()
|
|
self.subtitleLabel = ImmediateTextNode()
|
|
|
|
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.iconNode, scale: 0.9)
|
|
} else if !strongSelf.pressing {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
transition.updateTransformScale(node: strongSelf.iconNode, scale: 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
self.backgroundNode.updatedActive = { [weak self] active in
|
|
self?.activePromise.set(active)
|
|
}
|
|
|
|
self.backgroundNode.updatedOuterColor = { [weak self] color in
|
|
self?.outerColorPromise.set(color)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.activeDisposable.dispose()
|
|
}
|
|
|
|
func updateLevel(_ level: CGFloat) {
|
|
self.backgroundNode.audioLevel = level
|
|
}
|
|
|
|
func applyParams(animated: Bool) {
|
|
guard let (size, _, _, _, small, title, subtitle) = self.currentParams else {
|
|
return
|
|
}
|
|
|
|
let updatedTitle = self.titleLabel.attributedText?.string != title
|
|
let updatedSubtitle = self.subtitleLabel.attributedText?.string != title
|
|
|
|
self.titleLabel.attributedText = NSAttributedString(string: title, font: titleFont, textColor: .white)
|
|
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: subtitleFont, textColor: .white)
|
|
|
|
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 - totalHeight / 2.0) - 110.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.bounds = CGRect(origin: CGPoint(), size: size)
|
|
self.backgroundNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
|
if small {
|
|
self.backgroundNode.transform = CATransform3DMakeScale(0.85, 0.85, 1.0)
|
|
} else {
|
|
self.backgroundNode.transform = CATransform3DIdentity
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func update(size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, title: String, subtitle: String, dark: Bool, small: Bool, animated: Bool = false) {
|
|
let previousState = self.currentParams?.state
|
|
self.currentParams = (size, buttonSize, state, dark, small, title, subtitle)
|
|
|
|
var iconMuted = true
|
|
var iconColor: UIColor = .white
|
|
var backgroundState: VoiceChatActionButtonBackgroundNode.State
|
|
switch state {
|
|
case let .active(state):
|
|
switch state {
|
|
case .on:
|
|
iconMuted = false
|
|
backgroundState = .blob(true)
|
|
case .muted:
|
|
backgroundState = .blob(false)
|
|
case .cantSpeak:
|
|
iconColor = UIColor(rgb: 0xff3b30)
|
|
backgroundState = .disabled
|
|
}
|
|
case .connecting:
|
|
backgroundState = .connecting
|
|
}
|
|
self.backgroundNode.updateColor(dark: dark)
|
|
self.backgroundNode.update(state: backgroundState, animated: true)
|
|
self.iconNode.update(state: VoiceChatMicrophoneNode.State(muted: iconMuted, color: iconColor), animated: true)
|
|
|
|
if case .active = state, let previousState = previousState, case .connecting = previousState, animated {
|
|
self.activeDisposable.set((self.activePromise.get()
|
|
|> deliverOnMainQueue).start(next: { [weak self] active in
|
|
if active {
|
|
self?.activeDisposable.set(nil)
|
|
self?.applyParams(animated: true)
|
|
}
|
|
}))
|
|
} else {
|
|
applyParams(animated: animated)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
extension UIBezierPath {
|
|
static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat, curve: Bool = false) -> 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()
|
|
if curve {
|
|
resultPath.move(to: CGPoint())
|
|
resultPath.addLine(to: smoothPoints[0].point)
|
|
} else {
|
|
resultPath.move(to: smoothPoints[0].point)
|
|
}
|
|
|
|
let smoothCount = curve ? smoothPoints.count - 1 : smoothPoints.count
|
|
for index in (0 ..< smoothCount) {
|
|
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)
|
|
}
|
|
if curve {
|
|
resultPath.addLine(to: CGPoint(x: length, y: 0.0))
|
|
}
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private let progressLineWidth: CGFloat = 3.0 + UIScreenPixel
|
|
private let buttonSize = CGSize(width: 144.0, height: 144.0)
|
|
private let radius = buttonSize.width / 2.0
|
|
|
|
private final class VoiceChatActionButtonBackgroundNode: ASDisplayNode {
|
|
enum State: Equatable {
|
|
case connecting
|
|
case disabled
|
|
case blob(Bool)
|
|
}
|
|
|
|
private var state: State
|
|
private var hasState = false
|
|
private var transition: State?
|
|
|
|
var audioLevel: CGFloat = 0.0 {
|
|
didSet {
|
|
self.maskBlobView.updateLevel(audioLevel)
|
|
}
|
|
}
|
|
|
|
var updatedActive: ((Bool) -> Void)?
|
|
var updatedOuterColor: ((UIColor?) -> Void)?
|
|
|
|
private let backgroundCircleLayer = CAShapeLayer()
|
|
private let foregroundCircleLayer = CAShapeLayer()
|
|
|
|
private let foregroundView = UIView()
|
|
private let foregroundGradientLayer = CAGradientLayer()
|
|
|
|
private let maskView = UIView()
|
|
private let maskGradientLayer = CAGradientLayer()
|
|
private let maskBlobView: VoiceBlobView
|
|
private let maskCircleLayer = CAShapeLayer()
|
|
|
|
private let maskProgressLayer = CAShapeLayer()
|
|
|
|
private let maskMediumBlobLayer = CAShapeLayer()
|
|
private let maskBigBlobLayer = CAShapeLayer()
|
|
|
|
private let hierarchyTrackingNode: HierarchyTrackingNode
|
|
private var isCurrentlyInHierarchy = false
|
|
|
|
override init() {
|
|
self.state = .connecting
|
|
|
|
self.maskBlobView = VoiceBlobView(frame: CGRect(origin: CGPoint(x: (areaSize.width - blobSize.width) / 2.0, y: (areaSize.height - blobSize.height) / 2.0), size: blobSize), maxLevel: 2.0, mediumBlobRange: (0.69, 0.87), bigBlobRange: (0.71, 1.0))
|
|
self.maskBlobView.setColor(white)
|
|
self.maskBlobView.isHidden = true
|
|
|
|
var updateInHierarchy: ((Bool) -> Void)?
|
|
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
|
|
updateInHierarchy?(value)
|
|
})
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.hierarchyTrackingNode)
|
|
|
|
let circlePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: buttonSize)).cgPath
|
|
self.backgroundCircleLayer.fillColor = greyColor.cgColor
|
|
self.backgroundCircleLayer.path = circlePath
|
|
|
|
let smallerCirclePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: CGSize(width: buttonSize.width - progressLineWidth, height: buttonSize.height - progressLineWidth))).cgPath
|
|
self.foregroundCircleLayer.fillColor = greyColor.cgColor
|
|
self.foregroundCircleLayer.path = smallerCirclePath
|
|
self.foregroundCircleLayer.transform = CATransform3DMakeScale(0.0, 0.0, 1)
|
|
self.foregroundCircleLayer.isHidden = true
|
|
|
|
self.foregroundGradientLayer.type = .radial
|
|
self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor]
|
|
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
|
|
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
|
|
|
|
self.maskView.backgroundColor = .clear
|
|
|
|
self.maskGradientLayer.type = .radial
|
|
self.maskGradientLayer.colors = [UIColor(rgb: 0xffffff, alpha: 0.4).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
|
|
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
|
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
|
|
self.maskGradientLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1.0)
|
|
self.maskGradientLayer.isHidden = true
|
|
|
|
let path = CGMutablePath()
|
|
path.addArc(center: CGPoint(x: (buttonSize.width + 6.0) / 2.0, y: (buttonSize.height + 6.0) / 2.0), radius: radius, startAngle: 0.0, endAngle: CGFloat.pi * 2.0, clockwise: true)
|
|
|
|
self.maskProgressLayer.strokeColor = white.cgColor
|
|
self.maskProgressLayer.fillColor = UIColor.clear.cgColor
|
|
self.maskProgressLayer.lineWidth = progressLineWidth
|
|
self.maskProgressLayer.lineCap = .round
|
|
self.maskProgressLayer.path = path
|
|
|
|
let largerCirclePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: CGSize(width: buttonSize.width + progressLineWidth, height: buttonSize.height + progressLineWidth))).cgPath
|
|
self.maskCircleLayer.fillColor = white.cgColor
|
|
self.maskCircleLayer.path = largerCirclePath
|
|
self.maskCircleLayer.isHidden = true
|
|
|
|
updateInHierarchy = { [weak self] value in
|
|
if let strongSelf = self {
|
|
strongSelf.isCurrentlyInHierarchy = value
|
|
strongSelf.updateAnimations()
|
|
}
|
|
}
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
|
|
self.layer.addSublayer(self.backgroundCircleLayer)
|
|
|
|
self.view.addSubview(self.foregroundView)
|
|
self.layer.addSublayer(self.foregroundCircleLayer)
|
|
|
|
self.foregroundView.mask = self.maskView
|
|
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
|
|
|
|
self.maskView.layer.addSublayer(self.maskGradientLayer)
|
|
self.maskView.layer.addSublayer(self.maskProgressLayer)
|
|
self.maskView.addSubview(self.maskBlobView)
|
|
self.maskView.layer.addSublayer(self.maskCircleLayer)
|
|
|
|
self.maskBlobView.scaleUpdated = { [weak self] scale in
|
|
if let strongSelf = self {
|
|
strongSelf.updateGlowScale(strongSelf.isActive ? scale : nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupGradientAnimations() {
|
|
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
|
|
} else {
|
|
let previousValue = self.foregroundGradientLayer.startPoint
|
|
let newValue: CGPoint
|
|
if self.maskBlobView.presentationAudioLevel > 0.15 {
|
|
newValue = CGPoint(x: CGFloat.random(in: 0.8 ..< 1.0), y: CGFloat.random(in: 0.1 ..< 0.45))
|
|
} else {
|
|
newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.8), y: CGFloat.random(in: 0.1 ..< 0.45))
|
|
}
|
|
self.foregroundGradientLayer.startPoint = newValue
|
|
|
|
CATransaction.begin()
|
|
|
|
let animation = CABasicAnimation(keyPath: "startPoint")
|
|
animation.duration = Double.random(in: 0.8 ..< 1.4)
|
|
animation.fromValue = previousValue
|
|
animation.toValue = newValue
|
|
|
|
CATransaction.setCompletionBlock { [weak self] in
|
|
if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy {
|
|
self?.setupGradientAnimations()
|
|
}
|
|
}
|
|
|
|
self.foregroundGradientLayer.add(animation, forKey: "movement")
|
|
CATransaction.commit()
|
|
}
|
|
}
|
|
|
|
private func setupProgressAnimations() {
|
|
if let _ = self.maskProgressLayer.animation(forKey: "progressRotation") {
|
|
} else {
|
|
self.maskProgressLayer.isHidden = false
|
|
|
|
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
|
|
animation.duration = 1.0
|
|
animation.fromValue = NSNumber(value: Float(0.0))
|
|
animation.toValue = NSNumber(value: Float.pi * 2.0)
|
|
animation.repeatCount = Float.infinity
|
|
animation.beginTime = 0.0
|
|
self.maskProgressLayer.add(animation, forKey: "progressRotation")
|
|
|
|
let shrinkAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
|
shrinkAnimation.fromValue = 1.0
|
|
shrinkAnimation.toValue = 0.0
|
|
shrinkAnimation.duration = 1.0
|
|
shrinkAnimation.beginTime = 0.0
|
|
|
|
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
|
growthAnimation.fromValue = 0.0
|
|
growthAnimation.toValue = 1.0
|
|
growthAnimation.duration = 1.0
|
|
growthAnimation.beginTime = 1.0
|
|
|
|
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
rotateAnimation.fromValue = 0.0
|
|
rotateAnimation.toValue = CGFloat.pi * 2
|
|
rotateAnimation.isAdditive = true
|
|
rotateAnimation.duration = 1.0
|
|
rotateAnimation.beginTime = 1.0
|
|
|
|
let groupAnimation = CAAnimationGroup()
|
|
groupAnimation.repeatCount = Float.infinity
|
|
groupAnimation.animations = [shrinkAnimation, growthAnimation, rotateAnimation]
|
|
groupAnimation.duration = 2.0
|
|
|
|
self.maskProgressLayer.add(groupAnimation, forKey: "progressGrowth")
|
|
}
|
|
}
|
|
|
|
func updateGlowScale(_ scale: CGFloat?) {
|
|
return
|
|
if let scale = scale {
|
|
self.maskGradientLayer.transform = CATransform3DMakeScale(0.89 + 0.11 * scale, 0.89 + 0.11 * scale, 1.0)
|
|
} else {
|
|
// let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (effectivePreviousActive ? 0.95 : 0.8))
|
|
// let targetScale: CGFloat = self.isActive ? 0.89 : 0.85
|
|
// self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
|
|
// self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
|
|
}
|
|
}
|
|
|
|
func updateGlowAndGradientAnimations(active: Bool?, previousActive: Bool? = nil) {
|
|
let effectivePreviousActive = previousActive ?? false
|
|
|
|
let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (effectivePreviousActive ? 0.95 : 0.8))
|
|
let initialColors = self.foregroundGradientLayer.colors
|
|
|
|
let outerColor: UIColor?
|
|
let targetColors: [CGColor]
|
|
let targetScale: CGFloat
|
|
if let active = active {
|
|
if active {
|
|
targetColors = [blue.cgColor, green.cgColor]
|
|
targetScale = 0.89
|
|
outerColor = UIColor(rgb: 0x005720)
|
|
} else {
|
|
targetColors = [lightBlue.cgColor, blue.cgColor]
|
|
targetScale = 0.85
|
|
outerColor = UIColor(rgb: 0x00274d)
|
|
}
|
|
} else {
|
|
targetColors = [lightBlue.cgColor, blue.cgColor]
|
|
targetScale = 0.3
|
|
outerColor = nil
|
|
}
|
|
self.updatedOuterColor?(outerColor)
|
|
|
|
self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
|
|
if let _ = previousActive {
|
|
self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
|
|
} else {
|
|
self.maskGradientLayer.animateSpring(from: initialScale as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
|
}
|
|
|
|
self.foregroundGradientLayer.colors = targetColors
|
|
self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
|
}
|
|
|
|
private func playConnectionDisappearanceAnimation() {
|
|
let initialRotation: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.transform.rotation.z") as? NSNumber)?.floatValue ?? 0.0)
|
|
let initialStrokeEnd: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.strokeEnd") as? NSNumber)?.floatValue ?? 1.0)
|
|
|
|
self.maskProgressLayer.removeAnimation(forKey: "progressGrowth")
|
|
self.maskProgressLayer.removeAnimation(forKey: "progressRotation")
|
|
|
|
let duration: Double = (1.0 - Double(initialStrokeEnd)) * 0.6
|
|
|
|
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
|
growthAnimation.fromValue = initialStrokeEnd
|
|
growthAnimation.toValue = 0.0
|
|
growthAnimation.duration = duration
|
|
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
rotateAnimation.fromValue = initialRotation
|
|
rotateAnimation.toValue = initialRotation + CGFloat.pi * 2
|
|
rotateAnimation.isAdditive = true
|
|
rotateAnimation.duration = duration
|
|
rotateAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
let groupAnimation = CAAnimationGroup()
|
|
groupAnimation.animations = [growthAnimation, rotateAnimation]
|
|
groupAnimation.duration = duration
|
|
|
|
CATransaction.setCompletionBlock {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.maskProgressLayer.isHidden = true
|
|
self.maskProgressLayer.removeAllAnimations()
|
|
CATransaction.commit()
|
|
}
|
|
|
|
self.maskProgressLayer.add(groupAnimation, forKey: "progressDisappearance")
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func playBlobsDisappearanceAnimation() {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.foregroundCircleLayer.isHidden = false
|
|
CATransaction.commit()
|
|
|
|
self.updateGlowAndGradientAnimations(active: nil, previousActive: nil)
|
|
|
|
self.maskBlobView.startAnimating()
|
|
self.maskBlobView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in
|
|
self?.maskBlobView.isHidden = true
|
|
self?.maskBlobView.stopAnimating()
|
|
self?.maskBlobView.layer.removeAllAnimations()
|
|
})
|
|
|
|
CATransaction.begin()
|
|
let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
|
|
growthAnimation.fromValue = 0.0
|
|
growthAnimation.toValue = 1.0
|
|
growthAnimation.duration = 0.15
|
|
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
|
|
growthAnimation.isRemovedOnCompletion = false
|
|
|
|
CATransaction.setCompletionBlock {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.maskGradientLayer.isHidden = true
|
|
self.maskCircleLayer.isHidden = true
|
|
self.foregroundCircleLayer.isHidden = true
|
|
self.foregroundCircleLayer.removeAllAnimations()
|
|
CATransaction.commit()
|
|
}
|
|
|
|
self.foregroundCircleLayer.add(growthAnimation, forKey: "insideGrowth")
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func playBlobsAppearanceAnimation(active: Bool) {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.foregroundCircleLayer.isHidden = false
|
|
self.maskCircleLayer.isHidden = false
|
|
self.maskProgressLayer.isHidden = true
|
|
self.maskGradientLayer.isHidden = false
|
|
CATransaction.commit()
|
|
|
|
self.updateGlowAndGradientAnimations(active: active, previousActive: nil)
|
|
|
|
self.maskBlobView.isHidden = false
|
|
self.maskBlobView.startAnimating()
|
|
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
|
|
|
CATransaction.begin()
|
|
let shrinkAnimation = CABasicAnimation(keyPath: "transform.scale")
|
|
shrinkAnimation.fromValue = 1.0
|
|
shrinkAnimation.toValue = 0.0
|
|
shrinkAnimation.duration = 0.15
|
|
shrinkAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
CATransaction.setCompletionBlock {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.foregroundCircleLayer.isHidden = true
|
|
CATransaction.commit()
|
|
}
|
|
|
|
self.foregroundCircleLayer.add(shrinkAnimation, forKey: "insideShrink")
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func playConnectionAnimation(active: Bool, completion: @escaping () -> Void) {
|
|
CATransaction.begin()
|
|
let initialRotation: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.transform.rotation.z") as? NSNumber)?.floatValue ?? 0.0)
|
|
let initialStrokeEnd: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.strokeEnd") as? NSNumber)?.floatValue ?? 1.0)
|
|
|
|
self.maskProgressLayer.removeAnimation(forKey: "progressGrowth")
|
|
self.maskProgressLayer.removeAnimation(forKey: "progressRotation")
|
|
|
|
let duration: Double = (1.0 - Double(initialStrokeEnd)) * 0.3
|
|
|
|
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
|
growthAnimation.fromValue = initialStrokeEnd
|
|
growthAnimation.toValue = 1.0
|
|
growthAnimation.duration = duration
|
|
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
rotateAnimation.fromValue = initialRotation
|
|
rotateAnimation.toValue = initialRotation + CGFloat.pi * 2
|
|
rotateAnimation.isAdditive = true
|
|
rotateAnimation.duration = duration
|
|
rotateAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
let groupAnimation = CAAnimationGroup()
|
|
groupAnimation.animations = [growthAnimation, rotateAnimation]
|
|
groupAnimation.duration = duration
|
|
|
|
CATransaction.setCompletionBlock {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.foregroundCircleLayer.isHidden = false
|
|
self.maskCircleLayer.isHidden = false
|
|
self.maskProgressLayer.isHidden = true
|
|
self.maskGradientLayer.isHidden = false
|
|
CATransaction.commit()
|
|
|
|
completion()
|
|
|
|
self.updateGlowAndGradientAnimations(active: active, previousActive: nil)
|
|
|
|
self.maskBlobView.isHidden = false
|
|
self.maskBlobView.startAnimating()
|
|
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
|
|
|
self.updatedActive?(true)
|
|
|
|
CATransaction.begin()
|
|
let shrinkAnimation = CABasicAnimation(keyPath: "transform.scale")
|
|
shrinkAnimation.fromValue = 1.0
|
|
shrinkAnimation.toValue = 0.0
|
|
shrinkAnimation.duration = 0.15
|
|
shrinkAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
|
|
|
CATransaction.setCompletionBlock {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
self.foregroundCircleLayer.isHidden = true
|
|
CATransaction.commit()
|
|
}
|
|
|
|
self.foregroundCircleLayer.add(shrinkAnimation, forKey: "insideShrink")
|
|
CATransaction.commit()
|
|
}
|
|
|
|
self.maskProgressLayer.add(groupAnimation, forKey: "progressCompletion")
|
|
CATransaction.commit()
|
|
}
|
|
|
|
var isActive = false
|
|
func updateAnimations() {
|
|
if !self.isCurrentlyInHierarchy {
|
|
self.foregroundGradientLayer.removeAllAnimations()
|
|
self.maskGradientLayer.removeAllAnimations()
|
|
self.maskProgressLayer.removeAllAnimations()
|
|
self.maskBlobView.stopAnimating()
|
|
return
|
|
}
|
|
self.setupGradientAnimations()
|
|
|
|
switch self.state {
|
|
case .connecting:
|
|
self.updatedActive?(false)
|
|
self.setupProgressAnimations()
|
|
self.isActive = false
|
|
case let .blob(newActive):
|
|
if let transition = self.transition {
|
|
if transition == .connecting {
|
|
self.playConnectionAnimation(active: newActive) { [weak self] in
|
|
self?.isActive = newActive
|
|
}
|
|
} else if transition == .disabled {
|
|
self.playBlobsAppearanceAnimation(active: newActive)
|
|
self.transition = nil
|
|
self.isActive = newActive
|
|
self.updatedActive?(true)
|
|
} else if case let .blob(previousActive) = transition {
|
|
updateGlowAndGradientAnimations(active: newActive, previousActive: previousActive)
|
|
self.transition = nil
|
|
self.isActive = newActive
|
|
}
|
|
self.transition = nil
|
|
} else {
|
|
self.maskBlobView.startAnimating()
|
|
}
|
|
case .disabled:
|
|
self.updatedActive?(true)
|
|
self.isActive = false
|
|
self.updateGlowScale(nil)
|
|
|
|
if let transition = self.transition {
|
|
if case .connecting = transition {
|
|
playConnectionDisappearanceAnimation()
|
|
} else if case .blob = transition {
|
|
playBlobsDisappearanceAnimation()
|
|
}
|
|
self.transition = nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
func updateColor(dark: Bool) {
|
|
let previousColor: CGColor = self.backgroundCircleLayer.fillColor ?? greyColor.cgColor
|
|
let targetColor: CGColor
|
|
if dark {
|
|
targetColor = secondaryGreyColor.cgColor
|
|
} else {
|
|
targetColor = greyColor.cgColor
|
|
}
|
|
self.backgroundCircleLayer.fillColor = targetColor
|
|
self.foregroundCircleLayer.fillColor = targetColor
|
|
self.backgroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
|
self.foregroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
|
}
|
|
|
|
func update(state: State, animated: Bool) {
|
|
var animated = animated
|
|
var hadState = true
|
|
if !self.hasState {
|
|
hadState = false
|
|
self.hasState = true
|
|
animated = false
|
|
}
|
|
|
|
if state != self.state || !hadState {
|
|
if animated {
|
|
self.transition = self.state
|
|
}
|
|
self.state = state
|
|
}
|
|
|
|
self.updateAnimations()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
|
|
let center = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
|
|
|
|
let circleFrame = CGRect(origin: CGPoint(x: (self.bounds.width - buttonSize.width) / 2.0, y: (self.bounds.height - buttonSize.height) / 2.0), size: buttonSize)
|
|
self.backgroundCircleLayer.frame = circleFrame
|
|
self.foregroundCircleLayer.position = center
|
|
self.foregroundCircleLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: circleFrame.width - progressLineWidth, height: circleFrame.height - progressLineWidth))
|
|
self.maskCircleLayer.frame = circleFrame.insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
|
|
self.maskProgressLayer.frame = circleFrame.insetBy(dx: -3.0, dy: -3.0)
|
|
self.foregroundView.frame = self.bounds
|
|
self.foregroundGradientLayer.frame = self.bounds
|
|
self.maskGradientLayer.position = center
|
|
self.maskGradientLayer.bounds = self.bounds
|
|
self.maskView.frame = self.bounds
|
|
}
|
|
}
|
|
|
|
private final class VoiceBlobView: UIView {
|
|
private let mediumBlob: BlobView
|
|
private let bigBlob: BlobView
|
|
|
|
private let maxLevel: CGFloat
|
|
|
|
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
|
|
|
|
private var audioLevel: CGFloat = 0.0
|
|
var presentationAudioLevel: CGFloat = 0.0
|
|
|
|
var scaleUpdated: ((CGFloat) -> Void)? {
|
|
didSet {
|
|
self.bigBlob.scaleUpdated = self.scaleUpdated
|
|
}
|
|
}
|
|
|
|
private(set) var isAnimating = false
|
|
|
|
public typealias BlobRange = (min: CGFloat, max: CGFloat)
|
|
|
|
public init(
|
|
frame: CGRect,
|
|
maxLevel: CGFloat,
|
|
mediumBlobRange: BlobRange,
|
|
bigBlobRange: BlobRange
|
|
) {
|
|
self.maxLevel = maxLevel
|
|
|
|
self.mediumBlob = BlobView(
|
|
pointsCount: 8,
|
|
minRandomness: 1,
|
|
maxRandomness: 1,
|
|
minSpeed: 0.85,
|
|
maxSpeed: 7,
|
|
minScale: mediumBlobRange.min,
|
|
maxScale: mediumBlobRange.max
|
|
)
|
|
self.bigBlob = BlobView(
|
|
pointsCount: 8,
|
|
minRandomness: 1,
|
|
maxRandomness: 1,
|
|
minSpeed: 0.85,
|
|
maxSpeed: 7,
|
|
minScale: bigBlobRange.min,
|
|
maxScale: bigBlobRange.max
|
|
)
|
|
|
|
super.init(frame: frame)
|
|
|
|
addSubview(bigBlob)
|
|
addSubview(mediumBlob)
|
|
|
|
displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
|
|
guard let strongSelf = self else { return }
|
|
|
|
strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1
|
|
|
|
strongSelf.mediumBlob.level = strongSelf.presentationAudioLevel
|
|
strongSelf.bigBlob.level = strongSelf.presentationAudioLevel
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public func setColor(_ color: UIColor) {
|
|
mediumBlob.setColor(color.withAlphaComponent(0.55))
|
|
bigBlob.setColor(color.withAlphaComponent(0.35))
|
|
}
|
|
|
|
public func updateLevel(_ level: CGFloat) {
|
|
let normalizedLevel = min(1, max(level / maxLevel, 0))
|
|
|
|
mediumBlob.updateSpeedLevel(to: normalizedLevel)
|
|
bigBlob.updateSpeedLevel(to: normalizedLevel)
|
|
|
|
audioLevel = normalizedLevel
|
|
}
|
|
|
|
public func startAnimating() {
|
|
guard !isAnimating else { return }
|
|
isAnimating = true
|
|
|
|
updateBlobsState()
|
|
|
|
displayLinkAnimator?.isPaused = false
|
|
}
|
|
|
|
public func stopAnimating() {
|
|
self.stopAnimating(duration: 0.15)
|
|
}
|
|
|
|
public func stopAnimating(duration: Double) {
|
|
guard isAnimating else { return }
|
|
isAnimating = false
|
|
|
|
updateBlobsState()
|
|
|
|
displayLinkAnimator?.isPaused = true
|
|
}
|
|
|
|
private func updateBlobsState() {
|
|
if isAnimating {
|
|
if mediumBlob.frame.size != .zero {
|
|
mediumBlob.startAnimating()
|
|
bigBlob.startAnimating()
|
|
}
|
|
} else {
|
|
mediumBlob.stopAnimating()
|
|
bigBlob.stopAnimating()
|
|
}
|
|
}
|
|
|
|
override public func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
mediumBlob.frame = bounds
|
|
bigBlob.frame = bounds
|
|
|
|
updateBlobsState()
|
|
}
|
|
}
|
|
|
|
final class BlobView: UIView {
|
|
let pointsCount: Int
|
|
let smoothness: CGFloat
|
|
|
|
let minRandomness: CGFloat
|
|
let maxRandomness: CGFloat
|
|
|
|
let minSpeed: CGFloat
|
|
let maxSpeed: CGFloat
|
|
|
|
let minScale: CGFloat
|
|
let maxScale: CGFloat
|
|
|
|
var scaleUpdated: ((CGFloat) -> Void)?
|
|
|
|
var level: CGFloat = 0 {
|
|
didSet {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
let lv = minScale + (maxScale - minScale) * level
|
|
shapeLayer.transform = CATransform3DMakeScale(lv, lv, 1)
|
|
self.scaleUpdated?(level)
|
|
CATransaction.commit()
|
|
}
|
|
}
|
|
|
|
private var speedLevel: CGFloat = 0
|
|
private var lastSpeedLevel: 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,
|
|
minSpeed: CGFloat,
|
|
maxSpeed: CGFloat,
|
|
minScale: CGFloat,
|
|
maxScale: CGFloat
|
|
) {
|
|
self.pointsCount = pointsCount
|
|
self.minRandomness = minRandomness
|
|
self.maxRandomness = maxRandomness
|
|
self.minSpeed = minSpeed
|
|
self.maxSpeed = maxSpeed
|
|
self.minScale = minScale
|
|
self.maxScale = maxScale
|
|
|
|
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)
|
|
|
|
shapeLayer.transform = CATransform3DMakeScale(minScale, minScale, 1)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setColor(_ color: UIColor) {
|
|
shapeLayer.fillColor = color.cgColor
|
|
}
|
|
|
|
func updateSpeedLevel(to newSpeedLevel: CGFloat) {
|
|
speedLevel = max(speedLevel, newSpeedLevel)
|
|
|
|
if abs(lastSpeedLevel - newSpeedLevel) > 0.45 {
|
|
animateToNewShape()
|
|
}
|
|
}
|
|
|
|
func startAnimating() {
|
|
animateToNewShape()
|
|
}
|
|
|
|
func stopAnimating() {
|
|
fromPoints = currentPoints
|
|
toPoints = nil
|
|
pop_removeAnimation(forKey: "blob")
|
|
}
|
|
|
|
private func animateToNewShape() {
|
|
if pop_animation(forKey: "blob") != nil {
|
|
fromPoints = currentPoints
|
|
toPoints = nil
|
|
pop_removeAnimation(forKey: "blob")
|
|
}
|
|
|
|
if fromPoints == nil {
|
|
fromPoints = generateNextBlob(for: bounds.size)
|
|
}
|
|
if toPoints == nil {
|
|
toPoints = generateNextBlob(for: bounds.size)
|
|
}
|
|
|
|
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) * speedLevel))
|
|
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
animation.fromValue = 0
|
|
animation.toValue = 1
|
|
pop_add(animation, forKey: "blob")
|
|
|
|
lastSpeedLevel = speedLevel
|
|
speedLevel = 0
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private func generateNextBlob(for size: CGSize) -> [CGPoint] {
|
|
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
|
|
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()
|
|
}
|
|
}
|