react-native-keyboard-controller 1.16.0 → 1.16.2

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.
@@ -24,6 +24,11 @@ class KeyboardControllerViewManager(
24
24
 
25
25
  override fun createViewInstance(context: ThemedReactContext): ReactViewGroup = manager.createViewInstance(context)
26
26
 
27
+ override fun onAfterUpdateTransaction(view: ReactViewGroup) {
28
+ super.onAfterUpdateTransaction(view)
29
+ manager.setEdgeToEdge(view as EdgeToEdgeReactViewGroup)
30
+ }
31
+
27
32
  @ReactProp(name = "statusBarTranslucent")
28
33
  override fun setStatusBarTranslucent(
29
34
  view: ReactViewGroup,
@@ -44,6 +44,10 @@ class KeyboardControllerViewManagerImpl(
44
44
  view.setPreserveEdgeToEdge(isPreservingEdgeToEdge)
45
45
  }
46
46
 
47
+ fun setEdgeToEdge(view: EdgeToEdgeReactViewGroup) {
48
+ view.setEdgeToEdge()
49
+ }
50
+
47
51
  fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
48
52
  val map: MutableMap<String, Any> =
49
53
  MapBuilder.of(
@@ -33,6 +33,7 @@ class EdgeToEdgeReactViewGroup(
33
33
  private var isNavigationBarTranslucent = false
34
34
  private var isPreservingEdgeToEdge = false
35
35
  private var active = false
36
+ private var isEdgeToEdge = false
36
37
 
37
38
  // internal class members
38
39
  private var eventView: ReactViewGroup? = null
@@ -124,12 +125,18 @@ class EdgeToEdgeReactViewGroup(
124
125
  }
125
126
  }
126
127
 
127
- private fun goToEdgeToEdge(edgeToEdge: Boolean) {
128
- reactContext.currentActivity?.let {
129
- WindowCompat.setDecorFitsSystemWindows(
130
- it.window,
131
- !edgeToEdge,
132
- )
128
+ fun setEdgeToEdge() {
129
+ val nextValue = active || isPreservingEdgeToEdge
130
+
131
+ if (isEdgeToEdge != nextValue) {
132
+ isEdgeToEdge = nextValue
133
+
134
+ reactContext.currentActivity?.let {
135
+ WindowCompat.setDecorFitsSystemWindows(
136
+ it.window,
137
+ !isEdgeToEdge,
138
+ )
139
+ }
133
140
  }
134
141
  }
135
142
 
@@ -182,17 +189,12 @@ class EdgeToEdgeReactViewGroup(
182
189
 
183
190
  // region State managers
184
191
  private fun enable() {
185
- this.goToEdgeToEdge(true)
186
192
  this.setupWindowInsets()
187
193
  this.setupKeyboardCallbacks()
188
194
  modalAttachedWatcher.enable()
189
195
  }
190
196
 
191
197
  private fun disable() {
192
- if (!isPreservingEdgeToEdge) {
193
- this.goToEdgeToEdge(false)
194
- }
195
-
196
198
  this.setupWindowInsets()
197
199
  this.removeKeyboardCallbacks()
198
200
  modalAttachedWatcher.disable()
@@ -3,6 +3,7 @@ package com.reactnativekeyboardcontroller
3
3
  import com.facebook.react.bridge.ReactApplicationContext
4
4
  import com.facebook.react.uimanager.ThemedReactContext
5
5
  import com.facebook.react.uimanager.annotations.ReactProp
6
+ import com.facebook.react.views.view.ReactViewGroup
6
7
  import com.facebook.react.views.view.ReactViewManager
7
8
  import com.reactnativekeyboardcontroller.managers.KeyboardControllerViewManagerImpl
8
9
  import com.reactnativekeyboardcontroller.views.EdgeToEdgeReactViewGroup
@@ -17,6 +18,11 @@ class KeyboardControllerViewManager(
17
18
  override fun createViewInstance(reactContext: ThemedReactContext): EdgeToEdgeReactViewGroup =
18
19
  manager.createViewInstance(reactContext)
19
20
 
21
+ override fun onAfterUpdateTransaction(view: ReactViewGroup) {
22
+ super.onAfterUpdateTransaction(view)
23
+ manager.setEdgeToEdge(view as EdgeToEdgeReactViewGroup)
24
+ }
25
+
20
26
  @ReactProp(name = "enabled")
21
27
  fun setEnabled(
22
28
  view: EdgeToEdgeReactViewGroup,
@@ -14,9 +14,10 @@ public class KeyboardControllerModuleImpl: NSObject {
14
14
 
15
15
  @objc
16
16
  public static func dismiss(_ keepFocus: Bool) {
17
- guard let input = UIResponder.current as? TextInput else { return }
17
+ let responder = UIResponder.current
18
18
 
19
19
  if keepFocus {
20
+ guard let input = responder as? TextInput else { return }
20
21
  let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onTextInputTapped(_:)))
21
22
  tapGesture.name = keyboardRevealGestureName
22
23
  input.addGestureRecognizer(tapGesture)
@@ -31,7 +32,7 @@ public class KeyboardControllerModuleImpl: NSObject {
31
32
  object: input
32
33
  )
33
34
  } else {
34
- input.resignFirstResponder()
35
+ responder?.resignFirstResponder()
35
36
  }
36
37
  }
37
38
 
@@ -11,28 +11,9 @@ import UIKit
11
11
 
12
12
  @objc
13
13
  public extension UIResponder {
14
- private weak static var _currentFirstResponder: UIResponder?
15
-
16
14
  static var current: UIResponder? {
17
- UIResponder._currentFirstResponder = nil
18
- UIApplication.shared.sendAction(#selector(findFirstResponder(sender:)), to: nil, from: nil, for: nil)
19
- return UIResponder._currentFirstResponder
20
- }
21
-
22
- internal func findFirstResponder(sender _: AnyObject) {
23
- let type = String(describing: type(of: self))
24
- // handle `contextMenuHidden` prop - in this case the parent is considered as a first responder
25
- // (but actually its children is an actual input), so we apply correction here and point out
26
- // to the actual first responder (first children)
27
- let isChildrenActuallyFirstResponder =
28
- type == "RCTMultilineTextInputView" ||
29
- type == "RCTSinglelineTextInputView" ||
30
- type == "RCTTextInputComponentView"
31
- if isChildrenActuallyFirstResponder {
32
- UIResponder._currentFirstResponder = (self as? UIView)?.subviews[0]
33
- } else {
34
- UIResponder._currentFirstResponder = self
35
- }
15
+ guard let window = UIApplication.shared.activeWindow else { return nil }
16
+ return window.findFirstResponder()
36
17
  }
37
18
  }
38
19
 
@@ -49,7 +30,11 @@ public extension Optional where Wrapped == UIResponder {
49
30
  guard let superview = (self as? UIView)?.superview else { return nil }
50
31
 
51
32
  #if KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED
52
- return (superview as NSObject).value(forKey: "nativeId") as? String
33
+ if superview.responds(to: Selector(("nativeId"))) == true {
34
+ return (superview as NSObject).value(forKey: "nativeId") as? String
35
+ }
36
+
37
+ return nil
53
38
  #else
54
39
  return superview.nativeID
55
40
  #endif
@@ -34,6 +34,18 @@ public extension UIView {
34
34
  return false
35
35
  }
36
36
  }
37
+
38
+ func findFirstResponder() -> UIView? {
39
+ if isFirstResponder {
40
+ return self
41
+ }
42
+ for subview in subviews {
43
+ if let responder = subview.findFirstResponder() {
44
+ return responder
45
+ }
46
+ }
47
+ return nil
48
+ }
37
49
  }
38
50
 
39
51
  public extension Optional where Wrapped == UIView {
@@ -33,7 +33,7 @@ public class KeyboardMovementObserver: NSObject {
33
33
  private var _windowsCount: Int = 0
34
34
  private var prevKeyboardPosition = 0.0
35
35
  private var displayLink: CADisplayLink?
36
- private var hasKVObserver = false
36
+ private var interactiveKeyboardObserver: NSKeyValueObservation?
37
37
  private var isMounted = false
38
38
  // state variables
39
39
  private var _keyboardHeight: CGFloat = 0.0
@@ -93,73 +93,57 @@ public class KeyboardMovementObserver: NSObject {
93
93
  }
94
94
 
95
95
  private func setupKVObserver() {
96
- if hasKVObserver {
97
- return
98
- }
96
+ guard interactiveKeyboardObserver == nil, let view = keyboardView else { return }
97
+
98
+ interactiveKeyboardObserver = view.observe(\.center, options: .new) { [weak self] _, change in
99
+ guard let self = self, let changeValue = change.newValue else { return }
99
100
 
100
- if keyboardView != nil {
101
- hasKVObserver = true
102
- keyboardView?.addObserver(self, forKeyPath: "center", options: .new, context: nil)
101
+ self.keyboardDidMoveInteractively(changeValue: changeValue)
103
102
  }
104
103
  }
105
104
 
106
105
  private func removeKVObserver() {
107
- if !hasKVObserver {
106
+ interactiveKeyboardObserver?.invalidate()
107
+ interactiveKeyboardObserver = nil
108
+ }
109
+
110
+ private func keyboardDidMoveInteractively(changeValue: CGPoint) {
111
+ // if we are currently animating keyboard -> we need to ignore values from KVO
112
+ if displayLink != nil {
113
+ return
114
+ }
115
+ // if keyboard height is not equal to its bounds - we can ignore
116
+ // values, since they'll be invalid and will cause UI jumps
117
+ if floor(keyboardView?.bounds.size.height ?? 0) != floor(_keyboardHeight) {
108
118
  return
109
119
  }
110
120
 
111
- hasKVObserver = false
112
- _keyboardView?.removeObserver(self, forKeyPath: "center", context: nil)
113
- }
121
+ let keyboardFrameY = changeValue.y
122
+ let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0
123
+ let keyboardPosition = keyboardWindowH - keyboardFrameY
114
124
 
115
- // swiftlint:disable:next block_based_kvo
116
- @objc override public func observeValue(
117
- forKeyPath keyPath: String?,
118
- of object: Any?,
119
- change: [NSKeyValueChangeKey: Any]?,
120
- context _: UnsafeMutableRawPointer?
121
- ) {
122
- if keyPath == "center", object as? NSObject == _keyboardView {
123
- // if we are currently animating keyboard -> we need to ignore values from KVO
124
- if displayLink != nil {
125
- return
126
- }
127
- // if keyboard height is not equal to its bounds - we can ignore
128
- // values, since they'll be invalid and will cause UI jumps
129
- if floor(keyboardView?.bounds.size.height ?? 0) != floor(_keyboardHeight) {
130
- return
131
- }
125
+ let position = CGFloat.interpolate(
126
+ inputRange: [_keyboardHeight / 2, -_keyboardHeight / 2],
127
+ outputRange: [_keyboardHeight, 0],
128
+ currentValue: keyboardPosition
129
+ ) - KeyboardAreaExtender.shared.offset
132
130
 
133
- guard let changeValue = change?[.newKey] as? NSValue else {
134
- return
135
- }
136
- let keyboardFrameY = changeValue.cgPointValue.y
137
- let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0
138
- let keyboardPosition = keyboardWindowH - keyboardFrameY
139
-
140
- let position = CGFloat.interpolate(
141
- inputRange: [_keyboardHeight / 2, -_keyboardHeight / 2],
142
- outputRange: [_keyboardHeight, 0],
143
- currentValue: keyboardPosition
144
- ) - KeyboardAreaExtender.shared.offset
145
-
146
- if position == 0 {
147
- // it will be triggered before `keyboardWillDisappear` and
148
- // we don't need to trigger `onInteractive` handler for that
149
- // since it will be handled in `keyboardWillDisappear` function
150
- return
151
- }
131
+ if position == 0 {
132
+ // it will be triggered before `keyboardWillDisappear` and
133
+ // we don't need to trigger `onInteractive` handler for that
134
+ // since it will be handled in `keyboardWillDisappear` function
135
+ return
136
+ }
152
137
 
153
- prevKeyboardPosition = position
138
+ prevKeyboardPosition = position
154
139
 
155
- onEvent(
156
- "onKeyboardMoveInteractive",
157
- position as NSNumber,
158
- position / CGFloat(keyboardHeight) as NSNumber,
159
- -1,
160
- tag
161
- )
162
- }
140
+ onEvent(
141
+ "onKeyboardMoveInteractive",
142
+ position as NSNumber,
143
+ position / CGFloat(keyboardHeight) as NSNumber,
144
+ -1,
145
+ tag
146
+ )
163
147
  }
164
148
 
165
149
  @objc public func unmount() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-keyboard-controller",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "description": "Keyboard manager which works in identical way on both iOS and Android",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -17,6 +17,7 @@
17
17
  "jest",
18
18
  "!lib/typescript/example",
19
19
  "!android/build",
20
+ "!android/src/test",
20
21
  "!android/.gradle",
21
22
  "!android/gradle",
22
23
  "!android/gradlew",
@@ -1,45 +0,0 @@
1
- package com.reactnativekeyboardcontroller.traversal
2
-
3
- import android.content.Context
4
- import android.widget.EditText
5
- import androidx.test.core.app.ApplicationProvider
6
- import org.junit.Assert.assertEquals
7
- import org.junit.Assert.assertFalse
8
- import org.junit.Assert.assertNull
9
- import org.junit.Assert.assertTrue
10
- import org.junit.Test
11
- import org.junit.runner.RunWith
12
- import org.robolectric.RobolectricTestRunner
13
-
14
- @RunWith(RobolectricTestRunner::class)
15
- class FocusedInputHolderTest {
16
- @Test
17
- fun `FocusedInputHolder should hold a weak reference`() {
18
- val context = ApplicationProvider.getApplicationContext<Context>()
19
- var input: EditText? = EditText(context)
20
-
21
- FocusedInputHolder.set(input as EditText)
22
-
23
- assertEquals(FocusedInputHolder.get(), input)
24
-
25
- input = null
26
-
27
- @Suppress("detekt:ExplicitGarbageCollectionCall")
28
- System.gc()
29
-
30
- assertNull(FocusedInputHolder.get())
31
- }
32
-
33
- @Test
34
- fun `focus() should request focus on expected field`() {
35
- val context = ApplicationProvider.getApplicationContext<Context>()
36
- val input = EditText(context)
37
-
38
- assertFalse(input.hasFocus())
39
-
40
- FocusedInputHolder.set(input)
41
- FocusedInputHolder.focus()
42
-
43
- assertTrue(input.hasFocus())
44
- }
45
- }
@@ -1,198 +0,0 @@
1
- package com.reactnativekeyboardcontroller.traversal
2
-
3
- import android.content.Context
4
- import android.widget.EditText
5
- import android.widget.LinearLayout
6
- import androidx.test.core.app.ApplicationProvider
7
- import com.reactnativekeyboardcontroller.extensions.focus
8
- import org.junit.Assert.assertTrue
9
- import org.junit.Before
10
- import org.junit.Test
11
- import org.junit.runner.RunWith
12
- import org.robolectric.RobolectricTestRunner
13
- import org.robolectric.shadows.ShadowLooper
14
-
15
- @RunWith(RobolectricTestRunner::class)
16
- class ViewHierarchyNavigatorTest {
17
- private lateinit var layout: LinearLayout
18
- private lateinit var editText1: EditText
19
- private lateinit var editText2: EditText
20
- private lateinit var editText3: EditText
21
- private lateinit var editText4: EditText
22
- private lateinit var editText5: EditText
23
- private lateinit var editText6: EditText
24
- private lateinit var editText7: EditText
25
- private lateinit var editText8: EditText
26
- private lateinit var editText9: EditText
27
- private lateinit var editText10: EditText
28
- private lateinit var editText11: EditText
29
- private lateinit var editText12: EditText
30
- private lateinit var editText13: EditText
31
-
32
- @Suppress("detekt:CyclomaticComplexMethod")
33
- @Before
34
- fun setUp() {
35
- val context = ApplicationProvider.getApplicationContext<Context>()
36
-
37
- editText1 = EditText(context).apply { id = 1 }
38
- editText2 = EditText(context).apply { id = 2 }
39
- editText3 =
40
- EditText(context).apply {
41
- id = 3
42
- isEnabled = false
43
- }
44
- editText4 =
45
- EditText(context).apply {
46
- id = 4
47
- isEnabled = false
48
- }
49
- editText5 = EditText(context).apply { id = 5 }
50
- editText6 = EditText(context).apply { id = 6 }
51
- editText7 = EditText(context).apply { id = 7 }
52
- editText8 = EditText(context).apply { id = 8 }
53
- editText9 = EditText(context).apply { id = 9 }
54
- editText10 = EditText(context).apply { id = 10 }
55
- editText11 = EditText(context).apply { id = 11 }
56
- editText12 = EditText(context).apply { id = 12 }
57
- editText13 = EditText(context).apply { id = 13 }
58
-
59
- layout =
60
- LinearLayout(context).apply {
61
- addView(editText1)
62
- addView(editText2)
63
- addView(editText3)
64
- addView(editText4)
65
- addView(
66
- LinearLayout(context).apply {
67
- addView(editText5)
68
- addView(editText6)
69
- addView(editText7)
70
- },
71
- )
72
- addView(editText8)
73
- addView(editText9)
74
- addView(editText10)
75
- addView(editText11)
76
- addView(editText12)
77
- addView(editText13)
78
- }
79
- }
80
-
81
- @Test
82
- fun `getAllInputFields returns all EditTexts in ViewGroup`() {
83
- val editTexts = ViewHierarchyNavigator.getAllInputFields(layout)
84
-
85
- // 13 (all) - 2 (disabled)
86
- assertTrue(editTexts.size == 11)
87
- }
88
-
89
- @Test
90
- fun `setFocusTo to 'next' should set focus to next field`() {
91
- editText1.focus()
92
-
93
- ViewHierarchyNavigator.setFocusTo("next", editText1)
94
-
95
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
96
-
97
- assertTrue(editText2.hasFocus())
98
- }
99
-
100
- @Test
101
- fun `setFocusTo to 'prev' should set focus to previous field`() {
102
- editText2.focus()
103
-
104
- ViewHierarchyNavigator.setFocusTo("prev", editText2)
105
-
106
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
107
-
108
- assertTrue(editText1.hasFocus())
109
- }
110
-
111
- @Test
112
- fun `setFocusTo to 'next' should skip non-editable fields`() {
113
- editText2.focus()
114
-
115
- ViewHierarchyNavigator.setFocusTo("next", editText2)
116
-
117
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
118
-
119
- assertTrue(editText5.hasFocus())
120
- }
121
-
122
- @Test
123
- fun `setFocusTo to 'prev' should skip non-editable fields`() {
124
- editText5.focus()
125
-
126
- ViewHierarchyNavigator.setFocusTo("prev", editText5)
127
-
128
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
129
-
130
- assertTrue(editText2.hasFocus())
131
- }
132
-
133
- @Test
134
- fun `setFocusTo to 'next' should set focus relatively to current group`() {
135
- editText5.focus()
136
-
137
- ViewHierarchyNavigator.setFocusTo("next", editText5)
138
-
139
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
140
-
141
- assertTrue(editText6.hasFocus())
142
- }
143
-
144
- @Test
145
- fun `setFocusTo to 'prev' should set focus relatively to current group`() {
146
- editText7.focus()
147
-
148
- ViewHierarchyNavigator.setFocusTo("prev", editText7)
149
-
150
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
151
-
152
- assertTrue(editText6.hasFocus())
153
- }
154
-
155
- @Test
156
- fun `setFocusTo to 'next' should correctly exit from current group`() {
157
- editText7.focus()
158
-
159
- ViewHierarchyNavigator.setFocusTo("next", editText7)
160
-
161
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
162
-
163
- assertTrue(editText8.hasFocus())
164
- }
165
-
166
- @Test
167
- fun `setFocusTo to 'prev' should set focus to last element in group`() {
168
- editText8.focus()
169
-
170
- ViewHierarchyNavigator.setFocusTo("prev", editText8)
171
-
172
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
173
-
174
- assertTrue(editText7.hasFocus())
175
- }
176
-
177
- @Test
178
- fun `setFocusTo to 'next' should do nothing if it's last element`() {
179
- editText13.focus()
180
-
181
- ViewHierarchyNavigator.setFocusTo("next", editText13)
182
-
183
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
184
-
185
- assertTrue(editText13.hasFocus())
186
- }
187
-
188
- @Test
189
- fun `setFocusTo to 'prev' should do nothing if it's first element`() {
190
- editText1.focus()
191
-
192
- ViewHierarchyNavigator.setFocusTo("prev", editText1)
193
-
194
- ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
195
-
196
- assertTrue(editText1.hasFocus())
197
- }
198
- }