TM-SGNL-iOS/Signal/Calls/UserInterface/ReturnToCallViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

333 lines
14 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
public import UIKit
protocol CallViewControllerWindowReference: AnyObject {
var localVideoViewReference: CallMemberView { get }
var remoteVideoViewReference: CallMemberView { get }
var remoteVideoAddress: SignalServiceAddress { get }
var isJustMe: Bool { get }
var view: UIView! { get }
func returnFromPip(pipWindow: UIWindow)
func willMoveToPip(pipWindow: UIWindow)
}
// MARK: -
public class ReturnToCallViewController: UIViewController {
public static var inherentPipSize: CGSize {
let nineBySixteen = CGSize(width: 90, height: 160)
let fourByThree = CGSize(width: 272, height: 204)
let threeByFour = CGSize(width: 204, height: 272)
if UIDevice.current.isIPad && UIDevice.current.isFullScreen {
if CurrentAppContext().frame.size.width > CurrentAppContext().frame.size.height {
return fourByThree
} else {
return threeByFour
}
} else {
return nineBySixteen
}
}
private weak var callViewController: CallViewControllerWindowReference?
func displayForCallViewController(_ callViewController: CallViewControllerWindowReference) {
guard callViewController !== self.callViewController else { return }
guard let callViewSnapshot = callViewController.view.snapshotView(afterScreenUpdates: false) else {
return owsFailDebug("failed to snapshot call view")
}
self.callViewController = callViewController
callViewController.remoteVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
self.view.addSubview(view)
view.autoPinEdgesToSuperviewEdges()
}
callViewController.localVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
self.view.addSubview(view)
view.layer.cornerRadius = 6
}
updateLocalVideoFrame()
animatePipPresentation(snapshot: callViewSnapshot)
}
public func resignCall() {
callViewController?.localVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
callViewController?.remoteVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
callViewController = nil
}
var isCallInPip: Bool {
return nil != self.callViewController
}
/// Tracks the frame of the keyboard if it is showing and docked (attached to the bottom of the screen).
///
/// `nil` if the keyboard is hidden, undocked, or floating (the latter two only apply to iOS).
/// Used to restrict `pipBoundingRect` to exclude the keyboard.
private var dockedKeyboardFrame: CGRect?
/// The frame of the PiP window before the user brings up the keyboard.
///
/// Captured at the moment the user brings up the keyboard, and used to set the position of the PiP window while
/// the keyboard is up (by clamping it to the new `pipBoundingRect`) as well as resetting the position when the
/// keyboard is dismissed.
///
/// `nil` if the keyboard is hidden, undocked, or floating, as well as when the user manually adjusts the PiP
/// while the keyboard is docked (because then we no longer have anything to reset to).
private var frameBeforeAdjustingForKeyboard: CGRect?
override public func loadView() {
view = UIView()
view.backgroundColor = .black
view.clipsToBounds = true
view.layer.cornerRadius = 8
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan)))
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self,
selector: #selector(keyboardFrameWillChange),
name: UIWindow.keyboardWillChangeFrameNotification,
object: nil)
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.updatePipLayout()
}, completion: nil)
}
public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
updatePipLayout()
}
// MARK: -
private func updateLocalVideoFrame() {
guard let callViewController else { return }
if !callViewController.isJustMe {
let localVideoSize = CGSize.scale(Self.inherentPipSize, factor: 0.3)
callViewController.localVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
view.frame = CGRect(
origin: CGPoint(
x: Self.inherentPipSize.width - 6 - localVideoSize.width,
y: Self.inherentPipSize.height - 6 - localVideoSize.height
),
size: localVideoSize
)
}
} else {
callViewController.localVideoViewReference.applyChangesToCallMemberViewAndVideoView { view in
view.frame = CGRect(origin: .zero, size: Self.inherentPipSize)
}
}
}
private var isAnimating = false
private func animatePipPresentation(snapshot: UIView) {
guard let window = view.window else { return owsFailDebug("missing window") }
isAnimating = true
let previousOrigin = window.frame.origin
window.frame = AppEnvironment.shared.windowManagerRef.rootWindow.bounds
view.addSubview(snapshot)
snapshot.autoPinEdgesToSuperviewEdges()
window.layoutIfNeeded()
UIView.animate(withDuration: 0.2, animations: {
snapshot.alpha = 0
window.frame = CGRect(
origin: previousOrigin,
size: Self.inherentPipSize
).pinnedToVerticalEdge(of: self.pipBoundingRect)
window.layoutIfNeeded()
}) { _ in
snapshot.removeFromSuperview()
self.isAnimating = false
}
}
/// The frame that the PiP window must be contained within, in screen coordinates.
///
/// Essentially "a rect inset from the main window safe areas".
private var pipBoundingRect: CGRect {
let padding: CGFloat = 4
// Don't let the PiP window overlap the chat list tab bar or the message input box in a conversation.
// This is a hardcoded estimate that doesn't adjust with dynamic type because
// - the height of the tab bar and the height of the message input box could change differently, and
// - trying to track either would require exposing that information in a way this controller could get at it;
// - trying to account for all that as well as for the user changing their preferences while the app is running
// would be hard, and
// - this is just a nicety; nothing bad actually happens if the PiP window overlaps those views.
let bottomBarEstimatedHeight: CGFloat = 56
let safeAreaInsets = AppEnvironment.shared.windowManagerRef.rootWindow.safeAreaInsets
var rect = CurrentAppContext().frame
rect = rect.inset(by: safeAreaInsets)
rect.size.height -= bottomBarEstimatedHeight
if let dockedKeyboardFrame = dockedKeyboardFrame, rect.maxY > dockedKeyboardFrame.minY {
rect.size.height -= rect.maxY - dockedKeyboardFrame.minY
}
rect = rect.inset(by: UIEdgeInsets(margin: padding))
return rect
}
/// Animates the PiP window to its new position.
///
/// This gets the position of `frameBeforeAdjustingForKeyboard`, falling back to the current frame, and pins it to
/// one of the vertical edges of `pipBoundingRect` (bringing the resulting frame fully within the bounding rect and
/// adjusting it to the nearest vertical edge).
///
/// If `animationDuration` and `animationCurve` are nil, a default animation is used.
private func updatePipLayout(animationDuration: CGFloat? = nil,
animationCurve: UIView.AnimationCurve? = nil) {
guard !isAnimating else { return }
guard let window = view.window else { return owsFailDebug("missing window") }
let origin = frameBeforeAdjustingForKeyboard?.origin ?? window.frame.origin
let newFrame = CGRect(
origin: origin,
size: Self.inherentPipSize
).pinnedToVerticalEdge(of: pipBoundingRect)
UIView.animate(withDuration: animationDuration ?? 0.25, delay: 0, options: animationCurve.asAnimationOptions) {
self.updateLocalVideoFrame()
window.frame = newFrame
}
}
@objc
private func handlePan(sender: UIPanGestureRecognizer) {
guard let window = view.window else { return owsFailDebug("missing window") }
// Don't try to reset to the old frame when the keyboard is dismissed.
frameBeforeAdjustingForKeyboard = nil
switch sender.state {
case .began, .changed:
let translation = sender.translation(in: window)
sender.setTranslation(.zero, in: window)
window.frame.origin.y += translation.y
window.frame.origin.x += translation.x
case .ended, .cancelled, .failed:
window.animateDecelerationToVerticalEdge(
withDuration: 0.35,
velocity: sender.velocity(in: window),
boundingRect: pipBoundingRect
)
default:
break
}
}
@objc
private func handleTap(sender: UITapGestureRecognizer) {
AppEnvironment.shared.windowManagerRef.returnToCallView()
}
@objc
private func keyboardFrameWillChange(_ notification: Notification) {
guard let window = view.window else { return }
guard
let userInfo = notification.userInfo,
let startFrame = userInfo[UIWindow.keyboardFrameBeginUserInfoKey] as? CGRect,
let endFrame = userInfo[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect
else {
owsFailDebug("bad notification")
return
}
// On an iPhone the keyboard only has two positions: showing and hidden.
// But iPads have many more:
// - hidden
// - showing
// - shortcut bar only (for hardware keyboards)
// - floating (on newer iPads)
// - undocked (on iPads with home buttons)
// - undocked and split (on iPads with home buttons)
// When changing this method, please be sure to check all iPad state transitions (on both old and new iPads),
// as well as dragging an undocked or floating keyboard around (which should do nothing).
// You should also check phones with and without home buttons.
guard dockedKeyboardFrame != endFrame else {
// The older iPad "dock" action transitions to the end frame twice;
// ignore the second one so we don't get messed up.
return
}
let animationDuration = userInfo[UIWindow.keyboardAnimationDurationUserInfoKey] as? CGFloat
let rawAnimationCurve = userInfo[UIWindow.keyboardAnimationDurationUserInfoKey] as? Int
let animationCurve = rawAnimationCurve.flatMap { UIView.AnimationCurve(rawValue: $0) }
let fullFrame = CurrentAppContext().frame
func isDockedAndOnscreen(_ keyboardFrame: CGRect) -> Bool {
if keyboardFrame.minY >= fullFrame.maxY {
// off-screen
return false
}
if keyboardFrame.maxY < fullFrame.maxY {
// on-screen, but floating or undocked (iPad)
return false
}
return true
}
if isDockedAndOnscreen(endFrame) {
guard !(animationDuration == 0 && endFrame.maxY > fullFrame.maxY) else {
// The older iPad "undock" action has a mysterious zero-duration transition
// from an empty rect to a half-offscreen frame, *after* a normal transition
// from a valid docked to a valid undocked frame. We want to ignore that.
// This is very subtle, and could be broken by a later OS update...
// but keep in mind that the failure mode here is that the PiP window ends up somewhere it shouldn't.
// Not the end of the world.
return
}
dockedKeyboardFrame = endFrame
if !isDockedAndOnscreen(startFrame) {
// The keyboard is newly docked-and-on-screen;
// save the current PiP window position so we can reset to it later.
frameBeforeAdjustingForKeyboard = window.frame
}
updatePipLayout(animationDuration: animationDuration, animationCurve: animationCurve)
} else {
dockedKeyboardFrame = nil
if frameBeforeAdjustingForKeyboard != nil {
// This handles both dismissing the keyboard and changing from docked to undocked/floating.
updatePipLayout(animationDuration: animationDuration, animationCurve: animationCurve)
frameBeforeAdjustingForKeyboard = nil
}
}
}
// MARK: Orientation
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return UIDevice.current.isIPad ? .all : .portrait
}
}