KeyboardManager.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. //
  2. // KeyboardManager.swift
  3. // InputBarAccessoryView
  4. //
  5. // Copyright © 2017-2019 Nathan Tannar.
  6. //
  7. // Permission is hereby granted, free of charge, to any person obtaining a copy
  8. // of this software and associated documentation files (the "Software"), to deal
  9. // in the Software without restriction, including without limitation the rights
  10. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. // copies of the Software, and to permit persons to whom the Software is
  12. // furnished to do so, subject to the following conditions:
  13. //
  14. // The above copyright notice and this permission notice shall be included in all
  15. // copies or substantial portions of the Software.
  16. //
  17. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23. // SOFTWARE.
  24. //
  25. // Created by Nathan Tannar on 8/18/17.
  26. //
  27. import UIKit
  28. /// An object that observes keyboard notifications such that event callbacks can be set for each notification
  29. open class KeyboardManager: NSObject, UIGestureRecognizerDelegate {
  30. /// A callback that passes a `KeyboardNotification` as an input
  31. public typealias EventCallback = (KeyboardNotification)->Void
  32. // MARK: - Properties [Public]
  33. /// A weak reference to a view bounded to the top of the keyboard to act as an `InputAccessoryView`
  34. /// but kept within the bounds of the `UIViewController`s view
  35. open weak var inputAccessoryView: UIView?
  36. /// A flag that indicates if a portion of the keyboard is visible on the screen
  37. private(set) public var isKeyboardHidden: Bool = true
  38. // MARK: - Properties [Private]
  39. /// The `NSLayoutConstraintSet` that holds the `inputAccessoryView` to the bottom if its superview
  40. private var constraints: NSLayoutConstraintSet?
  41. /// A weak reference to a `UIScrollView` that has been attached for interactive keyboard dismissal
  42. private weak var scrollView: UIScrollView?
  43. /// The `EventCallback` actions for each `KeyboardEvent`. Default value is EMPTY
  44. private var callbacks: [KeyboardEvent: EventCallback] = [:]
  45. /// The pan gesture that handles dragging on the `scrollView`
  46. private var panGesture: UIPanGestureRecognizer?
  47. /// A cached notification used as a starting point when a user dragging the `scrollView` down
  48. /// to interactively dismiss the keyboard
  49. private var cachedNotification: KeyboardNotification?
  50. // MARK: - Initialization
  51. /// Creates a `KeyboardManager` object an binds the view as fake `InputAccessoryView`
  52. ///
  53. /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
  54. public convenience init(inputAccessoryView: UIView) {
  55. self.init()
  56. self.bind(inputAccessoryView: inputAccessoryView)
  57. }
  58. /// Creates a `KeyboardManager` object that observes the state of the keyboard
  59. public override init() {
  60. super.init()
  61. addObservers()
  62. }
  63. required public init?(coder aDecoder: NSCoder) {
  64. fatalError("init(coder:) has not been implemented")
  65. }
  66. // MARK: - De-Initialization
  67. deinit {
  68. NotificationCenter.default.removeObserver(self)
  69. }
  70. // MARK: - Keyboard Observer
  71. /// Add an observer for each keyboard notification
  72. private func addObservers() {
  73. NotificationCenter.default.addObserver(self,
  74. selector: #selector(keyboardWillShow(notification:)),
  75. name: UIResponder.keyboardWillShowNotification,
  76. object: nil)
  77. NotificationCenter.default.addObserver(self,
  78. selector: #selector(keyboardDidShow(notification:)),
  79. name: UIResponder.keyboardDidShowNotification,
  80. object: nil)
  81. NotificationCenter.default.addObserver(self,
  82. selector: #selector(keyboardWillHide(notification:)),
  83. name: UIResponder.keyboardWillHideNotification,
  84. object: nil)
  85. NotificationCenter.default.addObserver(self,
  86. selector: #selector(keyboardDidHide(notification:)),
  87. name: UIResponder.keyboardDidHideNotification,
  88. object: nil)
  89. NotificationCenter.default.addObserver(self,
  90. selector: #selector(keyboardWillChangeFrame(notification:)),
  91. name: UIResponder.keyboardWillChangeFrameNotification,
  92. object: nil)
  93. NotificationCenter.default.addObserver(self,
  94. selector: #selector(keyboardDidChangeFrame(notification:)),
  95. name: UIResponder.keyboardDidChangeFrameNotification,
  96. object: nil)
  97. }
  98. // MARK: - Mutate Callback Dictionary
  99. /// Sets the `EventCallback` for a `KeyboardEvent`
  100. ///
  101. /// - Parameters:
  102. /// - event: KeyboardEvent
  103. /// - callback: EventCallback
  104. /// - Returns: Self
  105. @discardableResult
  106. open func on(event: KeyboardEvent, do callback: EventCallback?) -> Self {
  107. callbacks[event] = callback
  108. return self
  109. }
  110. /// Constrains the `inputAccessoryView` to the bottom of its superview and sets the
  111. /// `.willChangeFrame` and `.willHide` event callbacks such that it mimics an `InputAccessoryView`
  112. /// that is bound to the top of the keyboard
  113. ///
  114. /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
  115. /// - Returns: Self
  116. @discardableResult
  117. open func bind(inputAccessoryView: UIView) -> Self {
  118. guard let superview = inputAccessoryView.superview else {
  119. fatalError("`inputAccessoryView` must have a superview")
  120. }
  121. self.inputAccessoryView = inputAccessoryView
  122. inputAccessoryView.translatesAutoresizingMaskIntoConstraints = false
  123. constraints = NSLayoutConstraintSet(
  124. bottom: inputAccessoryView.bottomAnchor.constraint(equalTo: superview.bottomAnchor),
  125. left: inputAccessoryView.leftAnchor.constraint(equalTo: superview.leftAnchor),
  126. right: inputAccessoryView.rightAnchor.constraint(equalTo: superview.rightAnchor)
  127. ).activate()
  128. callbacks[.willShow] = { [weak self] (notification) in
  129. let keyboardHeight = notification.endFrame.height
  130. guard
  131. self?.isKeyboardHidden == false,
  132. self?.constraints?.bottom?.constant == 0,
  133. notification.isForCurrentApp else { return }
  134. self?.animateAlongside(notification) {
  135. self?.constraints?.bottom?.constant = -keyboardHeight
  136. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  137. }
  138. }
  139. callbacks[.willChangeFrame] = { [weak self] (notification) in
  140. let keyboardHeight = notification.endFrame.height
  141. guard
  142. self?.isKeyboardHidden == false,
  143. notification.isForCurrentApp else { return }
  144. self?.animateAlongside(notification) {
  145. self?.constraints?.bottom?.constant = -keyboardHeight
  146. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  147. }
  148. }
  149. callbacks[.willHide] = { [weak self] (notification) in
  150. guard notification.isForCurrentApp else { return }
  151. self?.animateAlongside(notification) { [weak self] in
  152. self?.constraints?.bottom?.constant = 0
  153. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  154. }
  155. }
  156. return self
  157. }
  158. /// Adds a `UIPanGestureRecognizer` to the `scrollView` to enable interactive dismissal`
  159. ///
  160. /// - Parameter scrollView: UIScrollView
  161. /// - Returns: Self
  162. @discardableResult
  163. open func bind(to scrollView: UIScrollView) -> Self {
  164. self.scrollView = scrollView
  165. self.scrollView?.keyboardDismissMode = .interactive // allows dismissing keyboard interactively
  166. let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureRecognizer))
  167. recognizer.delegate = self
  168. self.panGesture = recognizer
  169. self.scrollView?.addGestureRecognizer(recognizer)
  170. return self
  171. }
  172. // MARK: - Keyboard Notifications
  173. /// An observer method called last in the lifecycle of a keyboard becoming visible
  174. ///
  175. /// - Parameter notification: NSNotification
  176. @objc
  177. open func keyboardDidShow(notification: NSNotification) {
  178. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  179. callbacks[.didShow]?(keyboardNotification)
  180. }
  181. /// An observer method called last in the lifecycle of a keyboard becoming hidden
  182. ///
  183. /// - Parameter notification: NSNotification
  184. @objc
  185. open func keyboardDidHide(notification: NSNotification) {
  186. isKeyboardHidden = true
  187. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  188. callbacks[.didHide]?(keyboardNotification)
  189. }
  190. /// An observer method called third in the lifecycle of a keyboard becoming visible/hidden
  191. ///
  192. /// - Parameter notification: NSNotification
  193. @objc
  194. open func keyboardDidChangeFrame(notification: NSNotification) {
  195. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  196. callbacks[.didChangeFrame]?(keyboardNotification)
  197. cachedNotification = keyboardNotification
  198. }
  199. /// An observer method called first in the lifecycle of a keyboard becoming visible/hidden
  200. ///
  201. /// - Parameter notification: NSNotification
  202. @objc
  203. open func keyboardWillChangeFrame(notification: NSNotification) {
  204. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  205. callbacks[.willChangeFrame]?(keyboardNotification)
  206. cachedNotification = keyboardNotification
  207. }
  208. /// An observer method called second in the lifecycle of a keyboard becoming visible
  209. ///
  210. /// - Parameter notification: NSNotification
  211. @objc
  212. open func keyboardWillShow(notification: NSNotification) {
  213. isKeyboardHidden = false
  214. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  215. callbacks[.willShow]?(keyboardNotification)
  216. }
  217. /// An observer method called second in the lifecycle of a keyboard becoming hidden
  218. ///
  219. /// - Parameter notification: NSNotification
  220. @objc
  221. open func keyboardWillHide(notification: NSNotification) {
  222. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  223. callbacks[.willHide]?(keyboardNotification)
  224. }
  225. // MARK: - Helper Methods
  226. private func animateAlongside(_ notification: KeyboardNotification, animations: @escaping ()->Void) {
  227. UIView.animate(withDuration: notification.timeInterval, delay: 0, options: [notification.animationOptions, .allowAnimatedContent, .beginFromCurrentState], animations: animations, completion: nil)
  228. }
  229. // MARK: - UIGestureRecognizerDelegate
  230. /// Starts with the cached `KeyboardNotification` and calculates a new `endFrame` based
  231. /// on the `UIPanGestureRecognizer` then calls the `.willChangeFrame` `EventCallback` action
  232. ///
  233. /// - Parameter recognizer: UIPanGestureRecognizer
  234. @objc
  235. open func handlePanGestureRecognizer(recognizer: UIPanGestureRecognizer) {
  236. guard
  237. var keyboardNotification = cachedNotification,
  238. case .changed = recognizer.state,
  239. let view = recognizer.view,
  240. let window = UIApplication.shared.windows.first
  241. else { return }
  242. let location = recognizer.location(in: view)
  243. let absoluteLocation = view.convert(location, to: window)
  244. var frame = keyboardNotification.endFrame
  245. frame.origin.y = max(absoluteLocation.y, window.bounds.height - frame.height)
  246. frame.size.height = window.bounds.height - frame.origin.y
  247. keyboardNotification.endFrame = frame
  248. callbacks[.willChangeFrame]?(keyboardNotification)
  249. }
  250. /// Only receive a `UITouch` event when the `scrollView`'s keyboard dismiss mode is interactive
  251. open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
  252. return scrollView?.keyboardDismissMode == .interactive
  253. }
  254. /// Only recognice simultaneous gestures when its the `panGesture`
  255. open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
  256. return gestureRecognizer === panGesture
  257. }
  258. }