123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- //
- // KeyboardManager.swift
- // InputBarAccessoryView
- //
- // Copyright © 2017-2019 Nathan Tannar.
- //
- // Permission is hereby granted, free of charge, to any person obtaining a copy
- // of this software and associated documentation files (the "Software"), to deal
- // in the Software without restriction, including without limitation the rights
- // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- // copies of the Software, and to permit persons to whom the Software is
- // furnished to do so, subject to the following conditions:
- //
- // The above copyright notice and this permission notice shall be included in all
- // copies or substantial portions of the Software.
- //
- // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- // SOFTWARE.
- //
- // Created by Nathan Tannar on 8/18/17.
- //
- import UIKit
- /// An object that observes keyboard notifications such that event callbacks can be set for each notification
- open class KeyboardManager: NSObject, UIGestureRecognizerDelegate {
-
- /// A callback that passes a `KeyboardNotification` as an input
- public typealias EventCallback = (KeyboardNotification)->Void
-
- // MARK: - Properties [Public]
-
- /// A weak reference to a view bounded to the top of the keyboard to act as an `InputAccessoryView`
- /// but kept within the bounds of the `UIViewController`s view
- open weak var inputAccessoryView: UIView?
-
- /// A flag that indicates if a portion of the keyboard is visible on the screen
- private(set) public var isKeyboardHidden: Bool = true
-
- // MARK: - Properties [Private]
-
- /// The `NSLayoutConstraintSet` that holds the `inputAccessoryView` to the bottom if its superview
- private var constraints: NSLayoutConstraintSet?
-
- /// A weak reference to a `UIScrollView` that has been attached for interactive keyboard dismissal
- private weak var scrollView: UIScrollView?
-
- /// The `EventCallback` actions for each `KeyboardEvent`. Default value is EMPTY
- private var callbacks: [KeyboardEvent: EventCallback] = [:]
-
- /// The pan gesture that handles dragging on the `scrollView`
- private var panGesture: UIPanGestureRecognizer?
- /// A cached notification used as a starting point when a user dragging the `scrollView` down
- /// to interactively dismiss the keyboard
- private var cachedNotification: KeyboardNotification?
-
- // MARK: - Initialization
-
- /// Creates a `KeyboardManager` object an binds the view as fake `InputAccessoryView`
- ///
- /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
- public convenience init(inputAccessoryView: UIView) {
- self.init()
- self.bind(inputAccessoryView: inputAccessoryView)
- }
-
- /// Creates a `KeyboardManager` object that observes the state of the keyboard
- public override init() {
- super.init()
- addObservers()
- }
-
- required public init?(coder aDecoder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- // MARK: - De-Initialization
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
-
- // MARK: - Keyboard Observer
-
- /// Add an observer for each keyboard notification
- private func addObservers() {
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardWillShow(notification:)),
- name: UIResponder.keyboardWillShowNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardDidShow(notification:)),
- name: UIResponder.keyboardDidShowNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardWillHide(notification:)),
- name: UIResponder.keyboardWillHideNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardDidHide(notification:)),
- name: UIResponder.keyboardDidHideNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardWillChangeFrame(notification:)),
- name: UIResponder.keyboardWillChangeFrameNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(keyboardDidChangeFrame(notification:)),
- name: UIResponder.keyboardDidChangeFrameNotification,
- object: nil)
- }
-
- // MARK: - Mutate Callback Dictionary
-
- /// Sets the `EventCallback` for a `KeyboardEvent`
- ///
- /// - Parameters:
- /// - event: KeyboardEvent
- /// - callback: EventCallback
- /// - Returns: Self
- @discardableResult
- open func on(event: KeyboardEvent, do callback: EventCallback?) -> Self {
- callbacks[event] = callback
- return self
- }
-
- /// Constrains the `inputAccessoryView` to the bottom of its superview and sets the
- /// `.willChangeFrame` and `.willHide` event callbacks such that it mimics an `InputAccessoryView`
- /// that is bound to the top of the keyboard
- ///
- /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
- /// - Returns: Self
- @discardableResult
- open func bind(inputAccessoryView: UIView) -> Self {
-
- guard let superview = inputAccessoryView.superview else {
- fatalError("`inputAccessoryView` must have a superview")
- }
- self.inputAccessoryView = inputAccessoryView
- inputAccessoryView.translatesAutoresizingMaskIntoConstraints = false
- constraints = NSLayoutConstraintSet(
- bottom: inputAccessoryView.bottomAnchor.constraint(equalTo: superview.bottomAnchor),
- left: inputAccessoryView.leftAnchor.constraint(equalTo: superview.leftAnchor),
- right: inputAccessoryView.rightAnchor.constraint(equalTo: superview.rightAnchor)
- ).activate()
-
- callbacks[.willShow] = { [weak self] (notification) in
- let keyboardHeight = notification.endFrame.height
- guard
- self?.isKeyboardHidden == false,
- self?.constraints?.bottom?.constant == 0,
- notification.isForCurrentApp else { return }
- self?.animateAlongside(notification) {
- self?.constraints?.bottom?.constant = -keyboardHeight
- self?.inputAccessoryView?.superview?.layoutIfNeeded()
- }
- }
- callbacks[.willChangeFrame] = { [weak self] (notification) in
- let keyboardHeight = notification.endFrame.height
- guard
- self?.isKeyboardHidden == false,
- notification.isForCurrentApp else { return }
- self?.animateAlongside(notification) {
- self?.constraints?.bottom?.constant = -keyboardHeight
- self?.inputAccessoryView?.superview?.layoutIfNeeded()
- }
- }
- callbacks[.willHide] = { [weak self] (notification) in
- guard notification.isForCurrentApp else { return }
- self?.animateAlongside(notification) { [weak self] in
- self?.constraints?.bottom?.constant = 0
- self?.inputAccessoryView?.superview?.layoutIfNeeded()
- }
- }
- return self
- }
-
- /// Adds a `UIPanGestureRecognizer` to the `scrollView` to enable interactive dismissal`
- ///
- /// - Parameter scrollView: UIScrollView
- /// - Returns: Self
- @discardableResult
- open func bind(to scrollView: UIScrollView) -> Self {
- self.scrollView = scrollView
- self.scrollView?.keyboardDismissMode = .interactive // allows dismissing keyboard interactively
- let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureRecognizer))
- recognizer.delegate = self
- self.panGesture = recognizer
- self.scrollView?.addGestureRecognizer(recognizer)
- return self
- }
-
- // MARK: - Keyboard Notifications
-
- /// An observer method called last in the lifecycle of a keyboard becoming visible
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardDidShow(notification: NSNotification) {
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.didShow]?(keyboardNotification)
- }
-
- /// An observer method called last in the lifecycle of a keyboard becoming hidden
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardDidHide(notification: NSNotification) {
- isKeyboardHidden = true
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.didHide]?(keyboardNotification)
- }
-
- /// An observer method called third in the lifecycle of a keyboard becoming visible/hidden
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardDidChangeFrame(notification: NSNotification) {
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.didChangeFrame]?(keyboardNotification)
- cachedNotification = keyboardNotification
- }
-
- /// An observer method called first in the lifecycle of a keyboard becoming visible/hidden
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardWillChangeFrame(notification: NSNotification) {
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.willChangeFrame]?(keyboardNotification)
- cachedNotification = keyboardNotification
- }
-
- /// An observer method called second in the lifecycle of a keyboard becoming visible
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardWillShow(notification: NSNotification) {
- isKeyboardHidden = false
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.willShow]?(keyboardNotification)
- }
-
- /// An observer method called second in the lifecycle of a keyboard becoming hidden
- ///
- /// - Parameter notification: NSNotification
- @objc
- open func keyboardWillHide(notification: NSNotification) {
- guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
- callbacks[.willHide]?(keyboardNotification)
- }
-
- // MARK: - Helper Methods
-
- private func animateAlongside(_ notification: KeyboardNotification, animations: @escaping ()->Void) {
- UIView.animate(withDuration: notification.timeInterval, delay: 0, options: [notification.animationOptions, .allowAnimatedContent, .beginFromCurrentState], animations: animations, completion: nil)
- }
-
- // MARK: - UIGestureRecognizerDelegate
-
- /// Starts with the cached `KeyboardNotification` and calculates a new `endFrame` based
- /// on the `UIPanGestureRecognizer` then calls the `.willChangeFrame` `EventCallback` action
- ///
- /// - Parameter recognizer: UIPanGestureRecognizer
- @objc
- open func handlePanGestureRecognizer(recognizer: UIPanGestureRecognizer) {
- guard
- var keyboardNotification = cachedNotification,
- case .changed = recognizer.state,
- let view = recognizer.view,
- let window = UIApplication.shared.windows.first
- else { return }
-
- let location = recognizer.location(in: view)
- let absoluteLocation = view.convert(location, to: window)
- var frame = keyboardNotification.endFrame
- frame.origin.y = max(absoluteLocation.y, window.bounds.height - frame.height)
- frame.size.height = window.bounds.height - frame.origin.y
- keyboardNotification.endFrame = frame
- callbacks[.willChangeFrame]?(keyboardNotification)
- }
-
- /// Only receive a `UITouch` event when the `scrollView`'s keyboard dismiss mode is interactive
- open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
- return scrollView?.keyboardDismissMode == .interactive
- }
-
- /// Only recognice simultaneous gestures when its the `panGesture`
- open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
- return gestureRecognizer === panGesture
- }
-
- }
|