import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import AnimationUI import AppBundle import ManagedAnimationNode import ComponentFlow private let titleFont = Font.regular(15.0) private let subtitleFont = Font.regular(13.0) private let smallScale: CGFloat = 0.48 private let smallIconScale: CGFloat = 0.69 public final class VoiceChatActionButton: HighlightTrackingButtonNode { static let buttonHeight: CGFloat = 52.0 public enum State: Equatable { public enum ActiveState: Equatable { case cantSpeak case muted case on } public enum ScheduledState: Equatable { case start case subscribe case unsubscribe } case button(text: String) case scheduled(state: ScheduledState) case connecting case active(state: ActiveState) } public var stateValue: State { return self.currentParams?.state ?? .connecting } public var statePromise = ValuePromise() public var state: Signal { return self.statePromise.get() } public let bottomNode: ASDisplayNode private let containerNode: ASDisplayNode private let backgroundNode: VoiceChatActionButtonBackgroundNode private let iconNode: VoiceChatActionButtonIconNode private let labelContainerNode: ASDisplayNode public let titleLabel: ImmediateTextNode private let subtitleLabel: ImmediateTextNode private let buttonTitleLabel: ImmediateTextNode private var currentParams: (size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, dark: Bool, small: Bool, title: String, subtitle: String, snap: Bool)? private var activePromise = ValuePromise(false) private var outerColorPromise = Promise<(UIColor?, UIColor?)>((nil, nil)) public var outerColor: Signal<(UIColor?, UIColor?), NoError> { return self.outerColorPromise.get() } public var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) { didSet { self.backgroundNode.connectingColor = self.connectingColor } } public var activeDisposable = MetaDisposable() public var isDisabled: Bool = false public var ignoreHierarchyChanges: Bool { get { return self.backgroundNode.ignoreHierarchyChanges } set { self.backgroundNode.ignoreHierarchyChanges = newValue } } public var wasActiveWhenPressed = false public var pressing: Bool = false { didSet { guard let (_, _, state, _, small, _, _, snap) = self.currentParams, !self.isDisabled else { return } if self.pressing { let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring) if small { transition.updateTransformScale(node: self.backgroundNode, scale: smallScale * 0.9) transition.updateTransformScale(node: self.iconNode, scale: smallIconScale * 0.9) } else { transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 0.9) } switch state { case let .active(state): switch state { case .on: self.wasActiveWhenPressed = true default: break } case .connecting, .button, .scheduled: break } } else { let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring) if small { transition.updateTransformScale(node: self.backgroundNode, scale: smallScale) transition.updateTransformScale(node: self.iconNode, scale: smallIconScale) } else { transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 1.0) } self.wasActiveWhenPressed = false } } } public var animationsEnabled: Bool = true { didSet { self.backgroundNode.animationsEnabled = self.animationsEnabled } } public init() { self.bottomNode = ASDisplayNode() self.bottomNode.isUserInteractionEnabled = false self.containerNode = ASDisplayNode() self.containerNode.isUserInteractionEnabled = false self.backgroundNode = VoiceChatActionButtonBackgroundNode() self.iconNode = VoiceChatActionButtonIconNode(isColored: false) self.labelContainerNode = ASDisplayNode() self.titleLabel = ImmediateTextNode() self.subtitleLabel = ImmediateTextNode() self.buttonTitleLabel = ImmediateTextNode() self.buttonTitleLabel.isUserInteractionEnabled = false self.buttonTitleLabel.alpha = 0.0 super.init() self.addSubnode(self.bottomNode) self.labelContainerNode.addSubnode(self.titleLabel) self.labelContainerNode.addSubnode(self.subtitleLabel) self.addSubnode(self.labelContainerNode) self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.backgroundNode) self.containerNode.addSubnode(self.iconNode) self.containerNode.addSubnode(self.buttonTitleLabel) self.highligthedChanged = { [weak self] pressing in if let strongSelf = self { guard let (_, _, state, _, small, _, _, snap) = strongSelf.currentParams else { return } if pressing { if case .button = state { strongSelf.containerNode.layer.removeAnimation(forKey: "opacity") strongSelf.containerNode.alpha = 0.4 } else { let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring) if small { transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale * 0.9) transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale * 0.9) } else { transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 0.9) } } } else if !strongSelf.pressing { if case .button = state { strongSelf.containerNode.alpha = 1.0 strongSelf.containerNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } else { let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring) if small { transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale) transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale) } else { transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 1.0) } } } } } self.backgroundNode.updatedActive = { [weak self] active in self?.activePromise.set(active) } self.backgroundNode.updatedColors = { [weak self] outerColor, activeColor in self?.outerColorPromise.set(.single((outerColor, activeColor))) } } deinit { self.activeDisposable.dispose() } public func updateLevel(_ level: CGFloat, immediately: Bool = false) { self.backgroundNode.audioLevel = level } private func applyParams(animated: Bool) { guard let (size, _, state, _, small, title, subtitle, snap) = self.currentParams else { return } let updatedTitle = self.titleLabel.attributedText?.string != title let updatedSubtitle = self.subtitleLabel.attributedText?.string != subtitle self.titleLabel.attributedText = NSAttributedString(string: title, font: titleFont, textColor: .white) self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: subtitleFont, textColor: .white) if animated && self.titleLabel.alpha > 0.0 { 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.labelContainerNode.frame = CGRect(origin: CGPoint(), size: size) let titleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - totalHeight) / 2.0) + 84.0), size: titleSize) let subtitleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: titleLabelFrame.maxY + 1.0), size: subtitleSize) self.titleLabel.bounds = CGRect(origin: CGPoint(), size: titleLabelFrame.size) self.titleLabel.position = titleLabelFrame.center self.subtitleLabel.bounds = CGRect(origin: CGPoint(), size: subtitleLabelFrame.size) self.subtitleLabel.position = subtitleLabelFrame.center self.bottomNode.frame = CGRect(origin: CGPoint(), size: size) 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) var active = false switch state { case let .active(state): switch state { case .on: active = self.pressing && !self.wasActiveWhenPressed default: break } case .connecting, .button, .scheduled: break } if snap { let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate transition.updateTransformScale(node: self.backgroundNode, scale: active ? 0.9 : 0.625) transition.updateTransformScale(node: self.iconNode, scale: 0.625) transition.updateAlpha(node: self.titleLabel, alpha: 0.0) transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0) transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 0.0) } else { let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate if small { transition.updateTransformScale(node: self.backgroundNode, scale: self.pressing ? smallScale * 0.9 : smallScale, delay: 0.0) transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? smallIconScale * 0.9 : smallIconScale, delay: 0.0) transition.updateAlpha(node: self.titleLabel, alpha: 0.0) transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0) transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint(x: 0.0, y: -43.0)) transition.updateTransformScale(node: self.titleLabel, scale: 0.8) transition.updateTransformScale(node: self.subtitleLabel, scale: 0.8) } else { transition.updateTransformScale(node: self.backgroundNode, scale: 1.0, delay: 0.0) transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? 0.9 : 1.0, delay: 0.0) transition.updateAlpha(node: self.titleLabel, alpha: 1.0, delay: 0.05) transition.updateAlpha(node: self.subtitleLabel, alpha: 1.0, delay: 0.05) transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint()) transition.updateTransformScale(node: self.titleLabel, scale: 1.0) transition.updateTransformScale(node: self.subtitleLabel, scale: 1.0) } transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 1.0) } let iconSize = CGSize(width: 100.0, height: 100.0) self.iconNode.bounds = CGRect(origin: CGPoint(), size: iconSize) self.iconNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0) } private var previousIcon: VoiceChatActionButtonIconAnimationState? private func applyIconParams() { guard let (_, _, state, _, _, _, _, _) = self.currentParams else { return } let icon: VoiceChatActionButtonIconAnimationState switch state { case .button: icon = .empty case let .scheduled(state): switch state { case .start: icon = .start case .subscribe: icon = .subscribe case .unsubscribe: icon = .unsubscribe } case let .active(state): switch state { case .on: icon = .unmute case .muted: icon = .mute case .cantSpeak: icon = .hand } case .connecting: if let previousIcon = previousIcon { icon = previousIcon } else { icon = .mute } } self.previousIcon = icon self.iconNode.enqueueState(icon) } public func update(snap: Bool, animated: Bool) { if let previous = self.currentParams { self.currentParams = (previous.size, previous.buttonSize, previous.state, previous.dark, previous.small, previous.title, previous.subtitle, snap) self.backgroundNode.isSnap = snap self.backgroundNode.glowHidden = snap || previous.small self.backgroundNode.updateColors() self.applyParams(animated: animated) self.applyIconParams() } } public func update(size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, title: String, subtitle: String, dark: Bool, small: Bool, animated: Bool = false) { let previous = self.currentParams let previousState = previous?.state self.currentParams = (size, buttonSize, state, dark, small, title, subtitle, previous?.snap ?? false) self.statePromise.set(state) if let previousState = previousState, case .button = previousState, case .scheduled = state { self.buttonTitleLabel.alpha = 0.0 self.buttonTitleLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) self.buttonTitleLabel.layer.animateScale(from: 1.0, to: 0.001, duration: 0.24) self.iconNode.alpha = 1.0 self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.iconNode.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.42, damping: 104.0) } var backgroundState: VoiceChatActionButtonBackgroundNode.State var animated = true switch state { case let .button(text): backgroundState = .button self.buttonTitleLabel.alpha = 1.0 self.buttonTitleLabel.attributedText = NSAttributedString(string: text, font: Font.semibold(17.0), textColor: .white) let titleSize = self.buttonTitleLabel.updateLayout(CGSize(width: size.width, height: 100.0)) self.buttonTitleLabel.frame = CGRect(origin: CGPoint(x: floor((self.bounds.width - titleSize.width) / 2.0), y: floor((self.bounds.height - titleSize.height) / 2.0)), size: titleSize) case .scheduled: backgroundState = .disabled if previousState == .connecting { animated = false } case let .active(state): switch state { case .on: backgroundState = .blob(true) case .muted: backgroundState = .blob(false) case .cantSpeak: backgroundState = .disabled } case .connecting: backgroundState = .connecting } self.applyIconParams() self.backgroundNode.glowHidden = (self.currentParams?.snap ?? false) || small self.backgroundNode.isDark = dark self.backgroundNode.update(state: backgroundState, animated: animated) 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 { self.applyParams(animated: animated) } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { var hitRect = self.bounds if let (_, buttonSize, state, _, _, _, _, _) = self.currentParams { if case .button = state { hitRect = CGRect(x: 0.0, y: floor((self.bounds.height - VoiceChatActionButton.buttonHeight) / 2.0), width: self.bounds.width, height: VoiceChatActionButton.buttonHeight) } else { 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 } public func playAnimation() { self.iconNode.playRandomAnimation() } } public 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) ) } } }