-
-
Notifications
You must be signed in to change notification settings - Fork 87
/
Copy pathKeyboardMovementObserver.swift
312 lines (268 loc) · 10.2 KB
/
KeyboardMovementObserver.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
//
// KeyboardMovementObserver.swift
// KeyboardController
//
// Created by Kiryl Ziusko on 2.08.22.
// Copyright © 2022 Facebook. All rights reserved.
//
import Foundation
import UIKit
@objc(KeyboardMovementObserver)
public class KeyboardMovementObserver: NSObject {
// class members
var onEvent: (NSString, NSNumber, NSNumber, NSNumber, NSNumber) -> Void
var onNotify: (String, Any) -> Void
// animation
var onRequestAnimation: () -> Void
var onCancelAnimation: () -> Void
// progress tracker
private var _keyboardView: UIView?
private var keyboardView: UIView? {
let windowsCount = UIApplication.shared.windows.count
if _keyboardView == nil || windowsCount != _windowsCount {
_keyboardView = KeyboardView.find()
_windowsCount = windowsCount
}
return _keyboardView
}
private var _windowsCount: Int = 0
private var prevKeyboardPosition = 0.0
private var displayLink: CADisplayLink?
private var hasKVObserver = false
private var isMounted = false
// state variables
private var keyboardHeight: CGFloat = 0.0
private var duration = 0
private var tag: NSNumber = -1
private var animation: KeyboardAnimation?
private var didShowDeadline: Int64 = 0
@objc public init(
handler: @escaping (NSString, NSNumber, NSNumber, NSNumber, NSNumber) -> Void,
onNotify: @escaping (String, Any) -> Void,
onRequestAnimation: @escaping () -> Void,
onCancelAnimation: @escaping () -> Void
) {
onEvent = handler
self.onNotify = onNotify
self.onRequestAnimation = onRequestAnimation
self.onCancelAnimation = onCancelAnimation
}
@objc public func mount() {
if isMounted {
return
}
isMounted = true
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillDisappear),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillAppear),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidAppear),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidDisappear),
name: UIResponder.keyboardDidHideNotification,
object: nil
)
}
private func setupKVObserver() {
if hasKVObserver {
return
}
if keyboardView != nil {
hasKVObserver = true
keyboardView?.addObserver(self, forKeyPath: "center", options: .new, context: nil)
}
}
private func removeKVObserver() {
if !hasKVObserver {
return
}
hasKVObserver = false
_keyboardView?.removeObserver(self, forKeyPath: "center", context: nil)
}
// swiftlint:disable:next block_based_kvo
@objc override public func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context _: UnsafeMutableRawPointer?
) {
if keyPath == "center", object as? NSObject == _keyboardView {
// if we are currently animating keyboard -> we need to ignore values from KVO
if displayLink != nil {
return
}
// if keyboard height is not equal to its bounds - we can ignore
// values, since they'll be invalid and will cause UI jumps
if keyboardView?.bounds.size.height != keyboardHeight {
return
}
guard let changeValue = change?[.newKey] as? NSValue else {
return
}
let keyboardFrameY = changeValue.cgPointValue.y
let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0
let keyboardPosition = keyboardWindowH - keyboardFrameY
let position = CGFloat.interpolate(
inputRange: [keyboardHeight / 2, -keyboardHeight / 2],
outputRange: [keyboardHeight, 0],
currentValue: keyboardPosition
)
if position == 0 {
// it will be triggered before `keyboardWillDisappear` and
// we don't need to trigger `onInteractive` handler for that
// since it will be handled in `keyboardWillDisappear` function
return
}
prevKeyboardPosition = position
onEvent(
"onKeyboardMoveInteractive",
position as NSNumber,
position / CGFloat(keyboardHeight) as NSNumber,
-1,
tag
)
}
}
@objc public func unmount() {
isMounted = false
// swiftlint:disable:next notification_center_detachment
NotificationCenter.default.removeObserver(self)
}
@objc func keyboardWillAppear(_ notification: Notification) {
let (duration, frame) = notification.keyboardMetaData()
if let keyboardFrame = frame {
tag = UIResponder.current.reactViewTag
let keyboardHeight = keyboardFrame.cgRectValue.size.height
self.keyboardHeight = keyboardHeight
self.duration = duration
didShowDeadline = Date.currentTimeStamp + Int64(duration)
onRequestAnimation()
onEvent("onKeyboardMoveStart", Float(keyboardHeight) as NSNumber, 1, duration as NSNumber, tag)
onNotify("KeyboardController::keyboardWillShow", buildEventParams(keyboardHeight, duration, tag))
setupKeyboardWatcher()
initializeAnimation(fromValue: prevKeyboardPosition, toValue: keyboardHeight)
}
}
@objc func keyboardWillDisappear(_ notification: Notification) {
let (duration, _) = notification.keyboardMetaData()
tag = UIResponder.current.reactViewTag
self.duration = duration
onRequestAnimation()
onEvent("onKeyboardMoveStart", 0, 0, duration as NSNumber, tag)
onNotify("KeyboardController::keyboardWillHide", buildEventParams(0, duration, tag))
setupKeyboardWatcher()
removeKVObserver()
initializeAnimation(fromValue: prevKeyboardPosition, toValue: 0)
}
@objc func keyboardDidAppear(_ notification: Notification) {
let timestamp = Date.currentTimeStamp
let (duration, frame) = notification.keyboardMetaData()
if let keyboardFrame = frame {
let (position, _) = keyboardView.frameTransitionInWindow
let keyboardHeight = keyboardFrame.cgRectValue.size.height
tag = UIResponder.current.reactViewTag
self.keyboardHeight = keyboardHeight
// if the event is caught in between it's highly likely that it could be a "resize" event
// so we just read actual keyboard frame value in this case
let height = timestamp >= didShowDeadline ? keyboardHeight : position
// always limit progress to the maximum possible value
let progress = min(height / self.keyboardHeight, 1.0)
onCancelAnimation()
onEvent("onKeyboardMoveEnd", height as NSNumber, progress as NSNumber, duration as NSNumber, tag)
onNotify("KeyboardController::keyboardDidShow", buildEventParams(height, duration, tag))
removeKeyboardWatcher()
setupKVObserver()
animation = nil
}
}
@objc func keyboardDidDisappear(_ notification: Notification) {
let (duration, _) = notification.keyboardMetaData()
tag = UIResponder.current.reactViewTag
onCancelAnimation()
onEvent("onKeyboardMoveEnd", 0 as NSNumber, 0, duration as NSNumber, tag)
onNotify("KeyboardController::keyboardDidHide", buildEventParams(0, duration, tag))
removeKeyboardWatcher()
animation = nil
}
@objc func setupKeyboardWatcher() {
// sometimes `will` events can be called multiple times.
// To avoid double re-creation of listener we are adding this condition
// (if active link is present, then no need to re-setup a listener)
if displayLink != nil {
return
}
displayLink = CADisplayLink(target: self, selector: #selector(updateKeyboardFrame))
displayLink?.preferredFramesPerSecond = 120 // will fallback to 60 fps for devices without Pro Motion display
displayLink?.add(to: .main, forMode: .common)
}
@objc func removeKeyboardWatcher() {
displayLink?.invalidate()
displayLink = nil
}
func initializeAnimation(fromValue: Double, toValue: Double) {
for key in ["position", "opacity"] {
if let keyboardAnimation = keyboardView?.layer.presentation()?.animation(forKey: key) {
if let springAnimation = keyboardAnimation as? CASpringAnimation {
animation = SpringAnimation(animation: springAnimation, fromValue: fromValue, toValue: toValue)
} else if let basicAnimation = keyboardAnimation as? CABasicAnimation {
animation = TimingAnimation(animation: basicAnimation, fromValue: fromValue, toValue: toValue)
}
return
}
}
}
@objc func updateKeyboardFrame(link: CADisplayLink) {
if keyboardView == nil {
return
}
let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.frameTransitionInWindow
var keyboardPosition = visibleKeyboardHeight
if keyboardPosition == prevKeyboardPosition || keyboardFrameY == 0 {
return
}
if animation == nil {
initializeAnimation(fromValue: prevKeyboardPosition, toValue: keyboardHeight)
}
prevKeyboardPosition = keyboardPosition
if let animation = animation {
let baseDuration = animation.timingAt(value: keyboardPosition)
#if targetEnvironment(simulator)
// on iOS simulator we can not use static interval
// (from my observation from frame to frame we may have different delays)
// so for now we use approximation - we add a difference as
// beginTime - keyboardEventTime (but only in 0..0.016 range)
// and it gives satisfactory results (better than static delays)
let duration = baseDuration + animation.diff
#else
// 2 frames because we read previous frame, but need to calculate the next frame
let duration = baseDuration + link.duration * 2
#endif
let position = CGFloat(animation.valueAt(time: duration))
// handles a case when final frame has final destination (i. e. 0 or 291)
// but CASpringAnimation can never get to this final destination
let race: (CGFloat, CGFloat) -> CGFloat = animation.isIncreasing ? max : min
keyboardPosition = race(position, keyboardPosition)
}
onEvent(
"onKeyboardMove",
keyboardPosition as NSNumber,
keyboardPosition / CGFloat(keyboardHeight) as NSNumber,
duration as NSNumber,
tag
)
}
}