240 lines
8.9 KiB
Swift
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()
|
|
}
|
|
}
|