react-native-controlled-input 0.17.0 → 0.18.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
@@ -35,6 +37,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
35
37
  * @see expo.modules.kotlin.views.ExpoView
36
38
  */
37
39
  class ControlledInputView : LinearLayout, LifecycleOwner {
40
+ companion object {
41
+ private const val TAG = "ControlledInputView"
42
+ }
38
43
  constructor(context: Context) : super(context) {
39
44
  configureComponent(context)
40
45
  }
@@ -61,6 +66,13 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
61
66
  private var usesLocalFallbackLifecycle = false
62
67
  private var windowLifecycleBound = false
63
68
 
69
+ /**
70
+ * True while we are in the focus-restoration dance:
71
+ * proxy stole Android focus from Compose → onBlur fired → we're restoring Compose focus.
72
+ * During this window the second onFocus must NOT call requestFocusProxy() again.
73
+ */
74
+ private var isRestoringComposeFocus = false
75
+
64
76
  /**
65
77
  * Invisible EditText that acts as a focus proxy for react-native-keyboard-controller.
66
78
  *
@@ -75,7 +87,9 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
75
87
  private val focusProxy: EditText by lazy {
76
88
  EditText(context).also { proxy ->
77
89
  proxy.layoutParams = LayoutParams(0, 0)
78
- proxy.visibility = View.INVISIBLE
90
+ // Must stay VISIBLE — Android's canTakeFocus() returns false for INVISIBLE/GONE views,
91
+ // causing requestFocus() to silently fail. Use alpha=0 to hide it visually instead.
92
+ proxy.alpha = 0f
79
93
  proxy.isFocusableInTouchMode = true
80
94
  proxy.showSoftInputOnFocus = false
81
95
  proxy.isClickable = false
@@ -85,21 +99,145 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
85
99
  }
86
100
 
87
101
  private fun requestFocusProxy() {
102
+ val fontSize = viewModel.inputStyle.value?.fontSize?.toFloat()
103
+ Log.d(TAG, "──── requestFocusProxy ────")
104
+ Log.d(TAG, " fontSize=$fontSize")
105
+ Log.d(TAG, " proxy before: isFocused=${focusProxy.isFocused} size=${focusProxy.width}x${focusProxy.height}")
106
+
107
+ // Sync proxy ID with ControlledInputView's React Native tag BEFORE requestFocus(),
108
+ // so KeyboardAnimationCallback.focusListener sets viewTagFocused = this.id (not -1).
109
+ // Without this, KeyboardAwareScrollView JS sees e.target=-1 → focusWasChanged=false
110
+ // → layout.value never updated → maybeScroll() always returns 0 → no scroll.
111
+ focusProxy.id = this.id
112
+ Log.d(TAG, " proxy.id set to ${focusProxy.id} (= ControlledInputView RN tag)")
113
+
88
114
  // Sync textSize so KeyboardControllerSelectionWatcher computes the correct
89
115
  // cursor Y via android.text.Layout.getLineBottom() — used as `customHeight`
90
116
  // in KeyboardAwareScrollView to determine how far to scroll.
91
- viewModel.inputStyle.value?.fontSize?.toFloat()?.let { fontSize ->
92
- focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize)
117
+ fontSize?.let {
118
+ focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
119
+ Log.d(TAG, " proxy textSize set to ${focusProxy.textSize}px (${it}sp)")
93
120
  }
121
+
94
122
  focusProxy.requestFocus()
123
+ Log.d(TAG, " proxy after requestFocus: isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
124
+
125
+ if (focusProxy.isFocused) {
126
+ // Proxy stole Android focus from AndroidComposeView → Compose will fire onBlur.
127
+ // Mark restoration mode so onBlur knows to recover (not dispatch BlurEvent to JS).
128
+ isRestoringComposeFocus = true
129
+ Log.d(TAG, " isRestoringComposeFocus=true (proxy has Android focus)")
130
+ }
131
+
95
132
  // setSelection triggers a selection change (lastSelectionStart starts at -1),
96
133
  // guaranteeing the watcher fires on the next pre-draw frame even if focus
97
134
  // was just transferred.
98
135
  focusProxy.setSelection(0)
136
+ Log.d(TAG, " proxy selectionStart=${focusProxy.selectionStart} layout=${focusProxy.layout != null}")
137
+
138
+ // Log absolute screen position — this is what keyboard-controller reads for scroll calculation
139
+ val loc = IntArray(2)
140
+ focusProxy.getLocationOnScreen(loc)
141
+ Log.d(TAG, " proxy screenLocation x=${loc[0]} y=${loc[1]} → absoluteY for KBC=${loc[1]}px")
142
+ Log.d(TAG, "──────────────────────────")
99
143
  }
100
144
 
101
145
  private fun clearFocusProxy() {
146
+ Log.d(TAG, "clearFocusProxy: proxy.isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
102
147
  focusProxy.clearFocus()
148
+ Log.d(TAG, "clearFocusProxy: after clearFocus isFocused=${focusProxy.isFocused}")
149
+ }
150
+
151
+ /**
152
+ * Fires when the proxy loses Android focus (i.e. after composeView.requestFocus() restores
153
+ * Compose). At this point KBC's listener has already cleared lastFocusedInput (KBC registered
154
+ * its ViewTreeObserver listener before us → it fires first). We post restoreKbcTracking() to
155
+ * run after all synchronous focus-change handlers complete.
156
+ */
157
+ private val proxyFocusLostListener =
158
+ ViewTreeObserver.OnGlobalFocusChangeListener { oldFocus, newFocus ->
159
+ // Fire for ANY proxy focus loss while we're in the restoration dance.
160
+ // newFocus can be null (proxy→null→AndroidComposeView, two steps on first focus)
161
+ // or AndroidComposeView directly (proxy→AndroidComposeView, subsequent focuses).
162
+ // Both cases need restoreKbcTracking(); KBC's null→AndroidComposeView transition does
163
+ // NOT clear lastFocusedInput, so restoring it once is enough for both paths.
164
+ if (oldFocus == focusProxy && isRestoringComposeFocus) {
165
+ Log.d(
166
+ TAG,
167
+ "proxyFocusLostListener: proxy → ${newFocus?.javaClass?.simpleName ?: "null"}, posting restoreKbcTracking()",
168
+ )
169
+ post { restoreKbcTracking() }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Uses reflection to restore KBC's lastFocusedInput = focusProxy and call syncUpLayout(),
175
+ * so that KeyboardAwareScrollView receives the correct absoluteY / height to scroll to.
176
+ *
177
+ * Chain: EdgeToEdgeViewRegistry.get() → .callback → .layoutObserver → .lastFocusedInput / .syncUpLayout()
178
+ */
179
+ private fun restoreKbcTracking() {
180
+ Log.d(TAG, "restoreKbcTracking: starting reflection chain")
181
+ try {
182
+ // 1. EdgeToEdgeViewRegistry is a Kotlin object — access via INSTANCE field
183
+ val registryClass =
184
+ Class.forName("com.reactnativekeyboardcontroller.views.EdgeToEdgeViewRegistry")
185
+ val registryInstance = registryClass.getField("INSTANCE").get(null)
186
+ val edgeToEdgeView =
187
+ registryClass.getDeclaredMethod("get").invoke(registryInstance)
188
+ ?: run {
189
+ Log.w(TAG, "restoreKbcTracking: EdgeToEdgeViewRegistry.get() == null")
190
+ return
191
+ }
192
+
193
+ // 2. callback: KeyboardAnimationCallback (internal var — find by type)
194
+ val callbackField =
195
+ edgeToEdgeView.javaClass.declaredFields.firstOrNull {
196
+ it.type.simpleName == "KeyboardAnimationCallback"
197
+ }
198
+ ?: run {
199
+ Log.w(TAG, "restoreKbcTracking: KeyboardAnimationCallback field not found")
200
+ return
201
+ }
202
+ callbackField.isAccessible = true
203
+ val callback =
204
+ callbackField.get(edgeToEdgeView)
205
+ ?: run {
206
+ Log.w(TAG, "restoreKbcTracking: callback == null")
207
+ return
208
+ }
209
+
210
+ // 3. layoutObserver: FocusedInputObserver (internal var — find by type)
211
+ val observerField =
212
+ callback.javaClass.declaredFields.firstOrNull {
213
+ it.type.simpleName == "FocusedInputObserver"
214
+ }
215
+ ?: run {
216
+ Log.w(TAG, "restoreKbcTracking: FocusedInputObserver field not found")
217
+ return
218
+ }
219
+ observerField.isAccessible = true
220
+ val observer =
221
+ observerField.get(callback)
222
+ ?: run {
223
+ Log.w(TAG, "restoreKbcTracking: layoutObserver == null")
224
+ return
225
+ }
226
+
227
+ // 4. Set lastFocusedInput = focusProxy (private var)
228
+ val lastFocusedField = observer.javaClass.getDeclaredField("lastFocusedInput")
229
+ lastFocusedField.isAccessible = true
230
+ lastFocusedField.set(observer, focusProxy)
231
+ Log.d(TAG, "restoreKbcTracking: lastFocusedInput set to focusProxy")
232
+
233
+ // 5. Call syncUpLayout() — public fun, dispatches FocusedInputLayoutChangedEvent to JS
234
+ val syncMethod = observer.javaClass.getDeclaredMethod("syncUpLayout")
235
+ syncMethod.isAccessible = true
236
+ syncMethod.invoke(observer)
237
+ Log.d(TAG, "restoreKbcTracking: ✅ syncUpLayout() invoked — KBC layout event sent to JS")
238
+ } catch (e: Exception) {
239
+ Log.w(TAG, "restoreKbcTracking: ❌ ${e.javaClass.simpleName}: ${e.message}")
240
+ }
103
241
  }
104
242
 
105
243
  private val shouldUseAndroidLayout = true
@@ -109,6 +247,12 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
109
247
  // Force proxy bounds to match ControlledInputView so keyboard-controller reads
110
248
  // the correct width/height/absolutePosition when the proxy is focused.
111
249
  focusProxy.layout(0, 0, width, height)
250
+ if (changed) {
251
+ val loc = IntArray(2)
252
+ getLocationOnScreen(loc)
253
+ Log.d(TAG, "onLayout: view=${width}x${height} screenX=${loc[0]} screenY=${loc[1]}")
254
+ Log.d(TAG, "onLayout: proxy=${focusProxy.width}x${focusProxy.height} (should match view)")
255
+ }
112
256
  }
113
257
 
114
258
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -142,10 +286,15 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
142
286
 
143
287
  override fun onAttachedToWindow() {
144
288
  super.onAttachedToWindow()
289
+ Log.d(TAG, "onAttachedToWindow: id=$id")
290
+ viewTreeObserver.addOnGlobalFocusChangeListener(proxyFocusLostListener)
145
291
  bindComposeToWindowLifecycle()
146
292
  }
147
293
 
148
294
  override fun onDetachedFromWindow() {
295
+ Log.d(TAG, "onDetachedFromWindow: id=$id")
296
+ viewTreeObserver.removeOnGlobalFocusChangeListener(proxyFocusLostListener)
297
+ isRestoringComposeFocus = false
149
298
  if (usesLocalFallbackLifecycle) {
150
299
  lifecycleRegistry.currentState = Lifecycle.State.CREATED
151
300
  }
@@ -190,6 +339,8 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
190
339
  }
191
340
 
192
341
  fun blur() {
342
+ Log.d(TAG, "blur() called from JS ref")
343
+ isRestoringComposeFocus = false
193
344
  blurSignal.value = blurSignal.value + 1
194
345
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
195
346
  imm.hideSoftInputFromWindow(windowToken, 0)
@@ -198,6 +349,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
198
349
  }
199
350
 
200
351
  fun focus() {
352
+ Log.d(TAG, "focus() called from JS ref")
201
353
  focusSignal.value = focusSignal.value + 1
202
354
  }
203
355
 
@@ -265,30 +417,47 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
265
417
  )
266
418
  },
267
419
  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()
420
+ Log.d(TAG, "━━ Compose onFocus ━━ id=$id isRestoring=$isRestoringComposeFocus")
421
+ if (isRestoringComposeFocus) {
422
+ // Second onFocus triggered by focusRequester.requestFocus() inside the restoration
423
+ // dance — KBC is being synced via reflection, keyboard is showing.
424
+ // Do NOT dispatch FocusEvent again (already sent on first onFocus).
425
+ // Do NOT call requestFocusProxy() again (would cause infinite loop).
426
+ Log.d(TAG, " → restoration onFocus: clearing flag, skipping proxy request")
427
+ isRestoringComposeFocus = false
428
+ } else {
429
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
430
+ val viewId = this@ControlledInputView.id
431
+ UIManagerHelper
432
+ .getEventDispatcherForReactTag(context as ReactContext, viewId)
433
+ ?.dispatchEvent(FocusEvent(surfaceId, viewId))
434
+ requestFocusProxy()
435
+ }
279
436
  },
280
437
  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()
438
+ Log.d(TAG, "━━ Compose onBlur ━━ id=$id proxyFocused=${focusProxy.isFocused}")
439
+ if (focusProxy.isFocused) {
440
+ // Proxy stole Android focus from AndroidComposeView → this blur is synthetic.
441
+ // Restore Compose focus so the keyboard stays/re-appears.
442
+ // Do NOT dispatch BlurEvent to JS.
443
+ Log.d(TAG, " → proxy-caused blur: restoring Compose focus")
444
+ post {
445
+ // Give AndroidComposeView Android focus back (needed for showSoftInput to work)
446
+ composeView.requestFocus()
447
+ // Trigger BasicTextField to re-gain Compose focus → shows keyboard → fires onFocus
448
+ focusSignal.value = focusSignal.value + 1
449
+ }
450
+ } else {
451
+ // Real blur (user dismissed keyboard / tapped elsewhere)
452
+ Log.d(TAG, " → real blur: dispatching BlurEvent")
453
+ isRestoringComposeFocus = false
454
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
455
+ val viewId = this@ControlledInputView.id
456
+ UIManagerHelper
457
+ .getEventDispatcherForReactTag(context as ReactContext, viewId)
458
+ ?.dispatchEvent(BlurEvent(surfaceId, viewId))
459
+ clearFocusProxy()
460
+ }
292
461
  },
293
462
  focusRequester = focusRequester
294
463
  )
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.18.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,