TM-SGNL-iOS/SignalUI/Views/InputAccessoryViewPlaceholder.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

240 lines
8.9 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public protocol InputAccessoryViewPlaceholderDelegate: AnyObject {
func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve)
func inputAccessoryPlaceholderKeyboardDidPresent()
func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve)
func inputAccessoryPlaceholderKeyboardDidDismiss()
func inputAccessoryPlaceholderKeyboardIsDismissingInteractively()
}
// MARK: -
/// Input accessory views always render at the full width of the window.
/// This wrapper allows resizing the accessory view to fit within its
/// presenting view.
public class InputAccessoryViewPlaceholder: UIView {
public weak var delegate: InputAccessoryViewPlaceholderDelegate?
/// The amount of the application frame that is overlapped
/// by the keyboard.
public var keyboardOverlap: CGFloat {
// Subtract our own height as this view is not actually
// visible, but is represented in the keyboard.
let ownHeight = superview != nil ? desiredHeight : 0
return max(0, visibleKeyboardHeight - ownHeight)
}
public weak var referenceView: UIView?
private var visibleKeyboardHeight: CGFloat {
guard var keyboardFrame = transitioningKeyboardFrame ?? superview?.frame else { return 0 }
guard keyboardFrame.height > 0 else { return 0 }
let referenceFrame: CGRect
if let referenceView = referenceView {
keyboardFrame = referenceView.convert(keyboardFrame, from: nil)
referenceFrame = referenceView.frame
} else {
referenceFrame = CurrentAppContext().frame
}
// Measure how much of the keyboard is currently offscreen.
let offScreenHeight = keyboardFrame.maxY - referenceFrame.maxY
// The onscreen region represents the overlap.
return max(0, keyboardFrame.height - offScreenHeight)
}
private var transitioningKeyboardFrame: CGRect? {
switch keyboardState {
case .dismissing:
return .zero
case .presenting(let frame):
return frame
default:
return nil
}
}
/// The height that the accessory view should take up. This is
/// automatically subtracted from the keyboard overlap and is
/// intended to represent the extent to which you want the
/// accessory view to overlap the presenting view, primarily
/// for the purpose of defining the start point for interactive
/// dismissals.
public var desiredHeight: CGFloat {
get {
return heightConstraint.constant
}
set {
guard newValue != desiredHeight else { return }
heightConstraint.constant = newValue
UIView.performWithoutAnimation {
heightConstraintView.layoutIfNeeded()
self.layoutIfNeeded()
superview?.layoutIfNeeded()
}
}
}
private let heightConstraintView = UIView()
private lazy var heightConstraint: NSLayoutConstraint = {
addSubview(heightConstraintView)
heightConstraintView.autoPinHeightToSuperview()
return heightConstraintView.autoSetDimension(.height, toSize: 0)
}()
private enum KeyboardState: CustomStringConvertible {
case dismissed
case dismissing
case presented
case presenting(frame: CGRect)
public var description: String {
switch self {
case .dismissed:
return "dismissed"
case .dismissing:
return "dismissing"
case .presented:
return "presented"
case .presenting:
return "presenting"
}
}
}
private var keyboardState: KeyboardState = .dismissed
public init() {
super.init(frame: .zero)
// Disable user interaction, the accessory view
// should never actually contain any UI.
isUserInteractionEnabled = false
autoresizingMask = .flexibleHeight
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillDismiss),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidDismiss),
name: UIResponder.keyboardDidHideNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillPresent),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidPresent),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
superviewCenterObserver?.invalidate()
}
public override var intrinsicContentSize: CGSize {
return .zero
}
private var superviewCenterObserver: NSKeyValueObservation?
public override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
// By observing the .center property of our superview, we can
// follow along as the keyboard moves up and down.
superviewCenterObserver?.invalidate()
superviewCenterObserver = newSuperview?.observe(\.center, options: [.initial, .new]) { [weak self] (_, _) in
guard let self = self else { return }
// Do nothing unless the keyboard is currently presented.
// We're only checking for interactive dismissal, which
// can only happen while presented.
guard case .presented = self.keyboardState else { return }
guard self.superview != nil else { return }
// While the visible keyboard height is greater than zero,
// and the keyboard is presented, we can safely assume
// an interactive dismissal is in progress.
if self.visibleKeyboardHeight > 0 {
self.delegate?.inputAccessoryPlaceholderKeyboardIsDismissingInteractively()
}
}
}
// MARK: - Presentation / Dismissal wrangling.
@objc
private func keyboardWillPresent(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let beginFrame = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect,
let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve) else {
return owsFailDebug("keyboard notification missing expected userInfo properties")
}
// We only want to do an animated presentation if either a) the height changed or b) the view is
// starting from off the bottom of the screen (a full presentation). This provides the best experience
// when canceling an interactive dismissal or changing orientations.
guard beginFrame.height != endFrame.height || beginFrame.minY == UIScreen.main.bounds.height else { return }
keyboardState = .presenting(frame: endFrame)
delegate?.inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: animationDuration, animationCurve: animationCurve)
}
@objc
private func keyboardDidPresent(_ notification: Notification) {
keyboardState = .presented
delegate?.inputAccessoryPlaceholderKeyboardDidPresent()
}
@objc
private func keyboardWillDismiss(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve) else {
return owsFailDebug("keyboard notification missing expected userInfo properties")
}
keyboardState = .dismissing
delegate?.inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: animationDuration, animationCurve: animationCurve)
}
@objc
private func keyboardDidDismiss(_ notification: Notification) {
keyboardState = .dismissed
delegate?.inputAccessoryPlaceholderKeyboardDidDismiss()
}
}