Swiftgram/submodules/TelegramCallsUI/Sources/CallControllerToastNode.swift
2020-08-10 16:49:08 +03:00

322 lines
12 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
private let labelFont = Font.regular(17.0)
private let smallLabelFont = Font.regular(15.0)
private enum ToastDescription: Equatable {
enum Key: Hashable {
case camera
case microphone
case mute
case battery
}
case camera
case microphone
case mute
case battery
var key: Key {
switch self {
case .camera:
return .camera
case .microphone:
return .microphone
case .mute:
return .mute
case .battery:
return .battery
}
}
}
struct CallControllerToastContent: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let camera = CallControllerToastContent(rawValue: 1 << 0)
public static let microphone = CallControllerToastContent(rawValue: 1 << 1)
public static let mute = CallControllerToastContent(rawValue: 1 << 2)
public static let battery = CallControllerToastContent(rawValue: 1 << 3)
}
final class CallControllerToastContainerNode: ASDisplayNode {
private var toastNodes: [ToastDescription.Key: CallControllerToastItemNode] = [:]
private var visibleToastNodes: [CallControllerToastItemNode] = []
private let strings: PresentationStrings
private var validLayout: (CGFloat, CGFloat)?
private var content: CallControllerToastContent?
private var appliedContent: CallControllerToastContent?
var title: String = ""
init(strings: PresentationStrings) {
self.strings = strings
super.init()
}
private func updateToastsLayout(strings: PresentationStrings, content: CallControllerToastContent, width: CGFloat, bottomInset: CGFloat, animated: Bool) -> CGFloat {
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
self.appliedContent = content
let spacing: CGFloat = 18.0
var height: CGFloat = 0.0
var toasts: [ToastDescription] = []
if content.contains(.camera) {
toasts.append(.camera)
}
if content.contains(.microphone) {
toasts.append(.microphone)
}
if content.contains(.mute) {
toasts.append(.mute)
}
if content.contains(.battery) {
toasts.append(.battery)
}
var transitions: [ToastDescription.Key: (ContainedViewLayoutTransition, CGFloat, Bool)] = [:]
var validKeys: [ToastDescription.Key] = []
for toast in toasts {
validKeys.append(toast.key)
var toastTransition = transition
var animateIn = false
let toastNode: CallControllerToastItemNode
if let current = self.toastNodes[toast.key] {
toastNode = current
} else {
toastNode = CallControllerToastItemNode()
self.toastNodes[toast.key] = toastNode
self.addSubnode(toastNode)
self.visibleToastNodes.append(toastNode)
toastTransition = .immediate
animateIn = transition.isAnimated
}
let toastContent: CallControllerToastItemNode.Content
switch toast {
case .camera:
toastContent = CallControllerToastItemNode.Content(
key: .camera,
image: .camera,
text: strings.Call_CameraOff(self.title).0
)
case .microphone:
toastContent = CallControllerToastItemNode.Content(
key: .microphone,
image: .microphone,
text: strings.Call_MicrophoneOff(self.title).0
)
case .mute:
toastContent = CallControllerToastItemNode.Content(
key: .mute,
image: .microphone,
text: strings.Call_YourMicrophoneOff
)
case .battery:
toastContent = CallControllerToastItemNode.Content(
key: .battery,
image: .battery,
text: strings.Call_BatteryLow(self.title).0
)
}
let toastHeight = toastNode.update(width: width, content: toastContent, transition: toastTransition)
transitions[toast.key] = (toastTransition, toastHeight, animateIn)
}
var removedKeys: [ToastDescription.Key] = []
for (key, toastNode) in self.toastNodes {
if !validKeys.contains(key) {
removedKeys.append(key)
self.visibleToastNodes.removeAll { $0 === toastNode }
if animated {
toastNode.animateOut(transition: transition) { [weak toastNode] in
toastNode?.removeFromSupernode()
}
} else {
toastNode.removeFromSupernode()
}
}
}
for key in removedKeys {
self.toastNodes.removeValue(forKey: key)
}
for toastNode in self.visibleToastNodes {
if let content = toastNode.currentContent, let (transition, toastHeight, animateIn) = transitions[content.key] {
transition.updateFrame(node: toastNode, frame: CGRect(x: 0.0, y: height, width: width, height: toastHeight))
height += toastHeight + spacing
if animateIn {
toastNode.animateIn()
}
}
}
if height > 0.0 {
height -= spacing
}
return height
}
func updateLayout(strings: PresentationStrings, content: CallControllerToastContent?, constrainedWidth: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = (constrainedWidth, bottomInset)
self.content = content
if let content = self.content {
return self.updateToastsLayout(strings: strings, content: content, width: constrainedWidth, bottomInset: bottomInset, animated: transition.isAnimated)
} else {
return 0.0
}
}
}
private class CallControllerToastItemNode: ASDisplayNode {
struct Content: Equatable {
enum Image {
case camera
case microphone
case battery
}
var key: ToastDescription.Key
var image: Image
var text: String
init(key: ToastDescription.Key, image: Image, text: String) {
self.key = key
self.image = image
self.text = text
}
}
let clipNode: ASDisplayNode
let effectView: UIVisualEffectView
let iconNode: ASImageNode
let textNode: ImmediateTextNode
private(set) var currentContent: Content?
private(set) var currentWidth: CGFloat?
private(set) var currentHeight: CGFloat?
override init() {
self.clipNode = ASDisplayNode()
self.clipNode.clipsToBounds = true
self.clipNode.layer.cornerRadius = 14.0
if #available(iOS 13.0, *) {
self.clipNode.layer.cornerCurve = .continuous
}
self.effectView = UIVisualEffectView()
self.effectView.effect = UIBlurEffect(style: .light)
self.effectView.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.contentMode = .center
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 2
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.clipNode)
self.clipNode.view.addSubview(self.effectView)
self.clipNode.addSubnode(self.iconNode)
self.clipNode.addSubnode(self.textNode)
}
func update(width: CGFloat, content: Content, transition: ContainedViewLayoutTransition) -> CGFloat {
let inset: CGFloat = 30.0
let isNarrowScreen = width <= 320.0
let font = isNarrowScreen ? smallLabelFont : labelFont
let topInset: CGFloat = isNarrowScreen ? 5.0 : 4.0
if self.currentContent != content || self.currentWidth != width {
self.currentContent = content
self.currentWidth = width
var image: UIImage?
switch content.image {
case .camera:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastCamera"), color: .white)
case .microphone:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastMicrophone"), color: .white)
case .battery:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastBattery"), color: .white)
}
if transition.isAnimated, let image = image, let previousContent = self.iconNode.image {
self.iconNode.image = image
self.iconNode.layer.animate(from: previousContent.cgImage!, to: image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2)
} else {
self.iconNode.image = image
}
self.textNode.attributedText = NSAttributedString(string: content.text, font: font, textColor: .white)
let iconSize = CGSize(width: 44.0, height: 28.0)
let iconSpacing: CGFloat = isNarrowScreen ? 0.0 : 1.0
let textSize = self.textNode.updateLayout(CGSize(width: width - inset * 2.0 - iconSize.width - iconSpacing, height: 100.0))
let backgroundSize = CGSize(width: iconSize.width + iconSpacing + textSize.width + 6.0 * 2.0, height: max(28.0, textSize.height + 4.0 * 2.0))
let backgroundFrame = CGRect(origin: CGPoint(x: floor((width - backgroundSize.width) / 2.0), y: 0.0), size: backgroundSize)
transition.updateFrame(node: self.clipNode, frame: backgroundFrame)
transition.updateFrame(view: self.effectView, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
self.iconNode.frame = CGRect(origin: CGPoint(), size: iconSize)
self.textNode.frame = CGRect(origin: CGPoint(x: iconSize.width + iconSpacing, y: topInset), size: textSize)
self.currentHeight = backgroundSize.height
}
return self.currentHeight ?? 28.0
}
func animateIn() {
let targetFrame = self.clipNode.frame
let initialFrame = CGRect(x: floor((self.frame.width - 44.0) / 2.0), y: 0.0, width: 44.0, height: 28.0)
self.clipNode.frame = initialFrame
// self.effectView.frame = CGRect(origin: CGPoint(), size: initialFrame.size)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45, damping: 105.0, completion: { _ in
self.clipNode.frame = targetFrame
// self.effectView.frame = CGRect(origin: CGPoint(), size: targetFrame.size)
self.clipNode.layer.animateFrame(from: initialFrame, to: targetFrame, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring)
// self.effectView.layer.animateFrame(from: initialFrame, to: targetFrame, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring)
})
}
func animateOut(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateTransformScale(node: self, scale: 0.1)
transition.updateAlpha(node: self, alpha: 0.0, completion: { _ in
completion()
})
}
}