KeyboardManager.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. //
  2. // KeyboardManager.swift
  3. // InputBarAccessoryView
  4. //
  5. // Copyright © 2017-2020 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. /// A flag that indicates if the keyboard is about to disappear
  39. private(set) public var isKeyboardDisappearing: Bool = false
  40. private(set) public var keyboardHeight: CGFloat = 0
  41. // MARK: - Properties [Private]
  42. /// The `NSLayoutConstraintSet` that holds the `inputAccessoryView` to the bottom if its superview
  43. private var constraints: NSLayoutConstraintSet?
  44. /// A weak reference to a `UIScrollView` that has been attached for interactive keyboard dismissal
  45. private weak var scrollView: UIScrollView?
  46. /// The `EventCallback` actions for each `KeyboardEvent`. Default value is EMPTY
  47. private var callbacks: [KeyboardEvent: EventCallback] = [:]
  48. /// The pan gesture that handles dragging on the `scrollView`
  49. private var panGesture: UIPanGestureRecognizer?
  50. /// A cached notification used as a starting point when a user dragging the `scrollView` down
  51. /// to interactively dismiss the keyboard
  52. private var cachedNotification: KeyboardNotification?
  53. // MARK: - Initialization
  54. /// Creates a `KeyboardManager` object an binds the view as fake `InputAccessoryView`
  55. ///
  56. /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
  57. public convenience init(inputAccessoryView: UIView) {
  58. self.init()
  59. self.bind(inputAccessoryView: inputAccessoryView)
  60. }
  61. /// Creates a `KeyboardManager` object that observes the state of the keyboard
  62. public override init() {
  63. super.init()
  64. addObservers()
  65. }
  66. required public init?(coder aDecoder: NSCoder) {
  67. fatalError("init(coder:) has not been implemented")
  68. }
  69. // MARK: - De-Initialization
  70. deinit {
  71. NotificationCenter.default.removeObserver(self)
  72. }
  73. // MARK: - Keyboard Observer
  74. /// Add an observer for each keyboard notification
  75. private func addObservers() {
  76. NotificationCenter.default.addObserver(self,
  77. selector: #selector(keyboardWillShow(notification:)),
  78. name: UIResponder.keyboardWillShowNotification,
  79. object: nil)
  80. NotificationCenter.default.addObserver(self,
  81. selector: #selector(keyboardDidShow(notification:)),
  82. name: UIResponder.keyboardDidShowNotification,
  83. object: nil)
  84. NotificationCenter.default.addObserver(self,
  85. selector: #selector(keyboardWillHide(notification:)),
  86. name: UIResponder.keyboardWillHideNotification,
  87. object: nil)
  88. NotificationCenter.default.addObserver(self,
  89. selector: #selector(keyboardDidHide(notification:)),
  90. name: UIResponder.keyboardDidHideNotification,
  91. object: nil)
  92. NotificationCenter.default.addObserver(self,
  93. selector: #selector(keyboardWillChangeFrame(notification:)),
  94. name: UIResponder.keyboardWillChangeFrameNotification,
  95. object: nil)
  96. NotificationCenter.default.addObserver(self,
  97. selector: #selector(keyboardDidChangeFrame(notification:)),
  98. name: UIResponder.keyboardDidChangeFrameNotification,
  99. object: nil)
  100. }
  101. // MARK: - Mutate Callback Dictionary
  102. /// Sets the `EventCallback` for a `KeyboardEvent`
  103. ///
  104. /// - Parameters:
  105. /// - event: KeyboardEvent
  106. /// - callback: EventCallback
  107. /// - Returns: Self
  108. @discardableResult
  109. open func on(event: KeyboardEvent, do callback: EventCallback?) -> Self {
  110. callbacks[event] = callback
  111. return self
  112. }
  113. /// Constrains the `inputAccessoryView` to the bottom of its superview and sets the
  114. /// `.willChangeFrame` and `.willHide` event callbacks such that it mimics an `InputAccessoryView`
  115. /// that is bound to the top of the keyboard
  116. ///
  117. /// - Parameter inputAccessoryView: The view to bind to the top of the keyboard but within its superview
  118. /// - Returns: Self
  119. @discardableResult
  120. open func bind(inputAccessoryView: UIView) -> Self {
  121. guard let superview = inputAccessoryView.superview else {
  122. fatalError("`inputAccessoryView` must have a superview")
  123. }
  124. self.inputAccessoryView = inputAccessoryView
  125. inputAccessoryView.translatesAutoresizingMaskIntoConstraints = false
  126. constraints = NSLayoutConstraintSet(
  127. bottom: inputAccessoryView.bottomAnchor.constraint(equalTo: superview.bottomAnchor),
  128. left: inputAccessoryView.leftAnchor.constraint(equalTo: superview.leftAnchor),
  129. right: inputAccessoryView.rightAnchor.constraint(equalTo: superview.rightAnchor)
  130. ).activate()
  131. callbacks[.willShow] = { [weak self] (notification) in
  132. let keyboardHeight = notification.endFrame.height
  133. guard
  134. self?.isKeyboardHidden == false,
  135. self?.constraints?.bottom?.constant == 0,
  136. notification.isForCurrentApp else { return }
  137. self?.animateAlongside(notification) {
  138. self?.constraints?.bottom?.constant = -keyboardHeight
  139. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  140. }
  141. }
  142. callbacks[.willChangeFrame] = { [weak self] (notification) in
  143. let keyboardHeight = notification.endFrame.height
  144. guard
  145. self?.isKeyboardHidden == false,
  146. notification.isForCurrentApp else { return }
  147. self?.animateAlongside(notification) {
  148. self?.constraints?.bottom?.constant = -keyboardHeight
  149. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  150. }
  151. }
  152. callbacks[.willHide] = { [weak self] (notification) in
  153. guard notification.isForCurrentApp else { return }
  154. self?.animateAlongside(notification) { [weak self] in
  155. self?.constraints?.bottom?.constant = 0
  156. self?.inputAccessoryView?.superview?.layoutIfNeeded()
  157. }
  158. }
  159. return self
  160. }
  161. /// Adds a `UIPanGestureRecognizer` to the `scrollView` to enable interactive dismissal`
  162. ///
  163. /// - Parameter scrollView: UIScrollView
  164. /// - Returns: Self
  165. @discardableResult
  166. open func bind(to scrollView: UIScrollView) -> Self {
  167. self.scrollView = scrollView
  168. self.scrollView?.keyboardDismissMode = .interactive // allows dismissing keyboard interactively
  169. let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureRecognizer))
  170. recognizer.delegate = self
  171. self.panGesture = recognizer
  172. self.scrollView?.addGestureRecognizer(recognizer)
  173. return self
  174. }
  175. // MARK: - Keyboard Notifications
  176. /// An observer method called last in the lifecycle of a keyboard becoming visible
  177. ///
  178. /// - Parameter notification: NSNotification
  179. @objc
  180. open func keyboardDidShow(notification: NSNotification) {
  181. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  182. callbacks[.didShow]?(keyboardNotification)
  183. }
  184. /// An observer method called last in the lifecycle of a keyboard becoming hidden
  185. ///
  186. /// - Parameter notification: NSNotification
  187. @objc
  188. open func keyboardDidHide(notification: NSNotification) {
  189. isKeyboardHidden = true
  190. isKeyboardDisappearing = false
  191. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  192. callbacks[.didHide]?(keyboardNotification)
  193. }
  194. /// An observer method called third in the lifecycle of a keyboard becoming visible/hidden
  195. ///
  196. /// - Parameter notification: NSNotification
  197. @objc
  198. open func keyboardDidChangeFrame(notification: NSNotification) {
  199. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  200. self.keyboardHeight = keyboardNotification.endFrame.height - (self.inputAccessoryView?.intrinsicContentSize.height ?? 0)
  201. callbacks[.didChangeFrame]?(keyboardNotification)
  202. cachedNotification = keyboardNotification
  203. }
  204. /// An observer method called first in the lifecycle of a keyboard becoming visible/hidden
  205. ///
  206. /// - Parameter notification: NSNotification
  207. @objc
  208. open func keyboardWillChangeFrame(notification: NSNotification) {
  209. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  210. self.keyboardHeight = keyboardNotification.endFrame.height - (self.inputAccessoryView?.intrinsicContentSize.height ?? 0)
  211. callbacks[.willChangeFrame]?(keyboardNotification)
  212. cachedNotification = keyboardNotification
  213. }
  214. /// An observer method called second in the lifecycle of a keyboard becoming visible
  215. ///
  216. /// - Parameter notification: NSNotification
  217. @objc
  218. open func keyboardWillShow(notification: NSNotification) {
  219. isKeyboardHidden = false
  220. isKeyboardDisappearing = false
  221. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  222. callbacks[.willShow]?(keyboardNotification)
  223. }
  224. /// An observer method called second in the lifecycle of a keyboard becoming hidden
  225. ///
  226. /// - Parameter notification: NSNotification
  227. @objc
  228. open func keyboardWillHide(notification: NSNotification) {
  229. isKeyboardDisappearing = true
  230. guard let keyboardNotification = KeyboardNotification(from: notification) else { return }
  231. callbacks[.willHide]?(keyboardNotification)
  232. }
  233. // MARK: - Helper Methods
  234. private func animateAlongside(_ notification: KeyboardNotification, animations: @escaping () -> Void) {
  235. UIView.animate(withDuration: notification.timeInterval,
  236. delay: 0,
  237. options: [notification.animationOptions, .allowAnimatedContent, .beginFromCurrentState],
  238. animations: animations, completion: nil)
  239. }
  240. // MARK: - UIGestureRecognizerDelegate
  241. /// Starts with the cached `KeyboardNotification` and calculates a new `endFrame` based
  242. /// on the `UIPanGestureRecognizer` then calls the `.willChangeFrame` `EventCallback` action
  243. ///
  244. /// - Parameter recognizer: UIPanGestureRecognizer
  245. @objc
  246. open func handlePanGestureRecognizer(recognizer: UIPanGestureRecognizer) {
  247. guard
  248. var keyboardNotification = cachedNotification,
  249. case .changed = recognizer.state,
  250. let view = recognizer.view,
  251. let window = UIApplication.shared.windows.first
  252. else { return }
  253. let location = recognizer.location(in: view)
  254. let absoluteLocation = view.convert(location, to: window)
  255. var frame = keyboardNotification.endFrame
  256. frame.origin.y = max(absoluteLocation.y, window.bounds.height - frame.height)
  257. frame.size.height = window.bounds.height - frame.origin.y
  258. keyboardNotification.endFrame = frame
  259. callbacks[.willChangeFrame]?(keyboardNotification)
  260. }
  261. /// Only receive a `UITouch` event when the `scrollView`'s keyboard dismiss mode is interactive
  262. open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
  263. return scrollView?.keyboardDismissMode == .interactive
  264. }
  265. /// Only recognice simultaneous gestures when its the `panGesture`
  266. open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
  267. return gestureRecognizer === panGesture
  268. }
  269. }