mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
522 lines
22 KiB
Swift
522 lines
22 KiB
Swift
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<State>()
|
|
public var state: Signal<State, NoError> {
|
|
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<Bool>(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)
|
|
)
|
|
}
|
|
}
|
|
}
|