diff --git a/Tests/CallUITest/Sources/ViewController.swift b/Tests/CallUITest/Sources/ViewController.swift index 8715889c47..816c6766f4 100644 --- a/Tests/CallUITest/Sources/ViewController.swift +++ b/Tests/CallUITest/Sources/ViewController.swift @@ -48,7 +48,7 @@ public final class ViewController: UIViewController { self.callState.lifecycleState = .active(PrivateCallScreen.State.ActiveState( startTime: Date().timeIntervalSince1970, signalInfo: PrivateCallScreen.State.SignalInfo(quality: 1.0), - emojiKey: ["A", "B", "C", "D"] + emojiKey: ["😂", "😘", "😍", "😊"] )) case var .active(activeState): activeState.signalInfo.quality = activeState.signalInfo.quality == 1.0 ? 0.1 : 1.0 @@ -57,7 +57,7 @@ public final class ViewController: UIViewController { self.callState.lifecycleState = .active(PrivateCallScreen.State.ActiveState( startTime: Date().timeIntervalSince1970, signalInfo: PrivateCallScreen.State.SignalInfo(quality: 1.0), - emojiKey: ["A", "B", "C", "D"] + emojiKey: ["😂", "😘", "😍", "😊"] )) } diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/EmojiExpandedInfoView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/EmojiExpandedInfoView.swift new file mode 100644 index 0000000000..1cf1654dd3 --- /dev/null +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/EmojiExpandedInfoView.swift @@ -0,0 +1,146 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +final class EmojiExpandedInfoView: OverlayMaskContainerView { + private struct Params: Equatable { + var constrainedWidth: CGFloat + var sideInset: CGFloat + + init(constrainedWidth: CGFloat, sideInset: CGFloat) { + self.constrainedWidth = constrainedWidth + self.sideInset = sideInset + } + } + + private struct Layout: Equatable { + var params: Params + var size: CGSize + + init(params: Params, size: CGSize) { + self.params = params + self.size = size + } + } + + private let title: String + private let text: String + + private let backgroundView: UIImageView + private let titleView: TextView + private let textView: TextView + + private let actionButton: HighlightTrackingButton + private let actionTitleView: TextView + + private var currentLayout: Layout? + + var closeAction: (() -> Void)? + + init(title: String, text: String) { + self.title = title + self.text = text + + self.backgroundView = UIImageView() + let cornerRadius: CGFloat = 18.0 + let buttonHeight: CGFloat = 56.0 + self.backgroundView.image = generateImage(CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius + 10.0 + buttonHeight), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath) + context.setFillColor(UIColor.white.cgColor) + context.fillPath() + + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fill(CGRect(origin: CGPoint(x: 0.0, y: size.height - buttonHeight), size: CGSize(width: size.width, height: UIScreenPixel))) + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5) + + self.titleView = TextView() + self.textView = TextView() + + self.actionButton = HighlightTrackingButton() + self.actionTitleView = TextView() + self.actionTitleView.isUserInteractionEnabled = false + + super.init(frame: CGRect()) + + self.maskContents.addSubview(self.backgroundView) + + self.addSubview(self.titleView) + self.addSubview(self.textView) + + self.addSubview(self.actionButton) + self.actionButton.addSubview(self.actionTitleView) + + self.actionButton.internalHighligthedChanged = { [weak self] highlighted in + if let self, self.bounds.width > 0.0 { + let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width + let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width + + if highlighted { + self.actionButton.layer.removeAnimation(forKey: "sublayerTransform") + let transition = Transition(animation: .curve(duration: 0.15, curve: .easeInOut)) + transition.setScale(layer: self.actionButton.layer, scale: topScale) + } else { + let t = self.actionButton.layer.presentation()?.transform ?? layer.transform + let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + + let transition = Transition(animation: .none) + transition.setScale(layer: self.actionButton.layer, scale: 1.0) + + self.actionButton.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in + guard let self, completed else { + return + } + + self.actionButton.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + } + } + } + self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func actionButtonPressed() { + self.closeAction?() + } + + func update(constrainedWidth: CGFloat, sideInset: CGFloat, transition: Transition) -> CGSize { + let params = Params(constrainedWidth: constrainedWidth, sideInset: sideInset) + if let currentLayout = self.currentLayout, currentLayout.params == params { + return currentLayout.size + } + let size = self.update(params: params, transition: transition) + self.currentLayout = Layout(params: params, size: size) + return size + } + + private func update(params: Params, transition: Transition) -> CGSize { + let size = CGSize(width: 304.0, height: 227.0) + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size)) + + let titleSize = self.titleView.update(string: self.title, fontSize: 16.0, fontWeight: 0.3, alignment: .center, color: .white, constrainedWidth: params.constrainedWidth - params.sideInset * 2.0 - 16.0 * 2.0, transition: transition) + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: 78.0), size: titleSize) + transition.setFrame(view: self.titleView, frame: titleFrame) + + let textSize = self.textView.update(string: self.text, fontSize: 16.0, fontWeight: 0.0, alignment: .center, color: .white, constrainedWidth: params.constrainedWidth - params.sideInset * 2.0 - 16.0 * 2.0, transition: transition) + let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: titleFrame.maxY + 10.0), size: textSize) + transition.setFrame(view: self.textView, frame: textFrame) + + let buttonHeight: CGFloat = 56.0 + let buttonFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - buttonHeight), size: CGSize(width: size.width, height: buttonHeight)) + transition.setFrame(view: self.actionButton, frame: buttonFrame) + + let actionTitleSize = self.actionTitleView.update(string: "OK", fontSize: 19.0, fontWeight: 0.3, color: .white, constrainedWidth: size.width, transition: transition) + let actionTitleFrame = CGRect(origin: CGPoint(x: floor((buttonFrame.width - actionTitleSize.width) * 0.5), y: floor((buttonFrame.height - actionTitleSize.height) * 0.5)), size: actionTitleSize) + transition.setFrame(view: self.actionTitleView, frame: actionTitleFrame) + + return size + } +} diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/KeyEmojiView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/KeyEmojiView.swift index 0576e3b30e..643f376c4d 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/KeyEmojiView.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/KeyEmojiView.swift @@ -1,47 +1,90 @@ import Foundation import UIKit import Display +import ComponentFlow -final class KeyEmojiView: UIView { +final class KeyEmojiView: HighlightTrackingButton { + private struct Params: Equatable { + var isExpanded: Bool + + init(isExpanded: Bool) { + self.isExpanded = isExpanded + } + } + + private struct Layout: Equatable { + var params: Params + var size: CGSize + + init(params: Params, size: CGSize) { + self.params = params + self.size = size + } + } + + private let emoji: [String] private let emojiViews: [TextView] - let size: CGSize + var pressAction: (() -> Void)? + + private var currentLayout: Layout? init(emoji: [String]) { - self.emojiViews = emoji.map { emoji in + self.emoji = emoji + self.emojiViews = emoji.map { _ in TextView() } - let itemSpacing: CGFloat = 3.0 - - var height: CGFloat = 0.0 - var nextX = 0.0 - for i in 0 ..< self.emojiViews.count { - if nextX != 0.0 { - nextX += itemSpacing - } - let emojiView = self.emojiViews[i] - let itemSize = emojiView.update(string: emoji[i], fontSize: 16.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: .immediate) - if height == 0.0 { - height = itemSize.height - } - emojiView.frame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: itemSize) - nextX += itemSize.width - } - - self.size = CGSize(width: nextX, height: height) - super.init(frame: CGRect()) for emojiView in self.emojiViews { + emojiView.contentMode = .scaleToFill + emojiView.isUserInteractionEnabled = false self.addSubview(emojiView) } + + self.internalHighligthedChanged = { [weak self] highlighted in + if let self, self.bounds.width > 0.0 { + let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width + let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width + + if highlighted { + self.layer.removeAnimation(forKey: "opacity") + self.layer.removeAnimation(forKey: "transform") + let transition = Transition(animation: .curve(duration: 0.15, curve: .easeInOut)) + transition.setScale(layer: self.layer, scale: topScale) + } else { + let t = self.layer.presentation()?.transform ?? layer.transform + let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + + let transition = Transition(animation: .none) + transition.setScale(layer: self.layer, scale: 1.0) + + self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in + guard let self, completed else { + return + } + + self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + } + } + } + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + @objc private func pressed() { + self.pressAction?() + } + func animateIn() { for i in 0 ..< self.emojiViews.count { let emojiView = self.emojiViews[i] @@ -49,4 +92,37 @@ final class KeyEmojiView: UIView { emojiView.layer.animatePosition(from: CGPoint(x: -CGFloat(self.emojiViews.count - 1 - i) * 30.0, y: 0.0), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } } + + func update(isExpanded: Bool, transition: Transition) -> CGSize { + let params = Params(isExpanded: isExpanded) + if let currentLayout = self.currentLayout, currentLayout.params == params { + return currentLayout.size + } + + let size = self.update(params: params, transition: transition) + self.currentLayout = Layout(params: params, size: size) + return size + } + + private func update(params: Params, transition: Transition) -> CGSize { + let itemSpacing: CGFloat = 3.0 + + var height: CGFloat = 0.0 + var nextX = 0.0 + for i in 0 ..< self.emojiViews.count { + if nextX != 0.0 { + nextX += itemSpacing + } + let emojiView = self.emojiViews[i] + let itemSize = emojiView.update(string: emoji[i], fontSize: params.isExpanded ? 40.0 : 16.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: transition) + if height == 0.0 { + height = itemSize.height + } + let itemFrame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: itemSize) + transition.setFrame(view: emojiView, frame: itemFrame) + nextX += itemSize.width + } + + return CGSize(width: nextX, height: height) + } } diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/TitleView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/TitleView.swift index 0b5e4aad96..e10079d404 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/TitleView.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/TitleView.swift @@ -8,6 +8,7 @@ final class TextView: UIView { var fontSize: CGFloat var fontWeight: CGFloat var monospacedDigits: Bool + var alignment: NSTextAlignment var constrainedWidth: CGFloat } @@ -43,8 +44,8 @@ final class TextView: UIView { return super.action(for: layer, forKey: event) } - func update(string: String, fontSize: CGFloat, fontWeight: CGFloat, monospacedDigits: Bool = false, color: UIColor, constrainedWidth: CGFloat, transition: Transition) -> CGSize { - let params = Params(string: string, fontSize: fontSize, fontWeight: fontWeight, monospacedDigits: monospacedDigits, constrainedWidth: constrainedWidth) + func update(string: String, fontSize: CGFloat, fontWeight: CGFloat, monospacedDigits: Bool = false, alignment: NSTextAlignment = .natural, color: UIColor, constrainedWidth: CGFloat, transition: Transition) -> CGSize { + let params = Params(string: string, fontSize: fontSize, fontWeight: fontWeight, monospacedDigits: monospacedDigits, alignment: alignment, constrainedWidth: constrainedWidth) if let layoutState = self.layoutState, layoutState.params == params { return layoutState.size } @@ -56,9 +57,13 @@ final class TextView: UIView { font = UIFont.systemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight)) } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + paragraphStyle.lineSpacing = 0.6 let attributedString = NSAttributedString(string: string, attributes: [ .font: font, .foregroundColor: color, + .paragraphStyle: paragraphStyle ]) let stringBounds = attributedString.boundingRect(with: CGSize(width: constrainedWidth, height: 200.0), options: .usesLineFragmentOrigin, context: nil) let stringSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height)) diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/MirroringLayer.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/MirroringLayer.swift index 8506d05445..6acf9b7346 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/MirroringLayer.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/MirroringLayer.swift @@ -39,6 +39,28 @@ final class MirroringLayer: SimpleLayer { } } + override var anchorPoint: CGPoint { + get { + return super.anchorPoint + } set(value) { + if let targetLayer = self.targetLayer { + targetLayer.anchorPoint = value + } + super.anchorPoint = value + } + } + + override var anchorPointZ: CGFloat { + get { + return super.anchorPointZ + } set(value) { + if let targetLayer = self.targetLayer { + targetLayer.anchorPointZ = value + } + super.anchorPointZ = value + } + } + override var opacity: Float { get { return super.opacity diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift index 89e7217688..46adac0738 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift @@ -127,6 +127,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView { private var weakSignalView: WeakSignalView? private var emojiView: KeyEmojiView? + private var emojiExpandedInfoView: EmojiExpandedInfoView? private let videoContainerBackgroundView: RoundedCornersView private let overlayContentsVideoContainerBackgroundView: RoundedCornersView @@ -139,6 +140,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView { private var activeLocalVideoSource: VideoSource? private var waitingForFirstLocalVideoFrameDisposable: Disposable? + private var isEmojiKeyExpanded: Bool = false private var areControlsHidden: Bool = false private var swapLocalAndRemoteVideo: Bool = false @@ -458,6 +460,53 @@ public final class PrivateCallScreen: OverlayMaskContainerView { } let contentBottomInset = self.buttonGroupView.update(size: params.size, insets: params.insets, controlsHidden: currentAreControlsHidden, buttons: buttons, transition: transition) + if self.isEmojiKeyExpanded { + let emojiExpandedInfoView: EmojiExpandedInfoView + var emojiExpandedInfoTransition = transition + var animateIn = false + if let current = self.emojiExpandedInfoView { + emojiExpandedInfoView = current + } else { + emojiExpandedInfoTransition = emojiExpandedInfoTransition.withAnimation(.none) + animateIn = true + + emojiExpandedInfoView = EmojiExpandedInfoView(title: "This call is end-to-end encrypted", text: "If the emoji on Emma's screen are the same, this call is 100% secure.") + self.emojiExpandedInfoView = emojiExpandedInfoView + emojiExpandedInfoView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0) + if let emojiView = self.emojiView { + self.insertSubview(emojiExpandedInfoView, belowSubview: emojiView) + } else { + self.addSubview(emojiExpandedInfoView) + } + + emojiExpandedInfoView.closeAction = { [weak self] in + guard let self else { + return + } + self.isEmojiKeyExpanded = false + self.update(transition: .spring(duration: 0.4)) + } + } + + let emojiExpandedInfoSize = emojiExpandedInfoView.update(constrainedWidth: params.size.width, sideInset: params.insets.left + 44.0, transition: emojiExpandedInfoTransition) + let emojiExpandedInfoFrame = CGRect(origin: CGPoint(x: floor((params.size.width - emojiExpandedInfoSize.width) * 0.5), y: params.insets.top + 73.0), size: emojiExpandedInfoSize) + emojiExpandedInfoTransition.setPosition(view: emojiExpandedInfoView, position: CGPoint(x: emojiExpandedInfoFrame.maxX, y: emojiExpandedInfoFrame.minY)) + emojiExpandedInfoTransition.setBounds(view: emojiExpandedInfoView, bounds: CGRect(origin: CGPoint(), size: emojiExpandedInfoFrame.size)) + + if animateIn { + transition.animateAlpha(view: emojiExpandedInfoView, from: 0.0, to: 1.0) + transition.animateScale(view: emojiExpandedInfoView, from: 0.001, to: 1.0) + } + } else { + if let emojiExpandedInfoView = self.emojiExpandedInfoView { + self.emojiExpandedInfoView = nil + transition.setAlpha(view: emojiExpandedInfoView, alpha: 0.0, completion: { [weak emojiExpandedInfoView] _ in + emojiExpandedInfoView?.removeFromSuperview() + }) + transition.setScale(view: emojiExpandedInfoView, scale: 0.001) + } + } + if case let .active(activeState) = params.state.lifecycleState { let emojiView: KeyEmojiView var emojiTransition = transition @@ -469,6 +518,15 @@ public final class PrivateCallScreen: OverlayMaskContainerView { emojiAlphaTransition = genericAlphaTransition.withAnimation(.none) emojiView = KeyEmojiView(emoji: activeState.emojiKey) self.emojiView = emojiView + emojiView.pressAction = { [weak self] in + guard let self else { + return + } + if !self.isEmojiKeyExpanded { + self.isEmojiKeyExpanded = true + self.update(transition: .spring(duration: 0.4)) + } + } } if emojiView.superview == nil { self.addSubview(emojiView) @@ -476,14 +534,25 @@ public final class PrivateCallScreen: OverlayMaskContainerView { emojiView.animateIn() } } - let emojiY: CGFloat - if currentAreControlsHidden { - emojiY = -8.0 - emojiView.size.height + emojiView.isUserInteractionEnabled = !self.isEmojiKeyExpanded + + let emojiViewSize = emojiView.update(isExpanded: self.isEmojiKeyExpanded, transition: emojiTransition) + + if self.isEmojiKeyExpanded { + let emojiViewFrame = CGRect(origin: CGPoint(x: floor((params.size.width - emojiViewSize.width) * 0.5), y: params.insets.top + 93.0), size: emojiViewSize) + emojiTransition.setFrame(view: emojiView, frame: emojiViewFrame) } else { - emojiY = params.insets.top + 12.0 + let emojiY: CGFloat + if currentAreControlsHidden { + emojiY = -8.0 - emojiViewSize.height + } else { + emojiY = params.insets.top + 12.0 + } + emojiTransition.setFrame(view: emojiView, frame: CGRect(origin: CGPoint(x: params.size.width - params.insets.right - 12.0 - emojiViewSize.width, y: emojiY), size: emojiViewSize)) + emojiAlphaTransition.setAlpha(view: emojiView, alpha: currentAreControlsHidden ? 0.0 : 1.0) } - emojiTransition.setFrame(view: emojiView, frame: CGRect(origin: CGPoint(x: params.size.width - params.insets.right - 12.0 - emojiView.size.width, y: emojiY), size: emojiView.size)) - emojiAlphaTransition.setAlpha(view: emojiView, alpha: currentAreControlsHidden ? 0.0 : 1.0) + + emojiAlphaTransition.setAlpha(view: emojiView, alpha: 1.0) } else { if let emojiView = self.emojiView { self.emojiView = nil @@ -666,6 +735,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView { transition.setPosition(layer: self.avatarLayer, position: avatarFrame.center) transition.setBounds(layer: self.avatarLayer, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) self.avatarLayer.update(size: collapsedAvatarFrame.size, isExpanded: havePrimaryVideo, cornerRadius: avatarCornerRadius, transition: transition) + transition.setAlpha(layer: self.avatarLayer, alpha: (self.isEmojiKeyExpanded && !havePrimaryVideo) ? 0.0 : 1.0) transition.setPosition(view: self.videoContainerBackgroundView, position: avatarFrame.center) transition.setBounds(view: self.videoContainerBackgroundView, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) @@ -693,7 +763,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView { transition.setAlpha(layer: self.blobLayer, alpha: 0.0) default: titleString = params.state.name - transition.setAlpha(layer: self.blobLayer, alpha: 1.0) + transition.setAlpha(layer: self.blobLayer, alpha: (self.isEmojiKeyExpanded && !havePrimaryVideo) ? 0.0 : 1.0) } let titleSize = self.titleView.update(