-
-
Notifications
You must be signed in to change notification settings - Fork 51
/
Copy pathTypist.swift
336 lines (271 loc) · 13.7 KB
/
Typist.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
//
// Typist.swift
// Small Swift helper to manage UIKit keyboard on iOS devices.
//
// Created by Toto Tvalavadze on 2016/09/26. MIT Licence.
//
import UIKit
/**
Typist is small, drop-in Swift UIKit keyboard manager for iOS apps. It helps you manage keyboard's screen presence and behavior without notification center and Objective-C.
Declare what should happen on what event and `start()` listening to keyboard events. Like so:
```
let keyboard = Typist.shared
func configureKeyboard() {
keyboard
.on(event: .didShow) { (options) in
print("New Keyboard Frame is \(options.endFrame).")
}
.on(event: .didHide) { (options) in
print("It took \(options.animationDuration) seconds to animate keyboard out.")
}
.start()
}
```
Usage of both—`shared` singleton, or your own instance of `Typist`—is considered to be OK depending on what you want to accomplish. However, **do not use singleton** when two or more objects (`UIViewController`s, most likely) using `Typist.shared` are presented on screen simultaneously. This will cause one of the controllers to fail at receiving keyboard events.
You _must_ call `start()` for callbacks to be triggered. Calling `stop()` on instance will stop callbacks from triggering, but callbacks themselves won't be dismissed, thus you can resume event callbacks by calling `start()` again.
To remove all event callbacks, call `clear()`.
*/
public class Typist: NSObject {
/// Returns the shared instance of Typist, creating it if necessary.
static public let shared = Typist()
/// Inert/immutable objects which carries all data that keyboard has at the event of happening.
public struct KeyboardOptions {
/// Identifies whether the keyboard belongs to the current app. With multitasking on iPad, all visible apps are notified when the keyboard appears and disappears. The value of this key is `true` for the app that caused the keyboard to appear and `false` for any other apps.
public let belongsToCurrentApp: Bool
/// Identifies the start frame of the keyboard in screen coordinates. These coordinates do not take into account any rotation factors applied to the window’s contents as a result of interface orientation changes. Thus, you may need to convert the rectangle to window coordinates (using the `convertRect:fromWindow:` method) or to view coordinates (using the `convertRect:fromView:` method) before using it.
public let startFrame: CGRect
/// Identifies the end frame of the keyboard in screen coordinates. These coordinates do not take into account any rotation factors applied to the window’s contents as a result of interface orientation changes. Thus, you may need to convert the rectangle to window coordinates (using the `convertRect:fromWindow:` method) or to view coordinates (using the `convertRect:fromView:` method) before using it.
public let endFrame: CGRect
/// Constant that defines how the keyboard will be animated onto or off the screen.
public let animationCurve: UIView.AnimationCurve
/// Identifies the duration of the animation in seconds.
public let animationDuration: Double
/// Maps the animationCurve to it's respective `UIView.AnimationOptions` value.
public var animationOptions: UIView.AnimationOptions {
switch self.animationCurve {
case UIView.AnimationCurve.easeIn:
return UIView.AnimationOptions.curveEaseIn
case UIView.AnimationCurve.easeInOut:
return UIView.AnimationOptions.curveEaseInOut
case UIView.AnimationCurve.easeOut:
return UIView.AnimationOptions.curveEaseOut
case UIView.AnimationCurve.linear:
return UIView.AnimationOptions.curveLinear
@unknown default:
fatalError()
}
}
}
/// TypistCallback
public typealias TypistCallback = (KeyboardOptions) -> ()
/// Keyboard events that can happen. Translates directly to `UIKeyboard` notifications from UIKit.
public enum KeyboardEvent {
/// Event raised by UIKit's `.UIKeyboardWillShow`.
case willShow
/// Event raised by UIKit's `.UIKeyboardDidShow`.
case didShow
/// Event raised by UIKit's `.UIKeyboardWillShow`.
case willHide
/// Event raised by UIKit's `.UIKeyboardDidHide`.
case didHide
/// Event raised by UIKit's `.UIKeyboardWillChangeFrame`.
case willChangeFrame
/// Event raised by UIKit's `.UIKeyboardDidChangeFrame`.
case didChangeFrame
}
/// Declares Typist behavior. Pass a closure parameter and event to bind those two. Without calling `start()` none of the closures will be executed.
///
/// - parameter event: Event on which callback should be executed.
/// - parameter do: Closure of code which will be executed on keyboard `event`.
/// - returns: `Self` for convenience so many `on` functions can be chained.
@discardableResult
public func on(event: KeyboardEvent, do callback: TypistCallback?) -> Self {
callbacks[event] = callback
return self
}
// TODO: add docs
public func toolbar(scrollView: UIScrollView) -> Self {
self.scrollView = scrollView
return self
}
/// Starts listening to events and calling corresponding events handlers.
public func start() {
let center = NotificationCenter.`default`
for event in callbacks.keys {
center.addObserver(self, selector: event.selector, name: event.notification, object: nil)
}
}
/// Stops listening to keyboard events. Callback closures won't be cleared, thus calling `start()` again will resume calling previously set event handlers.
public func stop() {
let center = NotificationCenter.default
center.removeObserver(self)
}
/// Clears all event handlers. Equivalent of setting `nil` for all events.
public func clear() {
callbacks.removeAll()
}
deinit {
stop()
}
// MARK: - How sausages are made
internal var callbacks: [KeyboardEvent : TypistCallback] = [:]
internal func keyboardOptions(fromNotificationDictionary userInfo: [AnyHashable : Any]?) -> KeyboardOptions {
var currentApp = true
if #available(iOS 9.0, *) {
if let value = (userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? NSNumber)?.boolValue {
currentApp = value
}
}
var endFrame = CGRect()
if let value = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
endFrame = value
}
var startFrame = CGRect()
if let value = (userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
startFrame = value
}
var animationCurve = UIView.AnimationCurve.linear
if let index = (userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.intValue,
let value = UIView.AnimationCurve(rawValue:index) {
animationCurve = value
}
var animationDuration: Double = 0.0
if let value = (userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue {
animationDuration = value
}
return KeyboardOptions(belongsToCurrentApp: currentApp, startFrame: startFrame, endFrame: endFrame, animationCurve: animationCurve, animationDuration: animationDuration)
}
// MARK: - UIKit notification handling
@objc internal func keyboardWillShow(note: Notification) {
callbacks[.willShow]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
}
@objc internal func keyboardDidShow(note: Notification) {
callbacks[.didShow]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
}
@objc internal func keyboardWillHide(note: Notification) {
callbacks[.willHide]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
}
@objc internal func keyboardDidHide(note: Notification) {
callbacks[.didHide]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
}
@objc internal func keyboardWillChangeFrame(note: Notification) {
callbacks[.willChangeFrame]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
_options = keyboardOptions(fromNotificationDictionary: note.userInfo)
}
@objc internal func keyboardDidChangeFrame(note: Notification) {
callbacks[.didChangeFrame]?(keyboardOptions(fromNotificationDictionary: note.userInfo))
_options = keyboardOptions(fromNotificationDictionary: note.userInfo)
}
// MARK: - Input Accessory View Support
fileprivate var scrollView: UIScrollView? {
didSet {
scrollView?.keyboardDismissMode = .interactive // allows dismissing keyboard interactively
scrollView?.addGestureRecognizer(panGesture)
}
}
fileprivate lazy var panGesture: UIPanGestureRecognizer = { [unowned self] in
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureRecognizer))
recognizer.delegate = self
return recognizer
}()
fileprivate var _options: KeyboardOptions?
@IBAction func handlePanGestureRecognizer(recognizer: UIPanGestureRecognizer) {
var useWindowCoordinates = true
var window: UIWindow?
var bounds: CGRect = .zero
// check to see if we can access the UIApplication.sharedApplication property. If not, due to being in an extension context where sharedApplication isn't
// available, grab the screen bounds and use the screen to determine the touch's absolute location.
let sharedApplicationSelector = NSSelectorFromString("sharedApplication")
if let applicationClass = NSClassFromString("UIApplication"), applicationClass.responds(to: sharedApplicationSelector) {
if let application = UIApplication.perform(sharedApplicationSelector).takeUnretainedValue() as? UIApplication, let appWindow = application.windows.first {
window = appWindow
bounds = appWindow.bounds
}
}
else {
useWindowCoordinates = false
bounds = UIScreen.main.bounds
}
guard
let options = _options,
case .changed = recognizer.state,
let view = recognizer.view
else { return }
let location = recognizer.location(in: view)
var absoluteLocation: CGPoint
if useWindowCoordinates {
absoluteLocation = view.convert(location, to: window)
}
else {
absoluteLocation = view.convert(location, to: UIScreen.main.coordinateSpace)
}
var frame = options.endFrame
frame.origin.y = max(absoluteLocation.y, bounds.height - frame.height)
frame.size.height = bounds.height - frame.origin.y
let event = KeyboardOptions(belongsToCurrentApp: options.belongsToCurrentApp, startFrame: options.startFrame, endFrame: frame, animationCurve: options.animationCurve, animationDuration: options.animationDuration)
callbacks[.willChangeFrame]?(event)
}
}
private extension Typist.KeyboardEvent {
var notification: NSNotification.Name {
get {
switch self {
case .willShow:
return UIResponder.keyboardWillShowNotification
case .didShow:
return UIResponder.keyboardDidShowNotification
case .willHide:
return UIResponder.keyboardWillHideNotification
case .didHide:
return UIResponder.keyboardDidHideNotification
case .willChangeFrame:
return UIResponder.keyboardWillChangeFrameNotification
case .didChangeFrame:
return UIResponder.keyboardDidChangeFrameNotification
}
}
}
var selector: Selector {
get {
switch self {
case .willShow:
return #selector(Typist.keyboardWillShow(note:))
case .didShow:
return #selector(Typist.keyboardDidShow(note:))
case .willHide:
return #selector(Typist.keyboardWillHide(note:))
case .didHide:
return #selector(Typist.keyboardDidHide(note:))
case .willChangeFrame:
return #selector(Typist.keyboardWillChangeFrame(note:))
case .didChangeFrame:
return #selector(Typist.keyboardDidChangeFrame(note:))
}
}
}
}
extension Typist: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return scrollView?.keyboardDismissMode == .interactive
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return gestureRecognizer === panGesture
}
}
// MARK: UIView extensions (convenience)
extension UIView.AnimationOptions {
public init(curve: UIView.AnimationCurve) {
switch curve {
case .easeIn:
self = [.curveEaseIn]
case .easeOut:
self = [.curveEaseOut]
case .easeInOut:
self = [.curveEaseInOut]
case .linear:
self = [.curveLinear]
@unknown default:
self = [.curveLinear]
}
}
}