react-native-controlled-input 0.19.0 → 0.20.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.
@@ -5,7 +5,6 @@ import android.util.AttributeSet
5
5
  import android.util.Log
6
6
  import android.util.TypedValue
7
7
  import android.view.View
8
- import android.view.ViewTreeObserver
9
8
  import android.view.inputmethod.InputMethodManager
10
9
  import android.widget.EditText
11
10
  import android.widget.LinearLayout
@@ -68,114 +67,28 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
68
67
  private var windowLifecycleBound = false
69
68
 
70
69
  /**
71
- * True while we are in the focus-restoration dance:
72
- * proxy stole Android focus from Compose onBlur fired we're restoring Compose focus.
73
- * During this window the second onFocus must NOT call requestFocusProxy() again.
70
+ * Hidden [EditText] used only as KBC's [FocusedInputObserver.lastFocusedInput]: [syncUpLayout]
71
+ * reads [EditText]-scoped geometry. It is NOT focused and does not participate in the focus
72
+ * chain we push state via reflection + synthetic selection events instead.
74
73
  */
75
- private var isRestoringComposeFocus = false
76
-
77
- /**
78
- * Invisible EditText that acts as a focus proxy for react-native-keyboard-controller.
79
- *
80
- * keyboard-controller's FocusedInputObserver only tracks views that are `EditText` instances.
81
- * Since ControlledInputView uses Compose BasicTextField, it is invisible to that observer.
82
- * Focusing this proxy when Compose gains focus makes keyboard-controller aware of the input
83
- * and allows KeyboardAwareScrollView to scroll correctly.
84
- *
85
- * Layout height is 0 so LinearLayout ignores it visually. onLayout() forces its bounds to
86
- * match ControlledInputView so keyboard-controller reads the correct width/height/position.
87
- */
88
- private val focusProxy: EditText by lazy {
89
- EditText(context).also { proxy ->
90
- proxy.layoutParams = LayoutParams(0, 0)
91
- // Must stay VISIBLE — Android's canTakeFocus() returns false for INVISIBLE/GONE views,
92
- // causing requestFocus() to silently fail. Use alpha=0 to hide it visually instead.
93
- proxy.alpha = 0f
94
- proxy.isFocusableInTouchMode = true
95
- proxy.showSoftInputOnFocus = false
96
- proxy.isClickable = false
97
- proxy.isCursorVisible = false
98
- proxy.isLongClickable = false
99
- }
100
- }
101
-
102
- private fun requestFocusProxy() {
103
- val fontSize = viewModel.inputStyle.value?.fontSize?.toFloat()
104
- Log.d(TAG, "──── requestFocusProxy ────")
105
- Log.d(TAG, " fontSize=$fontSize")
106
- Log.d(TAG, " proxy before: isFocused=${focusProxy.isFocused} size=${focusProxy.width}x${focusProxy.height}")
107
-
108
- // Sync proxy ID with ControlledInputView's React Native tag BEFORE requestFocus(),
109
- // so KeyboardAnimationCallback.focusListener sets viewTagFocused = this.id (not -1).
110
- // Without this, KeyboardAwareScrollView JS sees e.target=-1 → focusWasChanged=false
111
- // → layout.value never updated → maybeScroll() always returns 0 → no scroll.
112
- focusProxy.id = this.id
113
- Log.d(TAG, " proxy.id set to ${focusProxy.id} (= ControlledInputView RN tag)")
114
-
115
- // Sync textSize so KeyboardControllerSelectionWatcher computes the correct
116
- // cursor Y via android.text.Layout.getLineBottom() — used as `customHeight`
117
- // in KeyboardAwareScrollView to determine how far to scroll.
118
- fontSize?.let {
119
- focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
120
- Log.d(TAG, " proxy textSize set to ${focusProxy.textSize}px (${it}sp)")
74
+ private val kbcLayoutHost: EditText by lazy {
75
+ EditText(context).also { v ->
76
+ v.layoutParams = LayoutParams(0, 0)
77
+ v.alpha = 0f
78
+ v.isFocusable = false
79
+ v.isFocusableInTouchMode = false
80
+ v.showSoftInputOnFocus = false
81
+ v.isClickable = false
82
+ v.isCursorVisible = false
83
+ v.isLongClickable = false
121
84
  }
122
-
123
- focusProxy.requestFocus()
124
- Log.d(TAG, " proxy after requestFocus: isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
125
-
126
- if (focusProxy.isFocused) {
127
- // Proxy stole Android focus from AndroidComposeView → Compose will fire onBlur.
128
- // Mark restoration mode so onBlur knows to recover (not dispatch BlurEvent to JS).
129
- isRestoringComposeFocus = true
130
- Log.d(TAG, " isRestoringComposeFocus=true (proxy has Android focus)")
131
- }
132
-
133
- // setSelection triggers a selection change (lastSelectionStart starts at -1),
134
- // guaranteeing the watcher fires on the next pre-draw frame even if focus
135
- // was just transferred.
136
- focusProxy.setSelection(0)
137
- Log.d(TAG, " proxy selectionStart=${focusProxy.selectionStart} layout=${focusProxy.layout != null}")
138
-
139
- // Log absolute screen position — this is what keyboard-controller reads for scroll calculation
140
- val loc = IntArray(2)
141
- focusProxy.getLocationOnScreen(loc)
142
- Log.d(TAG, " proxy screenLocation x=${loc[0]} y=${loc[1]} → absoluteY for KBC=${loc[1]}px")
143
- Log.d(TAG, "──────────────────────────")
144
- }
145
-
146
- private fun clearFocusProxy() {
147
- Log.d(TAG, "clearFocusProxy: proxy.isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
148
- focusProxy.clearFocus()
149
- Log.d(TAG, "clearFocusProxy: after clearFocus isFocused=${focusProxy.isFocused}")
150
85
  }
151
86
 
152
87
  /**
153
- * Fires when the proxy loses Android focus (i.e. after composeView.requestFocus() restores
154
- * Compose). At this point KBC's listener has already cleared lastFocusedInput (KBC registered
155
- * its ViewTreeObserver listener before us → it fires first). We post restoreKbcTracking() to
156
- * run after all synchronous focus-change handlers complete.
157
- */
158
- private val proxyFocusLostListener =
159
- ViewTreeObserver.OnGlobalFocusChangeListener { oldFocus, newFocus ->
160
- // Fire for ANY proxy focus loss while we're in the restoration dance.
161
- // newFocus can be null (proxy→null→AndroidComposeView, two steps on first focus)
162
- // or AndroidComposeView directly (proxy→AndroidComposeView, subsequent focuses).
163
- // Both cases need restoreKbcTracking(); KBC's null→AndroidComposeView transition does
164
- // NOT clear lastFocusedInput, so restoring it once is enough for both paths.
165
- if (oldFocus == focusProxy && isRestoringComposeFocus) {
166
- Log.d(
167
- TAG,
168
- "proxyFocusLostListener: proxy → ${newFocus?.javaClass?.simpleName ?: "null"}, posting restoreKbcTracking()",
169
- )
170
- post { restoreKbcTracking() }
171
- }
172
- }
173
-
174
- /**
175
- * EdgeToEdgeViewRegistry.get() → callback → FocusedInputObserver.
88
+ * EdgeToEdgeViewRegistry KeyboardAnimationCallback + FocusedInputObserver.
176
89
  * Null if react-native-keyboard-controller is missing or not initialized.
177
90
  */
178
- private fun resolveKbcFocusedInputObserver(): Any? {
91
+ private fun resolveKbcCallbackAndObserver(): Pair<Any, Any>? {
179
92
  try {
180
93
  val registryClass =
181
94
  Class.forName("com.reactnativekeyboardcontroller.views.EdgeToEdgeViewRegistry")
@@ -183,7 +96,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
183
96
  val edgeToEdgeView =
184
97
  registryClass.getDeclaredMethod("get").invoke(registryInstance)
185
98
  ?: run {
186
- Log.w(TAG, "resolveKbcFocusedInputObserver: EdgeToEdgeViewRegistry.get() == null")
99
+ Log.w(TAG, "resolveKbcCallbackAndObserver: EdgeToEdgeViewRegistry.get() == null")
187
100
  return null
188
101
  }
189
102
 
@@ -192,14 +105,14 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
192
105
  it.type.simpleName == "KeyboardAnimationCallback"
193
106
  }
194
107
  ?: run {
195
- Log.w(TAG, "resolveKbcFocusedInputObserver: KeyboardAnimationCallback field not found")
108
+ Log.w(TAG, "resolveKbcCallbackAndObserver: KeyboardAnimationCallback field not found")
196
109
  return null
197
110
  }
198
111
  callbackField.isAccessible = true
199
112
  val callback =
200
113
  callbackField.get(edgeToEdgeView)
201
114
  ?: run {
202
- Log.w(TAG, "resolveKbcFocusedInputObserver: callback == null")
115
+ Log.w(TAG, "resolveKbcCallbackAndObserver: callback == null")
203
116
  return null
204
117
  }
205
118
 
@@ -208,35 +121,57 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
208
121
  it.type.simpleName == "FocusedInputObserver"
209
122
  }
210
123
  ?: run {
211
- Log.w(TAG, "resolveKbcFocusedInputObserver: FocusedInputObserver field not found")
124
+ Log.w(TAG, "resolveKbcCallbackAndObserver: FocusedInputObserver field not found")
212
125
  return null
213
126
  }
214
127
  observerField.isAccessible = true
215
- return observerField.get(callback)
216
- ?: run {
217
- Log.w(TAG, "resolveKbcFocusedInputObserver: layoutObserver == null")
218
- null
219
- }
128
+ val observer =
129
+ observerField.get(callback)
130
+ ?: run {
131
+ Log.w(TAG, "resolveKbcCallbackAndObserver: layoutObserver == null")
132
+ return null
133
+ }
134
+ return Pair(callback, observer)
220
135
  } catch (_: ClassNotFoundException) {
221
- Log.d(TAG, "resolveKbcFocusedInputObserver: keyboard-controller not on classpath")
136
+ Log.d(TAG, "resolveKbcCallbackAndObserver: keyboard-controller not on classpath")
222
137
  return null
223
138
  } catch (e: Exception) {
224
- Log.w(TAG, "resolveKbcFocusedInputObserver: ${e.javaClass.simpleName}: ${e.message}")
139
+ Log.w(TAG, "resolveKbcCallbackAndObserver: ${e.javaClass.simpleName}: ${e.message}")
225
140
  return null
226
141
  }
227
142
  }
228
143
 
229
- /** Approximate one line height in dp for JS customHeight when proxy has no Layout yet. */
144
+ private fun setKbcViewTagFocused(callback: Any) {
145
+ try {
146
+ val f = callback.javaClass.getDeclaredField("viewTagFocused")
147
+ f.isAccessible = true
148
+ f.setInt(callback, id)
149
+ Log.d(TAG, "setKbcViewTagFocused: viewTagFocused=$id")
150
+ } catch (e: Exception) {
151
+ Log.w(TAG, "setKbcViewTagFocused: ${e.javaClass.simpleName}: ${e.message}")
152
+ }
153
+ }
154
+
155
+ private fun setKbcFocusedInputHolder() {
156
+ try {
157
+ val holderClass =
158
+ Class.forName("com.reactnativekeyboardcontroller.traversal.FocusedInputHolder")
159
+ val instance = holderClass.getField("INSTANCE").get(null)
160
+ holderClass
161
+ .getMethod("set", EditText::class.java)
162
+ .invoke(instance, kbcLayoutHost)
163
+ } catch (e: Exception) {
164
+ Log.w(TAG, "setKbcFocusedInputHolder: ${e.javaClass.simpleName}: ${e.message}")
165
+ }
166
+ }
167
+
168
+ /** Approximate one line height in dp for JS customHeight. */
230
169
  private fun approximateSelectionEndYDp(): Double {
231
170
  viewModel.inputStyle.value?.fontSize?.toDouble()?.takeIf { it > 0 }?.let { return it }
232
171
  val dm = resources.displayMetrics
233
- return (focusProxy.textSize / dm.density).toDouble().coerceAtLeast(12.0)
172
+ return (kbcLayoutHost.textSize / dm.density).toDouble().coerceAtLeast(12.0)
234
173
  }
235
174
 
236
- /**
237
- * Synthetic topFocusedInputSelectionChanged without a compile dependency on KBC.
238
- * Helps older KeyboardAwareScrollView when proxy never gets android.text.Layout in time.
239
- */
240
175
  private fun dispatchSyntheticKbcSelectionEvent(observer: Any) {
241
176
  val reactContext = context as? ReactContext ?: return
242
177
  try {
@@ -282,27 +217,34 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
282
217
  }
283
218
 
284
219
  /**
285
- * Reflection: lastFocusedInput = focusProxy, syncUpLayout(), then synthetic selection
286
- * so JS gets customHeight (selection.end.y) without waiting for KeyboardControllerSelectionWatcher.
220
+ * Pushes ControlledInput state into KBC without stealing Compose focus:
221
+ * viewTagFocused, lastFocusedInput, FocusedInputHolder, syncUpLayout, synthetic selection.
287
222
  */
288
- private fun restoreKbcTracking() {
289
- Log.d(TAG, "restoreKbcTracking: starting reflection chain")
290
- try {
291
- val observer = resolveKbcFocusedInputObserver() ?: return
223
+ private fun syncKeyboardControllerFocusedInput() {
224
+ Log.d(TAG, "syncKeyboardControllerFocusedInput: id=$id")
225
+ kbcLayoutHost.id = id
226
+ viewModel.inputStyle.value?.fontSize?.toFloat()?.let {
227
+ kbcLayoutHost.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
228
+ }
292
229
 
230
+ val (callback, observer) = resolveKbcCallbackAndObserver() ?: return
231
+
232
+ setKbcViewTagFocused(callback)
233
+ setKbcFocusedInputHolder()
234
+
235
+ try {
293
236
  val lastFocusedField = observer.javaClass.getDeclaredField("lastFocusedInput")
294
237
  lastFocusedField.isAccessible = true
295
- lastFocusedField.set(observer, focusProxy)
296
- Log.d(TAG, "restoreKbcTracking: lastFocusedInput set to focusProxy")
238
+ lastFocusedField.set(observer, kbcLayoutHost)
297
239
 
298
240
  val syncMethod = observer.javaClass.getDeclaredMethod("syncUpLayout")
299
241
  syncMethod.isAccessible = true
300
242
  syncMethod.invoke(observer)
301
- Log.d(TAG, "restoreKbcTracking: syncUpLayout() invoked")
243
+ Log.d(TAG, "syncKeyboardControllerFocusedInput: syncUpLayout() ok")
302
244
 
303
245
  dispatchSyntheticKbcSelectionEvent(observer)
304
246
  } catch (e: Exception) {
305
- Log.w(TAG, "restoreKbcTracking: ${e.javaClass.simpleName}: ${e.message}")
247
+ Log.w(TAG, "syncKeyboardControllerFocusedInput: ${e.javaClass.simpleName}: ${e.message}")
306
248
  }
307
249
  }
308
250
 
@@ -310,19 +252,15 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
310
252
 
311
253
  override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
312
254
  super.onLayout(changed, l, t, r, b)
313
- // Force proxy bounds to match ControlledInputView so keyboard-controller reads
314
- // the correct width/height/absolutePosition when the proxy is focused.
315
- focusProxy.layout(0, 0, width, height)
255
+ kbcLayoutHost.layout(0, 0, width, height)
316
256
  if (changed) {
317
257
  val loc = IntArray(2)
318
258
  getLocationOnScreen(loc)
319
259
  Log.d(TAG, "onLayout: view=${width}x${height} screenX=${loc[0]} screenY=${loc[1]}")
320
- Log.d(TAG, "onLayout: proxy=${focusProxy.width}x${focusProxy.height} (should match view)")
321
260
  }
322
261
  }
323
262
 
324
263
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
325
- // Do not measure ComposeView until attached to a window.
326
264
  if (shouldUseAndroidLayout && !isAttachedToWindow) {
327
265
  setMeasuredDimension(
328
266
  MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(0),
@@ -333,7 +271,6 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
333
271
  super.onMeasure(widthMeasureSpec, heightMeasureSpec)
334
272
  }
335
273
 
336
- // Fabric/Yoga often won't drive Android layout for native children.
337
274
  override fun requestLayout() {
338
275
  super.requestLayout()
339
276
  if (shouldUseAndroidLayout) {
@@ -353,14 +290,11 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
353
290
  override fun onAttachedToWindow() {
354
291
  super.onAttachedToWindow()
355
292
  Log.d(TAG, "onAttachedToWindow: id=$id")
356
- viewTreeObserver.addOnGlobalFocusChangeListener(proxyFocusLostListener)
357
293
  bindComposeToWindowLifecycle()
358
294
  }
359
295
 
360
296
  override fun onDetachedFromWindow() {
361
297
  Log.d(TAG, "onDetachedFromWindow: id=$id")
362
- viewTreeObserver.removeOnGlobalFocusChangeListener(proxyFocusLostListener)
363
- isRestoringComposeFocus = false
364
298
  if (usesLocalFallbackLifecycle) {
365
299
  lifecycleRegistry.currentState = Lifecycle.State.CREATED
366
300
  }
@@ -406,12 +340,10 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
406
340
 
407
341
  fun blur() {
408
342
  Log.d(TAG, "blur() called from JS ref")
409
- isRestoringComposeFocus = false
410
343
  blurSignal.value = blurSignal.value + 1
411
344
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
412
345
  imm.hideSoftInputFromWindow(windowToken, 0)
413
346
  clearFocus()
414
- clearFocusProxy()
415
347
  }
416
348
 
417
349
  fun focus() {
@@ -431,7 +363,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
431
363
 
432
364
  viewModel = JetpackComposeViewModel()
433
365
 
434
- addView(focusProxy)
366
+ addView(kbcLayoutHost)
435
367
 
436
368
  composeView = ComposeView(context).also { cv ->
437
369
  cv.layoutParams = LayoutParams(
@@ -483,47 +415,21 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
483
415
  )
484
416
  },
485
417
  onFocus = {
486
- Log.d(TAG, "Compose onFocus id=$id isRestoring=$isRestoringComposeFocus")
487
- if (isRestoringComposeFocus) {
488
- // Second onFocus triggered by focusRequester.requestFocus() inside the restoration
489
- // dance — KBC is being synced via reflection, keyboard is showing.
490
- // Do NOT dispatch FocusEvent again (already sent on first onFocus).
491
- // Do NOT call requestFocusProxy() again (would cause infinite loop).
492
- Log.d(TAG, " → restoration onFocus: clearing flag, skipping proxy request")
493
- isRestoringComposeFocus = false
494
- } else {
495
- val surfaceId = UIManagerHelper.getSurfaceId(context)
496
- val viewId = this@ControlledInputView.id
497
- UIManagerHelper
498
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
499
- ?.dispatchEvent(FocusEvent(surfaceId, viewId))
500
- requestFocusProxy()
501
- }
418
+ Log.d(TAG, "Compose onFocus id=$id")
419
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
420
+ val viewId = this@ControlledInputView.id
421
+ UIManagerHelper
422
+ .getEventDispatcherForReactTag(context as ReactContext, viewId)
423
+ ?.dispatchEvent(FocusEvent(surfaceId, viewId))
424
+ post { syncKeyboardControllerFocusedInput() }
502
425
  },
503
426
  onBlur = {
504
- Log.d(TAG, "Compose onBlur id=$id proxyFocused=${focusProxy.isFocused}")
505
- if (focusProxy.isFocused) {
506
- // Proxy stole Android focus from AndroidComposeView → this blur is synthetic.
507
- // Restore Compose focus so the keyboard stays/re-appears.
508
- // Do NOT dispatch BlurEvent to JS.
509
- Log.d(TAG, " → proxy-caused blur: restoring Compose focus")
510
- post {
511
- // Give AndroidComposeView Android focus back (needed for showSoftInput to work)
512
- composeView.requestFocus()
513
- // Trigger BasicTextField to re-gain Compose focus → shows keyboard → fires onFocus
514
- focusSignal.value = focusSignal.value + 1
515
- }
516
- } else {
517
- // Real blur (user dismissed keyboard / tapped elsewhere)
518
- Log.d(TAG, " → real blur: dispatching BlurEvent")
519
- isRestoringComposeFocus = false
520
- val surfaceId = UIManagerHelper.getSurfaceId(context)
521
- val viewId = this@ControlledInputView.id
522
- UIManagerHelper
523
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
524
- ?.dispatchEvent(BlurEvent(surfaceId, viewId))
525
- clearFocusProxy()
526
- }
427
+ Log.d(TAG, "Compose onBlur id=$id")
428
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
429
+ val viewId = this@ControlledInputView.id
430
+ UIManagerHelper
431
+ .getEventDispatcherForReactTag(context as ReactContext, viewId)
432
+ ?.dispatchEvent(BlurEvent(surfaceId, viewId))
527
433
  },
528
434
  focusRequester = focusRequester
529
435
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-controlled-input",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "React Native controlled TextInput with strict value sync (Fabric)",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",