From 81ae40bcdeb77199c70106a1de33c2d0f9d79d74 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 8 Apr 2025 02:44:02 +0400 Subject: [PATCH] Update encryption key animations --- .../VideoChatEncryptionKeyComponent.swift | 258 ++++++++++++++++-- 1 file changed, 241 insertions(+), 17 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift index 7b416571c0..e4d6c43309 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift @@ -7,6 +7,96 @@ import BalancedTextComponent import TelegramPresentationData import CallsEmoji +private final class EmojiContainerView: UIView { + private let maskImageView: UIImageView? + let contentView: UIView + + var isMaskEnabled: Bool = false { + didSet { + if self.isMaskEnabled != oldValue { + if self.isMaskEnabled { + self.mask = self.maskImageView + } else { + self.mask = nil + } + } + } + } + + init(hasMask: Bool) { + if hasMask { + self.maskImageView = UIImageView() + } else { + self.maskImageView = nil + } + + self.contentView = UIView() + self.contentView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.0) + self.contentView.center = CGPoint(x: 0.0, y: 0.0) + + super.init(frame: CGRect()) + + if let maskImageView = self.maskImageView { + self.mask = maskImageView + } + self.addSubview(self.contentView) + self.clipsToBounds = hasMask + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(size: CGSize, borderWidth: CGFloat) { + let minimalHeight = borderWidth * 2.0 + 1.0 + let minimalSize = CGSize(width: 4.0, height: minimalHeight) + + if let maskImageView = self.maskImageView, maskImageView.image?.size != minimalSize { + let generatedImage = generateImage(minimalSize, rotatedContext: { imageSize, context in + context.clear(CGRect(origin: CGPoint(), size: imageSize)) + + let height: CGFloat = borderWidth + let baseGradientAlpha: CGFloat = 1.0 + let numSteps = 8 + let firstStep = 0 + let firstLocation = 0.0 + let colors = (0 ..< numSteps).map { i -> UIColor in + if i < firstStep { + return UIColor(white: 1.0, alpha: 1.0) + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + let value: CGFloat = bezierPoint(0.42, 0.0, 0.58, 1.0, step) + return UIColor(white: 1.0, alpha: baseGradientAlpha * value) + } + } + var locations = (0 ..< numSteps).map { i -> CGFloat in + if i < firstStep { + return 0.0 + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + return (firstLocation + (1.0 - firstLocation) * step) + } + } + + let gradient = CGGradient(colorsSpace: DeviceGraphicsContextSettings.shared.colorSpace, colors: colors.map { $0.cgColor } as CFArray, locations: &locations)! + + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(x: 0.0, y: height), size: CGSize(width: imageSize.width, height: 1.0))) + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: height), options: CGGradientDrawingOptions()) + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: imageSize.height), end: CGPoint(x: 0.0, y: imageSize.height - height), options: CGGradientDrawingOptions()) + }) + + let capInsets = UIEdgeInsets(top: borderWidth, left: 0, bottom: borderWidth, right: 0) + maskImageView.image = generatedImage?.resizableImage(withCapInsets: capInsets, resizingMode: .stretch) + } + if let maskImageView = self.maskImageView { + maskImageView.frame = CGRect(origin: CGPoint(), size: size) + } + self.contentView.bounds = CGRect(origin: CGPoint(), size: size) + } +} + private final class EmojiItemComponent: Component { let emoji: String? @@ -22,8 +112,9 @@ private final class EmojiItemComponent: Component { } final class View: UIView { + private let containerView: EmojiContainerView private let measureEmojiView = ComponentView() - private var pendingContainerView: UIView? + private var pendingContainerView: EmojiContainerView? private var pendingEmojiViews: [ComponentView] = [] private var emojiView: ComponentView? @@ -33,7 +124,11 @@ private final class EmojiItemComponent: Component { private var pendingEmojiValues: [String]? override init(frame: CGRect) { + self.containerView = EmojiContainerView(hasMask: true) + super.init(frame: frame) + + self.addSubview(self.containerView) } required init?(coder: NSCoder) { @@ -44,6 +139,8 @@ private final class EmojiItemComponent: Component { } func update(component: EmojiItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let pendingContainerInset: CGFloat = 6.0 + self.component = component self.state = state @@ -55,10 +152,35 @@ private final class EmojiItemComponent: Component { environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) ) + + let containerFrame = CGRect(origin: CGPoint(x: -pendingContainerInset, y: -pendingContainerInset), size: CGSize(width: size.width + pendingContainerInset * 2.0, height: size.height + pendingContainerInset * 2.0)) + self.containerView.frame = containerFrame + self.containerView.update(size: containerFrame.size, borderWidth: 12.0) + + /*let maxBlur: CGFloat = 4.0 + if component.emoji == nil, (self.containerView.contentView.layer.filters == nil || self.containerView.contentView.layer.filters?.count == 0) { + if let blurFilter = CALayer.blur() { + blurFilter.setValue(maxBlur as NSNumber, forKey: "inputRadius") + self.containerView.contentView.layer.filters = [blurFilter] + self.containerView.contentView.layer.animate(from: 0.0 as NSNumber, to: maxBlur as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: true) + } + } else if self.containerView.contentView.layer.filters != nil && self.containerView.contentView.layer.filters?.count != 0 { + if let blurFilter = CALayer.blur() { + blurFilter.setValue(0.0 as NSNumber, forKey: "inputRadius") + self.containerView.contentView.layer.filters = [blurFilter] + self.containerView.contentView.layer.animate(from: maxBlur as NSNumber, to: 0.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak self] flag in + if flag, let self { + self.containerView.contentView.layer.filters = nil + } + }) + } + }*/ let borderEmoji = 2 let numEmoji = borderEmoji * 2 + 3 + var previousEmojiView: ComponentView? + if let emoji = component.emoji { let emojiView: ComponentView var emojiViewTransition = transition @@ -77,19 +199,41 @@ private final class EmojiItemComponent: Component { environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) ) - let emojiFrame = CGRect(origin: CGPoint(x: floor((size.width - emojiSize.width) * 0.5), y: floor((size.height - emojiSize.height) * 0.5)), size: emojiSize) + let emojiFrame = CGRect(origin: CGPoint(x: pendingContainerInset + floor((size.width - emojiSize.width) * 0.5), y: pendingContainerInset + floor((size.height - emojiSize.height) * 0.5)), size: emojiSize) if let emojiComponentView = emojiView.view { if emojiComponentView.superview == nil { - self.addSubview(emojiComponentView) + self.containerView.contentView.addSubview(emojiComponentView) } emojiViewTransition.setFrame(view: emojiComponentView, frame: emojiFrame) + + if let pendingContainerView = self.pendingContainerView { + self.pendingContainerView = nil + self.pendingEmojiViews.removeAll() + + let currentPendingContainerOffset = pendingContainerView.contentView.layer.presentation()?.position.y ?? pendingContainerView.contentView.layer.position.y + + pendingContainerView.contentView.layer.removeAnimation(forKey: "offsetCycle") + pendingContainerView.contentView.layer.position.y = currentPendingContainerOffset + + let animateTransition: ComponentTransition = .spring(duration: 0.4) + let targetOffset: CGFloat = CGFloat(borderEmoji - 1) * size.height + animateTransition.setPosition(layer: pendingContainerView.contentView.layer, position: CGPoint(x: 0.0, y: targetOffset), completion: { [weak self, weak pendingContainerView] _ in + pendingContainerView?.removeFromSuperview() + + self?.containerView.isMaskEnabled = false + }) + + animateTransition.animatePosition(view: emojiComponentView, from: CGPoint(x: 0.0, y: currentPendingContainerOffset - targetOffset), to: CGPoint(), additive: true) + } else { + self.containerView.isMaskEnabled = false + } } self.pendingEmojiValues = nil } else { if let emojiView = self.emojiView { self.emojiView = nil - emojiView.view?.removeFromSuperview() + previousEmojiView = emojiView } if self.pendingEmojiValues?.count != numEmoji { @@ -105,11 +249,13 @@ private final class EmojiItemComponent: Component { } if let pendingEmojiValues, pendingEmojiValues.count == numEmoji { - let pendingContainerView: UIView + self.containerView.isMaskEnabled = true + + let pendingContainerView: EmojiContainerView if let current = self.pendingContainerView { pendingContainerView = current } else { - pendingContainerView = UIView() + pendingContainerView = EmojiContainerView(hasMask: false) self.pendingContainerView = pendingContainerView } @@ -131,27 +277,70 @@ private final class EmojiItemComponent: Component { ) if let pendingEmojiComponentView = pendingEmojiView.view { if pendingEmojiComponentView.superview == nil { - pendingContainerView.addSubview(pendingEmojiComponentView) + pendingContainerView.contentView.addSubview(pendingEmojiComponentView) } - pendingEmojiComponentView.frame = CGRect(origin: CGPoint(x: 0.0, y: CGFloat(i) * size.height), size: pendingEmojiViewSize) + pendingEmojiComponentView.frame = CGRect(origin: CGPoint(x: pendingContainerInset, y: pendingContainerInset + CGFloat(i) * size.height), size: pendingEmojiViewSize) } } - pendingContainerView.frame = CGRect(origin: CGPoint(), size: size) + pendingContainerView.frame = CGRect(origin: CGPoint(), size: containerFrame.size) + pendingContainerView.update(size: containerFrame.size, borderWidth: 12.0) if pendingContainerView.superview == nil { - self.addSubview(pendingContainerView) + self.containerView.contentView.addSubview(pendingContainerView) - let animation = CABasicAnimation(keyPath: "sublayerTransform.translation.y") - //animation.duration = 4.2 + let startTime = CACurrentMediaTime() + + var loopAnimationOffset: Double = 0.0 + if let previousEmojiComponentView = previousEmojiView?.view { + previousEmojiView = nil + + pendingContainerView.contentView.addSubview(previousEmojiComponentView) + previousEmojiComponentView.center = previousEmojiComponentView.center.offsetBy(dx: 0.0, dy: CGFloat(numEmoji) * size.height) + + let animation = CABasicAnimation(keyPath: "position.y") + loopAnimationOffset = 0.25 + animation.duration = loopAnimationOffset + animation.fromValue = -CGFloat(numEmoji) * size.height + animation.toValue = 0.0 + animation.timingFunction = CAMediaTimingFunction(name: .easeIn) + animation.autoreverses = false + animation.repeatCount = 1.0 + animation.fillMode = .backwards + animation.isRemovedOnCompletion = true + animation.beginTime = pendingContainerView.contentView.layer.convertTime(startTime, from: nil) + animation.isAdditive = true + + animation.completion = { [weak previousEmojiComponentView] _ in + previousEmojiComponentView?.removeFromSuperview() + } + + pendingContainerView.contentView.layer.add(animation, forKey: "offsetCyclePre") + } + + let animation = CABasicAnimation(keyPath: "position.y") animation.duration = 0.2 animation.fromValue = -CGFloat(numEmoji - borderEmoji) * size.height animation.toValue = CGFloat(borderEmoji - 3) * size.height animation.timingFunction = CAMediaTimingFunction(name: .linear) animation.autoreverses = false animation.repeatCount = .infinity + animation.fillMode = .forwards + + animation.beginTime = pendingContainerView.contentView.layer.convertTime(startTime + loopAnimationOffset, from: nil) - pendingContainerView.layer.add(animation, forKey: "offsetCycle") + pendingContainerView.contentView.layer.add(animation, forKey: "offsetCycle") + } else if pendingContainerView.contentView.layer.animation(forKey: "offsetCycle") == nil { + let animation = CABasicAnimation(keyPath: "position.y") + animation.duration = 0.2 + animation.fromValue = -CGFloat(numEmoji - borderEmoji) * size.height + animation.toValue = CGFloat(borderEmoji - 3) * size.height + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.autoreverses = false + animation.repeatCount = .infinity + animation.fillMode = .forwards + + pendingContainerView.contentView.layer.add(animation, forKey: "offsetCycle") } } else if let pendingContainerView = self.pendingContainerView { self.pendingContainerView = nil @@ -162,9 +351,10 @@ private final class EmojiItemComponent: Component { } self.pendingEmojiViews.removeAll() } - - //self.layer.borderColor = UIColor.red.cgColor - //self.layer.borderWidth = 4.0 + + if let previousEmojiView { + previousEmojiView.view?.removeFromSuperview() + } return size } @@ -227,9 +417,15 @@ final class VideoChatEncryptionKeyComponent: Component { private let expandedButtonText = ComponentView() private var component: VideoChatEncryptionKeyComponent? + private weak var state: EmptyComponentState? private var isUpdating: Bool = false private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer? + + #if DEBUG + private var mockStateTimer: Foundation.Timer? + private var mockCurrentKey: [String]? + #endif override init(frame: CGRect) { self.containerView = UIView() @@ -246,6 +442,10 @@ final class VideoChatEncryptionKeyComponent: Component { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + self.mockStateTimer?.invalidate() + } @objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { guard let component = self.component else { @@ -269,8 +469,32 @@ final class VideoChatEncryptionKeyComponent: Component { defer { self.isUpdating = false } + + #if DEBUG && false + if self.component == nil { + self.mockStateTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + + if self.mockCurrentKey == nil { + self.mockCurrentKey = (0 ..< 4).map { _ in randomCallsEmoji() ?? "👍" } + } else { + self.mockCurrentKey = nil + } + + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4), isLocal: true) + } + }) + } + let emoji = self.mockCurrentKey ?? [] + #else + let emoji = component.emoji + #endif self.component = component + self.state = state let alphaTransition: ComponentTransition if transition.animation.isImmediate { @@ -303,7 +527,7 @@ final class VideoChatEncryptionKeyComponent: Component { return emojiItem.update( transition: transition, component: AnyComponent(EmojiItemComponent( - emoji: i < component.emoji.count ? component.emoji[i] : nil + emoji: i < emoji.count ? emoji[i] : nil )), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0)