react-native-controlled-input 0.17.0 → 0.19.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.
@@ -2,8 +2,10 @@ package com.controlledinput
2
2
 
3
3
  import android.content.Context
4
4
  import android.util.AttributeSet
5
+ import android.util.Log
5
6
  import android.util.TypedValue
6
7
  import android.view.View
8
+ import android.view.ViewTreeObserver
7
9
  import android.view.inputmethod.InputMethodManager
8
10
  import android.widget.EditText
9
11
  import android.widget.LinearLayout
@@ -25,6 +27,7 @@ import androidx.savedstate.SavedStateRegistryOwner
25
27
  import androidx.savedstate.setViewTreeSavedStateRegistryOwner
26
28
  import com.facebook.react.bridge.ReactContext
27
29
  import com.facebook.react.uimanager.UIManagerHelper
30
+ import com.facebook.react.uimanager.events.Event
28
31
  import kotlinx.coroutines.flow.MutableStateFlow
29
32
 
30
33
  /**
@@ -35,6 +38,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
35
38
  * @see expo.modules.kotlin.views.ExpoView
36
39
  */
37
40
  class ControlledInputView : LinearLayout, LifecycleOwner {
41
+ companion object {
42
+ private const val TAG = "ControlledInputView"
43
+ }
38
44
  constructor(context: Context) : super(context) {
39
45
  configureComponent(context)
40
46
  }
@@ -61,6 +67,13 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
61
67
  private var usesLocalFallbackLifecycle = false
62
68
  private var windowLifecycleBound = false
63
69
 
70
+ /**
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.
74
+ */
75
+ private var isRestoringComposeFocus = false
76
+
64
77
  /**
65
78
  * Invisible EditText that acts as a focus proxy for react-native-keyboard-controller.
66
79
  *
@@ -75,7 +88,9 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
75
88
  private val focusProxy: EditText by lazy {
76
89
  EditText(context).also { proxy ->
77
90
  proxy.layoutParams = LayoutParams(0, 0)
78
- proxy.visibility = View.INVISIBLE
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
79
94
  proxy.isFocusableInTouchMode = true
80
95
  proxy.showSoftInputOnFocus = false
81
96
  proxy.isClickable = false
@@ -85,21 +100,210 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
85
100
  }
86
101
 
87
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
+
88
115
  // Sync textSize so KeyboardControllerSelectionWatcher computes the correct
89
116
  // cursor Y via android.text.Layout.getLineBottom() — used as `customHeight`
90
117
  // in KeyboardAwareScrollView to determine how far to scroll.
91
- viewModel.inputStyle.value?.fontSize?.toFloat()?.let { fontSize ->
92
- focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize)
118
+ fontSize?.let {
119
+ focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
120
+ Log.d(TAG, " proxy textSize set to ${focusProxy.textSize}px (${it}sp)")
93
121
  }
122
+
94
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
+
95
133
  // setSelection triggers a selection change (lastSelectionStart starts at -1),
96
134
  // guaranteeing the watcher fires on the next pre-draw frame even if focus
97
135
  // was just transferred.
98
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, "──────────────────────────")
99
144
  }
100
145
 
101
146
  private fun clearFocusProxy() {
147
+ Log.d(TAG, "clearFocusProxy: proxy.isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
102
148
  focusProxy.clearFocus()
149
+ Log.d(TAG, "clearFocusProxy: after clearFocus isFocused=${focusProxy.isFocused}")
150
+ }
151
+
152
+ /**
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.
176
+ * Null if react-native-keyboard-controller is missing or not initialized.
177
+ */
178
+ private fun resolveKbcFocusedInputObserver(): Any? {
179
+ try {
180
+ val registryClass =
181
+ Class.forName("com.reactnativekeyboardcontroller.views.EdgeToEdgeViewRegistry")
182
+ val registryInstance = registryClass.getField("INSTANCE").get(null)
183
+ val edgeToEdgeView =
184
+ registryClass.getDeclaredMethod("get").invoke(registryInstance)
185
+ ?: run {
186
+ Log.w(TAG, "resolveKbcFocusedInputObserver: EdgeToEdgeViewRegistry.get() == null")
187
+ return null
188
+ }
189
+
190
+ val callbackField =
191
+ edgeToEdgeView.javaClass.declaredFields.firstOrNull {
192
+ it.type.simpleName == "KeyboardAnimationCallback"
193
+ }
194
+ ?: run {
195
+ Log.w(TAG, "resolveKbcFocusedInputObserver: KeyboardAnimationCallback field not found")
196
+ return null
197
+ }
198
+ callbackField.isAccessible = true
199
+ val callback =
200
+ callbackField.get(edgeToEdgeView)
201
+ ?: run {
202
+ Log.w(TAG, "resolveKbcFocusedInputObserver: callback == null")
203
+ return null
204
+ }
205
+
206
+ val observerField =
207
+ callback.javaClass.declaredFields.firstOrNull {
208
+ it.type.simpleName == "FocusedInputObserver"
209
+ }
210
+ ?: run {
211
+ Log.w(TAG, "resolveKbcFocusedInputObserver: FocusedInputObserver field not found")
212
+ return null
213
+ }
214
+ observerField.isAccessible = true
215
+ return observerField.get(callback)
216
+ ?: run {
217
+ Log.w(TAG, "resolveKbcFocusedInputObserver: layoutObserver == null")
218
+ null
219
+ }
220
+ } catch (_: ClassNotFoundException) {
221
+ Log.d(TAG, "resolveKbcFocusedInputObserver: keyboard-controller not on classpath")
222
+ return null
223
+ } catch (e: Exception) {
224
+ Log.w(TAG, "resolveKbcFocusedInputObserver: ${e.javaClass.simpleName}: ${e.message}")
225
+ return null
226
+ }
227
+ }
228
+
229
+ /** Approximate one line height in dp for JS customHeight when proxy has no Layout yet. */
230
+ private fun approximateSelectionEndYDp(): Double {
231
+ viewModel.inputStyle.value?.fontSize?.toDouble()?.takeIf { it > 0 }?.let { return it }
232
+ val dm = resources.displayMetrics
233
+ return (focusProxy.textSize / dm.density).toDouble().coerceAtLeast(12.0)
234
+ }
235
+
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
+ private fun dispatchSyntheticKbcSelectionEvent(observer: Any) {
241
+ val reactContext = context as? ReactContext ?: return
242
+ try {
243
+ val epField = observer.javaClass.getDeclaredField("eventPropagationView")
244
+ epField.isAccessible = true
245
+ val propagationId = (epField.get(observer) as View).id
246
+
247
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
248
+ val targetId = id
249
+ val endY = approximateSelectionEndYDp()
250
+
251
+ val dataClz =
252
+ Class.forName("com.reactnativekeyboardcontroller.events.FocusedInputSelectionChangedEventData")
253
+ val dataCtor =
254
+ dataClz.declaredConstructors.singleOrNull { it.parameterTypes.size == 7 }
255
+ ?: run {
256
+ Log.w(TAG, "dispatchSyntheticKbcSelectionEvent: no 7-arg data ctor")
257
+ return
258
+ }
259
+ dataCtor.isAccessible = true
260
+ val data =
261
+ dataCtor.newInstance(targetId, 0.0, 0.0, 0.0, endY, 0, 0)
262
+
263
+ val eventClz =
264
+ Class.forName("com.reactnativekeyboardcontroller.events.FocusedInputSelectionChangedEvent")
265
+ val eventCtor =
266
+ eventClz.getConstructor(
267
+ Int::class.javaPrimitiveType,
268
+ Int::class.javaPrimitiveType,
269
+ dataClz,
270
+ )
271
+ val event = eventCtor.newInstance(surfaceId, propagationId, data) as Event<*>
272
+
273
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, propagationId)
274
+ ?.dispatchEvent(event)
275
+ Log.d(
276
+ TAG,
277
+ "dispatchSyntheticKbcSelectionEvent: propagationId=$propagationId target=$targetId endY(dp)=$endY",
278
+ )
279
+ } catch (e: Exception) {
280
+ Log.w(TAG, "dispatchSyntheticKbcSelectionEvent: ${e.javaClass.simpleName}: ${e.message}")
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Reflection: lastFocusedInput = focusProxy, syncUpLayout(), then synthetic selection
286
+ * so JS gets customHeight (selection.end.y) without waiting for KeyboardControllerSelectionWatcher.
287
+ */
288
+ private fun restoreKbcTracking() {
289
+ Log.d(TAG, "restoreKbcTracking: starting reflection chain")
290
+ try {
291
+ val observer = resolveKbcFocusedInputObserver() ?: return
292
+
293
+ val lastFocusedField = observer.javaClass.getDeclaredField("lastFocusedInput")
294
+ lastFocusedField.isAccessible = true
295
+ lastFocusedField.set(observer, focusProxy)
296
+ Log.d(TAG, "restoreKbcTracking: lastFocusedInput set to focusProxy")
297
+
298
+ val syncMethod = observer.javaClass.getDeclaredMethod("syncUpLayout")
299
+ syncMethod.isAccessible = true
300
+ syncMethod.invoke(observer)
301
+ Log.d(TAG, "restoreKbcTracking: syncUpLayout() invoked")
302
+
303
+ dispatchSyntheticKbcSelectionEvent(observer)
304
+ } catch (e: Exception) {
305
+ Log.w(TAG, "restoreKbcTracking: ${e.javaClass.simpleName}: ${e.message}")
306
+ }
103
307
  }
104
308
 
105
309
  private val shouldUseAndroidLayout = true
@@ -109,6 +313,12 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
109
313
  // Force proxy bounds to match ControlledInputView so keyboard-controller reads
110
314
  // the correct width/height/absolutePosition when the proxy is focused.
111
315
  focusProxy.layout(0, 0, width, height)
316
+ if (changed) {
317
+ val loc = IntArray(2)
318
+ getLocationOnScreen(loc)
319
+ 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
+ }
112
322
  }
113
323
 
114
324
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -142,10 +352,15 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
142
352
 
143
353
  override fun onAttachedToWindow() {
144
354
  super.onAttachedToWindow()
355
+ Log.d(TAG, "onAttachedToWindow: id=$id")
356
+ viewTreeObserver.addOnGlobalFocusChangeListener(proxyFocusLostListener)
145
357
  bindComposeToWindowLifecycle()
146
358
  }
147
359
 
148
360
  override fun onDetachedFromWindow() {
361
+ Log.d(TAG, "onDetachedFromWindow: id=$id")
362
+ viewTreeObserver.removeOnGlobalFocusChangeListener(proxyFocusLostListener)
363
+ isRestoringComposeFocus = false
149
364
  if (usesLocalFallbackLifecycle) {
150
365
  lifecycleRegistry.currentState = Lifecycle.State.CREATED
151
366
  }
@@ -190,6 +405,8 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
190
405
  }
191
406
 
192
407
  fun blur() {
408
+ Log.d(TAG, "blur() called from JS ref")
409
+ isRestoringComposeFocus = false
193
410
  blurSignal.value = blurSignal.value + 1
194
411
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
195
412
  imm.hideSoftInputFromWindow(windowToken, 0)
@@ -198,6 +415,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
198
415
  }
199
416
 
200
417
  fun focus() {
418
+ Log.d(TAG, "focus() called from JS ref")
201
419
  focusSignal.value = focusSignal.value + 1
202
420
  }
203
421
 
@@ -265,30 +483,47 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
265
483
  )
266
484
  },
267
485
  onFocus = {
268
- val surfaceId = UIManagerHelper.getSurfaceId(context)
269
- val viewId = this@ControlledInputView.id
270
- UIManagerHelper
271
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
272
- ?.dispatchEvent(
273
- FocusEvent(
274
- surfaceId,
275
- viewId
276
- )
277
- )
278
- requestFocusProxy()
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
+ }
279
502
  },
280
503
  onBlur = {
281
- val surfaceId = UIManagerHelper.getSurfaceId(context)
282
- val viewId = this@ControlledInputView.id
283
- UIManagerHelper
284
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
285
- ?.dispatchEvent(
286
- BlurEvent(
287
- surfaceId,
288
- viewId
289
- )
290
- )
291
- clearFocusProxy()
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
+ }
292
527
  },
293
528
  focusRequester = focusRequester
294
529
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-controlled-input",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -82,6 +82,7 @@
82
82
  "react": "19.2.0",
83
83
  "react-native": "0.83.0",
84
84
  "react-native-builder-bob": "^0.40.17",
85
+ "react-native-worklets": "0.8.1",
85
86
  "release-it": "^19.0.4",
86
87
  "turbo": "^2.5.6",
87
88
  "typescript": "^5.7.3",
@@ -94,6 +95,9 @@
94
95
  "workspaces": [
95
96
  "example"
96
97
  ],
98
+ "overrides": {
99
+ "react-native-worklets": "0.8.1"
100
+ },
97
101
  "react-native-builder-bob": {
98
102
  "source": "src",
99
103
  "output": "lib",
@@ -142,7 +146,10 @@
142
146
  "git": {
143
147
  "commitMessage": "chore: release ${version}",
144
148
  "tagName": "v${version}",
145
- "requireBranch": ["main", "test-keboard--controller"]
149
+ "requireBranch": [
150
+ "main",
151
+ "test-keboard--controller"
152
+ ]
146
153
  },
147
154
  "npm": {
148
155
  "publish": true,