react-native-keyboard-controller 1.15.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt +6 -0
  2. package/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt +8 -0
  3. package/android/src/main/java/com/reactnativekeyboardcontroller/extensions/EditText.kt +77 -51
  4. package/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt +3 -3
  5. package/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt +2 -2
  6. package/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt +7 -0
  7. package/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +9 -1
  8. package/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt +8 -0
  9. package/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt +9 -0
  10. package/ios/animations/KeyboardAnimation.swift +4 -4
  11. package/ios/animations/SpringAnimation.swift +28 -5
  12. package/ios/animations/TimingAnimation.swift +77 -32
  13. package/ios/delegates/KCTextInputCompositeDelegate.swift +73 -1
  14. package/ios/extensions/Notification.swift +4 -0
  15. package/ios/extensions/UIResponder.swift +10 -0
  16. package/ios/interactive/InvisibleInputAccessoryView.swift +59 -0
  17. package/ios/interactive/KeyboardAreaExtender.swift +74 -0
  18. package/ios/interactive/KeyboardOffsetProvider.swift +39 -0
  19. package/ios/observers/FocusedInputObserver.swift +81 -25
  20. package/ios/observers/KeyboardEventsIgnorer.swift +39 -0
  21. package/ios/observers/KeyboardMovementObserver.swift +24 -10
  22. package/ios/protocols/TextInput.swift +2 -0
  23. package/ios/swizzling/UIResponderSwizzle.swift +60 -0
  24. package/ios/traversal/ViewHierarchyNavigator.swift +1 -1
  25. package/ios/views/KeyboardGestureAreaManager.h +31 -0
  26. package/ios/views/KeyboardGestureAreaManager.mm +174 -0
  27. package/lib/commonjs/animated.js +4 -1
  28. package/lib/commonjs/animated.js.map +1 -1
  29. package/lib/commonjs/bindings.native.js +1 -1
  30. package/lib/commonjs/bindings.native.js.map +1 -1
  31. package/lib/commonjs/specs/KeyboardControllerViewNativeComponent.js.map +1 -1
  32. package/lib/commonjs/specs/KeyboardGestureAreaNativeComponent.js +1 -3
  33. package/lib/commonjs/specs/KeyboardGestureAreaNativeComponent.js.map +1 -1
  34. package/lib/commonjs/types.js.map +1 -1
  35. package/lib/module/animated.js +4 -1
  36. package/lib/module/animated.js.map +1 -1
  37. package/lib/module/bindings.native.js +1 -1
  38. package/lib/module/bindings.native.js.map +1 -1
  39. package/lib/module/specs/KeyboardControllerViewNativeComponent.js.map +1 -1
  40. package/lib/module/specs/KeyboardGestureAreaNativeComponent.js +1 -3
  41. package/lib/module/specs/KeyboardGestureAreaNativeComponent.js.map +1 -1
  42. package/lib/module/types.js.map +1 -1
  43. package/lib/typescript/animated.d.ts +9 -1
  44. package/lib/typescript/specs/KeyboardControllerViewNativeComponent.d.ts +1 -0
  45. package/lib/typescript/specs/KeyboardGestureAreaNativeComponent.d.ts +1 -0
  46. package/lib/typescript/types.d.ts +6 -1
  47. package/package.json +1 -1
  48. package/src/animated.tsx +15 -1
  49. package/src/bindings.native.ts +1 -1
  50. package/src/specs/KeyboardControllerViewNativeComponent.ts +1 -0
  51. package/src/specs/KeyboardGestureAreaNativeComponent.ts +4 -3
  52. package/src/types.ts +6 -1
  53. package/android/src/main/java/com/reactnativekeyboardcontroller/ui/FrameScheduler.kt +0 -43
@@ -36,6 +36,12 @@ class KeyboardControllerViewManager(
36
36
  value: Boolean,
37
37
  ) = manager.setNavigationBarTranslucent(view as EdgeToEdgeReactViewGroup, value)
38
38
 
39
+ @ReactProp(name = "preserveEdgeToEdge")
40
+ override fun setPreserveEdgeToEdge(
41
+ view: ReactViewGroup,
42
+ value: Boolean,
43
+ ) = manager.setPreserveEdgeToEdge(view as EdgeToEdgeReactViewGroup, value)
44
+
39
45
  @ReactProp(name = "enabled")
40
46
  override fun setEnabled(
41
47
  view: ReactViewGroup,
@@ -56,4 +56,12 @@ class KeyboardGestureAreaViewManager(
56
56
  ) {
57
57
  manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value)
58
58
  }
59
+
60
+ @ReactProp(name = "textInputNativeID")
61
+ override fun setTextInputNativeID(
62
+ view: ReactViewGroup,
63
+ value: String?,
64
+ ) {
65
+ // no-op
66
+ }
59
67
  }
@@ -4,12 +4,13 @@ import android.os.Build
4
4
  import android.text.Editable
5
5
  import android.text.InputType
6
6
  import android.text.TextWatcher
7
+ import android.view.Gravity
7
8
  import android.view.View
9
+ import android.view.ViewTreeObserver.OnPreDrawListener
8
10
  import android.widget.EditText
9
11
  import com.facebook.react.views.scroll.ReactScrollView
10
12
  import com.facebook.react.views.textinput.ReactEditText
11
13
  import com.reactnativekeyboardcontroller.log.Logger
12
- import com.reactnativekeyboardcontroller.ui.FrameScheduler
13
14
  import java.lang.reflect.Field
14
15
  import kotlin.math.max
15
16
  import kotlin.math.min
@@ -78,6 +79,13 @@ fun EditText.addOnTextChangedListener(action: (String) -> Unit): TextWatcher {
78
79
  Logger.w(javaClass.simpleName, "Can not attach listener because casting failed: ${e.message}")
79
80
  } catch (e: NoSuchFieldException) {
80
81
  Logger.w(javaClass.simpleName, "Can not attach listener because field `mListeners` not found: ${e.message}")
82
+ } catch (e: IllegalArgumentException) {
83
+ Logger.w(
84
+ javaClass.simpleName,
85
+ "Can not attach listener to be the first in the list: ${e.message}. Attaching to the end...",
86
+ )
87
+ // it's plain EditText - it doesn't have the same problem as ReactEditText
88
+ this.addTextChangedListener(listener)
81
89
  }
82
90
 
83
91
  return listener
@@ -146,78 +154,96 @@ val EditText?.keyboardType: String
146
154
  }
147
155
 
148
156
  class KeyboardControllerSelectionWatcher(
149
- private val editText: ReactEditText,
157
+ private val editText: EditText,
150
158
  private val action: (start: Int, end: Int, startX: Double, startY: Double, endX: Double, endY: Double) -> Unit,
151
159
  ) {
152
160
  private var lastSelectionStart: Int = -1
153
161
  private var lastSelectionEnd: Int = -1
162
+ private var lastEditTextHeight: Int = -1
154
163
 
155
- private val frameScheduler =
156
- FrameScheduler {
157
- val start = editText.selectionStart
158
- val end = editText.selectionEnd
159
-
160
- if (lastSelectionStart != start || lastSelectionEnd != end) {
161
- lastSelectionStart = start
162
- lastSelectionEnd = end
164
+ private val preDrawListener: OnPreDrawListener =
165
+ object : OnPreDrawListener {
166
+ override fun onPreDraw(): Boolean {
167
+ val start = editText.selectionStart
168
+ val end = editText.selectionEnd
169
+ val editTextHeight = editText.height
163
170
 
164
171
  val view = editText
165
172
  val layout = view.layout
166
173
 
167
174
  if (layout === null) {
168
- return@FrameScheduler
175
+ return true
176
+ }
177
+
178
+ if (lastSelectionStart != start || lastSelectionEnd != end || lastEditTextHeight != editTextHeight) {
179
+ lastSelectionStart = start
180
+ lastSelectionEnd = end
181
+ lastEditTextHeight = editTextHeight
182
+
183
+ val cursorPositionStartX: Float
184
+ val cursorPositionStartY: Float
185
+ val cursorPositionEndX: Float
186
+ val cursorPositionEndY: Float
187
+
188
+ val realStart = min(start, end)
189
+ val realEnd = max(start, end)
190
+
191
+ val lineStart = layout.getLineForOffset(realStart)
192
+ val baselineStart = layout.getLineTop(lineStart)
193
+
194
+ val textHeight = layout.height
195
+ val cursorWidth =
196
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
197
+ view.textCursorDrawable?.intrinsicWidth ?: 0
198
+ } else {
199
+ 0
200
+ }
201
+ val gravity = editText.gravity and Gravity.VERTICAL_GRAVITY_MASK
202
+ val paddingVertical = editText.paddingTop + editText.paddingBottom
203
+ val lineHeightHalfHearted = editText.lineHeight / 2
204
+ val verticalOffset =
205
+ when (gravity) {
206
+ Gravity.CENTER_VERTICAL ->
207
+ (editTextHeight - paddingVertical - textHeight) / 2 + editText.paddingTop + lineHeightHalfHearted
208
+ Gravity.BOTTOM -> editTextHeight - textHeight - editText.paddingBottom + lineHeightHalfHearted
209
+ // Default to Gravity.TOP or other cases
210
+ else -> editText.paddingTop + lineHeightHalfHearted
211
+ }
212
+
213
+ cursorPositionStartX = layout.getPrimaryHorizontal(realStart)
214
+ cursorPositionStartY = (baselineStart + verticalOffset).toFloat()
215
+
216
+ val lineEnd = layout.getLineForOffset(realEnd)
217
+ val right = layout.getPrimaryHorizontal(realEnd)
218
+ val bottom = layout.getLineBottom(lineEnd)
219
+
220
+ cursorPositionEndX = right + cursorWidth
221
+ cursorPositionEndY = (bottom + verticalOffset).toFloat()
222
+
223
+ action(
224
+ start,
225
+ end,
226
+ cursorPositionStartX.dp,
227
+ cursorPositionStartY.dp,
228
+ cursorPositionEndX.dp,
229
+ cursorPositionEndY.dp,
230
+ )
169
231
  }
170
232
 
171
- val cursorPositionStartX: Float
172
- val cursorPositionStartY: Float
173
- val cursorPositionEndX: Float
174
- val cursorPositionEndY: Float
175
-
176
- val realStart = min(start, end)
177
- val realEnd = max(start, end)
178
-
179
- val lineStart = layout.getLineForOffset(realStart)
180
- val baselineStart = layout.getLineBaseline(lineStart)
181
- val ascentStart = layout.getLineAscent(lineStart)
182
-
183
- cursorPositionStartX = layout.getPrimaryHorizontal(realStart)
184
- cursorPositionStartY = (baselineStart + ascentStart).toFloat()
185
-
186
- val lineEnd = layout.getLineForOffset(realEnd)
187
-
188
- val right = layout.getPrimaryHorizontal(realEnd)
189
- val bottom = layout.getLineBottom(lineEnd) + layout.getLineAscent(lineEnd)
190
- val cursorWidth =
191
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
192
- view.textCursorDrawable?.intrinsicWidth ?: 0
193
- } else {
194
- 0
195
- }
196
-
197
- cursorPositionEndX = right + cursorWidth
198
- cursorPositionEndY = bottom.toFloat()
199
-
200
- action(
201
- start,
202
- end,
203
- cursorPositionStartX.dp,
204
- cursorPositionStartY.dp,
205
- cursorPositionEndX.dp,
206
- cursorPositionEndY.dp,
207
- )
233
+ return true
208
234
  }
209
235
  }
210
236
 
211
237
  fun setup() {
212
- frameScheduler.start()
238
+ editText.viewTreeObserver.addOnPreDrawListener(preDrawListener)
213
239
  }
214
240
 
215
241
  fun destroy() {
216
- frameScheduler.stop()
242
+ editText.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
217
243
  }
218
244
  }
219
245
 
220
- fun ReactEditText.addOnSelectionChangedListener(
246
+ fun EditText.addOnSelectionChangedListener(
221
247
  action: (start: Int, end: Int, startX: Double, startY: Double, endX: Double, endY: Double) -> Unit,
222
248
  ): () -> Unit {
223
249
  val listener = KeyboardControllerSelectionWatcher(this, action)
@@ -4,10 +4,10 @@ import android.text.TextWatcher
4
4
  import android.view.View
5
5
  import android.view.View.OnLayoutChangeListener
6
6
  import android.view.ViewTreeObserver.OnGlobalFocusChangeListener
7
+ import android.widget.EditText
7
8
  import com.facebook.react.bridge.Arguments
8
9
  import com.facebook.react.uimanager.ThemedReactContext
9
10
  import com.facebook.react.uimanager.UIManagerHelper
10
- import com.facebook.react.views.textinput.ReactEditText
11
11
  import com.facebook.react.views.view.ReactViewGroup
12
12
  import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent
13
13
  import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEventData
@@ -46,7 +46,7 @@ class FocusedInputObserver(
46
46
  private val surfaceId = UIManagerHelper.getSurfaceId(view)
47
47
 
48
48
  // state variables
49
- private var lastFocusedInput: ReactEditText? = null
49
+ private var lastFocusedInput: EditText? = null
50
50
  private var lastEventDispatched: FocusedInputLayoutChangedEventData = noFocusedInputEvent
51
51
  private var textWatcher: TextWatcher? = null
52
52
  private var selectionSubscription: (() -> Unit)? = null
@@ -105,7 +105,7 @@ class FocusedInputObserver(
105
105
  selectionSubscription?.invoke()
106
106
  lastFocusedInput = null
107
107
  }
108
- if (newFocus is ReactEditText) {
108
+ if (newFocus is EditText) {
109
109
  lastFocusedInput = newFocus
110
110
  newFocus.addOnLayoutChangeListener(layoutListener)
111
111
  this.syncUpLayout()
@@ -2,6 +2,7 @@ package com.reactnativekeyboardcontroller.listeners
2
2
 
3
3
  import android.view.View
4
4
  import android.view.ViewTreeObserver.OnGlobalFocusChangeListener
5
+ import android.widget.EditText
5
6
  import androidx.core.graphics.Insets
6
7
  import androidx.core.view.OnApplyWindowInsetsListener
7
8
  import androidx.core.view.ViewCompat
@@ -11,7 +12,6 @@ import com.facebook.react.bridge.Arguments
11
12
  import com.facebook.react.bridge.WritableMap
12
13
  import com.facebook.react.uimanager.ThemedReactContext
13
14
  import com.facebook.react.uimanager.UIManagerHelper
14
- import com.facebook.react.views.textinput.ReactEditText
15
15
  import com.facebook.react.views.view.ReactViewGroup
16
16
  import com.reactnativekeyboardcontroller.constants.Keyboard
17
17
  import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent
@@ -67,7 +67,7 @@ class KeyboardAnimationCallback(
67
67
  // listeners
68
68
  private val focusListener =
69
69
  OnGlobalFocusChangeListener { oldFocus, newFocus ->
70
- if (newFocus is ReactEditText) {
70
+ if (newFocus is EditText) {
71
71
  viewTagFocused = newFocus.id
72
72
 
73
73
  // keyboard is visible and focus has been changed
@@ -37,6 +37,13 @@ class KeyboardControllerViewManagerImpl(
37
37
  view.setNavigationBarTranslucent(isNavigationBarTranslucent)
38
38
  }
39
39
 
40
+ fun setPreserveEdgeToEdge(
41
+ view: EdgeToEdgeReactViewGroup,
42
+ isPreservingEdgeToEdge: Boolean,
43
+ ) {
44
+ view.setPreserveEdgeToEdge(isPreservingEdgeToEdge)
45
+ }
46
+
40
47
  fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
41
48
  val map: MutableMap<String, Any> =
42
49
  MapBuilder.of(
@@ -31,6 +31,7 @@ class EdgeToEdgeReactViewGroup(
31
31
  // props
32
32
  private var isStatusBarTranslucent = false
33
33
  private var isNavigationBarTranslucent = false
34
+ private var isPreservingEdgeToEdge = false
34
35
  private var active = false
35
36
 
36
37
  // internal class members
@@ -188,7 +189,10 @@ class EdgeToEdgeReactViewGroup(
188
189
  }
189
190
 
190
191
  private fun disable() {
191
- this.goToEdgeToEdge(false)
192
+ if (!isPreservingEdgeToEdge) {
193
+ this.goToEdgeToEdge(false)
194
+ }
195
+
192
196
  this.setupWindowInsets()
193
197
  this.removeKeyboardCallbacks()
194
198
  modalAttachedWatcher.disable()
@@ -208,6 +212,10 @@ class EdgeToEdgeReactViewGroup(
208
212
  this.isNavigationBarTranslucent = isNavigationBarTranslucent
209
213
  }
210
214
 
215
+ fun setPreserveEdgeToEdge(isPreservingEdgeToEdge: Boolean) {
216
+ this.isPreservingEdgeToEdge = isPreservingEdgeToEdge
217
+ }
218
+
211
219
  fun setActive(active: Boolean) {
212
220
  this.active = active
213
221
 
@@ -41,6 +41,14 @@ class KeyboardControllerViewManager(
41
41
  manager.setNavigationBarTranslucent(view, isNavigationBarTranslucent)
42
42
  }
43
43
 
44
+ @ReactProp(name = "preserveEdgeToEdge")
45
+ fun setPreserveEdgeToEdge(
46
+ view: EdgeToEdgeReactViewGroup,
47
+ isPreservingEdgeToEdge: Boolean,
48
+ ) {
49
+ manager.setPreserveEdgeToEdge(view, isPreservingEdgeToEdge)
50
+ }
51
+
44
52
  override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
45
53
  manager.getExportedCustomDirectEventTypeConstants()
46
54
  }
@@ -48,4 +48,13 @@ class KeyboardGestureAreaViewManager(
48
48
  ) {
49
49
  manager.setScrollKeyboardOffScreenWhenVisible(view, value)
50
50
  }
51
+
52
+ @Suppress("detekt:UnusedParameter")
53
+ @ReactProp(name = "textInputNativeID")
54
+ fun setTextInputNativeID(
55
+ view: KeyboardGestureAreaReactViewGroup,
56
+ value: String,
57
+ ) {
58
+ // no-op
59
+ }
51
60
  }
@@ -23,14 +23,14 @@ public class KeyboardAnimation: KeyboardAnimationProtocol {
23
23
  // constructor variables
24
24
  let fromValue: Double
25
25
  let toValue: Double
26
- let speed: Float
26
+ let speed: Double
27
27
  let timestamp: CFTimeInterval
28
28
 
29
29
  init(fromValue: Double, toValue: Double, animation: CAMediaTiming) {
30
30
  self.fromValue = fromValue
31
31
  self.toValue = toValue
32
32
  self.animation = animation
33
- speed = animation.speed
33
+ speed = Double(animation.speed)
34
34
  timestamp = CACurrentMediaTime()
35
35
  }
36
36
 
@@ -63,7 +63,7 @@ public class KeyboardAnimation: KeyboardAnimationProtocol {
63
63
 
64
64
  while (upperBound - lowerBound) > tolerance {
65
65
  tGuess = (lowerBound + upperBound) / 2
66
- let currentValue = valueAt(time: tGuess / Double(speed))
66
+ let currentValue = valueAt(time: tGuess / speed)
67
67
 
68
68
  // Adjust the condition to account for the direction of animation
69
69
  if (currentValue < value && isIncreasing) || (currentValue > value && !isIncreasing) {
@@ -73,6 +73,6 @@ public class KeyboardAnimation: KeyboardAnimationProtocol {
73
73
  }
74
74
  }
75
75
 
76
- return tGuess / Double(speed)
76
+ return tGuess / speed
77
77
  }
78
78
  }
@@ -16,6 +16,13 @@ public final class SpringAnimation: KeyboardAnimation {
16
16
  private let omega0: Double // Undamped angular frequency of the oscillator
17
17
  private let omega1: Double // Exponential decay
18
18
  private let v0: Double // Initial velocity
19
+ // pre-computed values
20
+ private let x0: Double
21
+ private let isUnderDamped: Bool
22
+ private let aUnder: Double
23
+ private let bUnder: Double
24
+ private let aCritical: Double
25
+ private let bCritical: Double
19
26
 
20
27
  // constructor variables
21
28
  private let stiffness: Double
@@ -33,24 +40,40 @@ public final class SpringAnimation: KeyboardAnimation {
33
40
  omega0 = sqrt(stiffness / mass) // Undamped angular frequency of the oscillator
34
41
  omega1 = omega0 * sqrt(1.0 - zeta * zeta) // Exponential decay
35
42
  v0 = -initialVelocity
43
+ x0 = toValue - fromValue
44
+ isUnderDamped = zeta < 1
45
+
46
+ if isUnderDamped {
47
+ // Under damped
48
+ aCritical = 0
49
+ bCritical = 0
50
+ aUnder = (v0 + zeta * omega0 * x0) / omega1
51
+ bUnder = x0
52
+ } else {
53
+ // Critically damped
54
+ aCritical = x0
55
+ bCritical = (v0 + omega0 * x0)
56
+ aUnder = 0
57
+ bUnder = 0
58
+ }
36
59
 
37
60
  super.init(fromValue: fromValue, toValue: toValue, animation: animation)
38
61
  }
39
62
 
40
63
  // public functions
41
64
  override func valueAt(time: Double) -> Double {
42
- let t = time * Double(speed)
43
- let x0 = toValue - fromValue
65
+ let t = time * speed
44
66
 
45
67
  var y: Double
46
- if zeta < 1 {
68
+ if isUnderDamped {
47
69
  // Under damped
48
70
  let envelope = exp(-zeta * omega0 * t)
49
- y = toValue - envelope * (((v0 + zeta * omega0 * x0) / omega1) * sin(omega1 * t) + x0 * cos(omega1 * t))
71
+ let angle = omega1 * t
72
+ y = toValue - envelope * (aUnder * sin(angle) + bUnder * cos(angle))
50
73
  } else {
51
74
  // Critically damped
52
75
  let envelope = exp(-omega0 * t)
53
- y = toValue - envelope * (x0 + (v0 + omega0 * x0) * t)
76
+ y = toValue - envelope * (aCritical + bCritical * t)
54
77
  }
55
78
 
56
79
  return y
@@ -33,19 +33,38 @@ public final class TimingAnimation: KeyboardAnimation {
33
33
  super.init(fromValue: fromValue, toValue: toValue, animation: animation)
34
34
  }
35
35
 
36
- // public functions
36
+ // MARK: public functions
37
+
37
38
  override func valueAt(time: Double) -> Double {
38
- let x = time * Double(speed)
39
- let frames = (animation?.duration ?? 0.0) * Double(speed)
40
- let fraction = min(x / frames, 1)
41
- let t = findTForX(xTarget: fraction)
39
+ let duration = animation?.duration ?? 0.0
40
+ guard duration > 0 else { return toValue }
42
41
 
42
+ let fraction = min(time / duration, 1.0)
43
+ let t = findTForX(xTarget: fraction)
43
44
  let progress = bezierY(t: t)
44
45
 
45
- return fromValue + (toValue - fromValue) * CGFloat(progress)
46
+ return fromValue + (toValue - fromValue) * progress
47
+ }
48
+
49
+ override func timingAt(value: Double) -> Double {
50
+ guard (toValue - fromValue) != 0 else { return 0 }
51
+
52
+ let targetY = (value - fromValue) / (toValue - fromValue)
53
+ let clampedY = max(min(targetY, 1.0), 0.0)
54
+
55
+ let t = findTForY(yTarget: clampedY)
56
+ let x = bezierX(t: t)
57
+
58
+ let duration = animation?.duration ?? 0.0
59
+ let time = x * duration / speed
60
+
61
+ return time
46
62
  }
47
63
 
48
64
  // private functions
65
+
66
+ // MARK: Bézier
67
+
49
68
  private func bezier(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
50
69
  let u = 1 - t
51
70
  let tt = t * t
@@ -70,39 +89,65 @@ public final class TimingAnimation: KeyboardAnimation {
70
89
  return bezier(t: t) { $0.x }
71
90
  }
72
91
 
73
- private func findTForX(xTarget: CGFloat, epsilon: CGFloat = 0.0001, maxIterations: Int = 100) -> CGFloat {
74
- var t: CGFloat = 0.5 // Start with an initial guess of t = 0.5
75
- for _ in 0 ..< maxIterations {
76
- let currentX = bezierX(t: t) // Compute the x-coordinate at t
77
- let derivativeX = bezierXDerivative(t: t) // Compute the derivative at t
78
- let xError = currentX - xTarget
79
- if abs(xError) < epsilon {
80
- return t
81
- }
82
- t -= xError / derivativeX // Newton-Raphson step
83
- t = max(min(t, 1), 0) // Ensure t stays within bounds
92
+ private func bezierDerivative(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
93
+ let u = 1 - t
94
+ let term1 = (3 * u * u - 6 * t * u) * valueForPoint(p1)
95
+ let term2 = (6 * t * u - 3 * t * t) * valueForPoint(p2)
96
+ let term3 = 3 * t * t
97
+ return term1 + term2 + term3
98
+ }
99
+
100
+ private func bezierXDerivative(t: CGFloat) -> CGFloat {
101
+ bezierDerivative(t: t) { $0.x }
102
+ }
103
+
104
+ private func bezierYDerivative(t: CGFloat) -> CGFloat {
105
+ bezierDerivative(t: t) { $0.y }
106
+ }
107
+
108
+ private enum BezierComponent {
109
+ case x
110
+ case y
111
+ }
112
+
113
+ private func calculateComponents(t: CGFloat, component: BezierComponent) -> (value: CGFloat, derivative: CGFloat) {
114
+ switch component {
115
+ case .x:
116
+ return (bezierX(t: t), bezierXDerivative(t: t))
117
+ case .y:
118
+ return (bezierY(t: t), bezierYDerivative(t: t))
84
119
  }
85
- return t // Return the approximation of t
86
120
  }
87
121
 
88
- private func bezierDerivative(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
89
- let u = 1 - t
90
- let uu = u * u
91
- let tt = t * t
92
- let tu = t * u
122
+ // MARK: Newton Raphson
93
123
 
94
- // term0 is evaluated as `0`, because P0(0, 0)
95
- // let term0 = -3 * uu * valueForPoint(p0)
96
- let term1 = (3 * uu - 6 * tu) * valueForPoint(p1)
97
- let term2 = (6 * tu - 3 * tt) * valueForPoint(p2)
98
- let term3 = 3 * tt // * valueForPoint(p3), because P3(1, 1)
124
+ private func findT(
125
+ target: CGFloat,
126
+ component: BezierComponent,
127
+ epsilon: CGFloat = 0.0001,
128
+ maxIterations: Int = 100
129
+ ) -> CGFloat {
130
+ var t: CGFloat = 0.5
131
+ for _ in 0 ..< maxIterations {
132
+ let (value, derivative) = calculateComponents(t: t, component: component)
133
+ let error = value - target
99
134
 
100
- // Sum all terms to get the derivative
101
- return term1 + term2 + term3
135
+ if abs(error) < epsilon {
136
+ break
137
+ }
138
+
139
+ t -= error / derivative
140
+ t = max(0, min(t, 1))
141
+ }
142
+ return t
102
143
  }
103
144
 
104
- private func bezierXDerivative(t: CGFloat) -> CGFloat {
105
- return bezierDerivative(t: t) { $0.x }
145
+ private func findTForX(xTarget: CGFloat) -> CGFloat {
146
+ findT(target: xTarget, component: .x)
147
+ }
148
+
149
+ private func findTForY(yTarget: CGFloat) -> CGFloat {
150
+ findT(target: yTarget, component: .y)
106
151
  }
107
152
  }
108
153
 
@@ -51,6 +51,9 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
51
51
  weak var textViewDelegate: UITextViewDelegate?
52
52
  weak var textFieldDelegate: UITextFieldDelegate?
53
53
 
54
+ // Keep track of which textField we’re observing (iOS < 13 only)
55
+ private weak var observedTextFieldForSelection: UITextField?
56
+
54
57
  public init(
55
58
  onSelectionChange: @escaping (_ event: NSDictionary) -> Void,
56
59
  onTextChange: @escaping (_ text: String?) -> Void
@@ -62,13 +65,35 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
62
65
  // MARK: setters/getters
63
66
 
64
67
  public func setTextViewDelegate(delegate: UITextViewDelegate?) {
68
+ // remove KVO from any old textField
69
+ if let oldTextField = observedTextFieldForSelection {
70
+ removeSelectionRangeObserver(from: oldTextField)
71
+ }
72
+
65
73
  textViewDelegate = delegate
66
74
  textFieldDelegate = nil
75
+ observedTextFieldForSelection = nil
67
76
  }
68
77
 
69
- public func setTextFieldDelegate(delegate: UITextFieldDelegate?) {
78
+ public func setTextFieldDelegate(delegate: UITextFieldDelegate?, textField: UITextField?) {
79
+ // remove KVO from any old textField
80
+ if let oldTextField = observedTextFieldForSelection {
81
+ removeSelectionRangeObserver(from: oldTextField)
82
+ }
83
+
70
84
  textFieldDelegate = delegate
71
85
  textViewDelegate = nil
86
+
87
+ // If iOS < 13, add KVO to the actual textField object
88
+ if #available(iOS 13.0, *) {
89
+ // rely on textFieldDidChangeSelection
90
+ observedTextFieldForSelection = nil
91
+ } else {
92
+ if let realTextField = textField {
93
+ addSelectionRangeObserver(to: realTextField)
94
+ observedTextFieldForSelection = realTextField
95
+ }
96
+ }
72
97
  }
73
98
 
74
99
  // Getter for the active delegate
@@ -116,9 +141,56 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
116
141
  self.onTextChange(textField.text)
117
142
  }
118
143
 
144
+ if #unavailable(iOS 13.0) {
145
+ DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) {
146
+ updateSelectionPosition(textInput: textField, sendEvent: self.onSelectionChange)
147
+ }
148
+ }
149
+
119
150
  return textFieldDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true
120
151
  }
121
152
 
153
+ func textFieldDidEndEditing(_ textField: UITextField) {
154
+ textFieldDelegate?.textFieldDidEndEditing?(textField)
155
+
156
+ if #unavailable(iOS 13.0) {
157
+ removeSelectionRangeObserver(from: textField)
158
+ if observedTextFieldForSelection === textField {
159
+ observedTextFieldForSelection = nil
160
+ }
161
+ }
162
+ }
163
+
164
+ // MARK: KVO for iOS < 13
165
+
166
+ private func addSelectionRangeObserver(to textField: UITextField) {
167
+ textField.addObserver(
168
+ self,
169
+ forKeyPath: "selectedTextRange",
170
+ options: [.new],
171
+ context: nil
172
+ )
173
+ }
174
+
175
+ private func removeSelectionRangeObserver(from textField: UITextField) {
176
+ textField.removeObserver(self, forKeyPath: "selectedTextRange")
177
+ }
178
+
179
+ // swiftlint:disable:next block_based_kvo
180
+ override func observeValue(
181
+ forKeyPath keyPath: String?,
182
+ of object: Any?,
183
+ change: [NSKeyValueChangeKey: Any]?,
184
+ context: UnsafeMutableRawPointer?
185
+ ) {
186
+ guard keyPath == "selectedTextRange", let textField = object as? UITextField else {
187
+ super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
188
+ return
189
+ }
190
+ // selection changed => forward the event
191
+ updateSelectionPosition(textInput: textField, sendEvent: onSelectionChange)
192
+ }
193
+
122
194
  // MARK: call forwarding
123
195
 
124
196
  override func responds(to aSelector: Selector!) -> Bool {
@@ -15,3 +15,7 @@ extension Notification {
15
15
  return (duration, keyboardFrame)
16
16
  }
17
17
  }
18
+
19
+ extension Notification.Name {
20
+ static let shouldIgnoreKeyboardEvents = Notification.Name("shouldIgnoreKeyboardEvents")
21
+ }