[WIP] Call UI

This commit is contained in:
Isaac 2023-12-01 13:44:57 +04:00
parent 63575a4a30
commit 743551096f
6 changed files with 352 additions and 33 deletions

View File

@ -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: ["😂", "😘", "😍", "😊"]
))
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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))

View File

@ -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

View File

@ -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(