react-native-controlled-input 0.16.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,7 +2,10 @@ package com.controlledinput
2
2
 
3
3
  import android.content.Context
4
4
  import android.util.AttributeSet
5
+ import android.util.Log
6
+ import android.util.TypedValue
5
7
  import android.view.View
8
+ import android.view.ViewTreeObserver
6
9
  import android.view.inputmethod.InputMethodManager
7
10
  import android.widget.EditText
8
11
  import android.widget.LinearLayout
@@ -34,6 +37,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
34
37
  * @see expo.modules.kotlin.views.ExpoView
35
38
  */
36
39
  class ControlledInputView : LinearLayout, LifecycleOwner {
40
+ companion object {
41
+ private const val TAG = "ControlledInputView"
42
+ }
37
43
  constructor(context: Context) : super(context) {
38
44
  configureComponent(context)
39
45
  }
@@ -60,6 +66,13 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
60
66
  private var usesLocalFallbackLifecycle = false
61
67
  private var windowLifecycleBound = false
62
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
+
63
76
  /**
64
77
  * Invisible EditText that acts as a focus proxy for react-native-keyboard-controller.
65
78
  *
@@ -74,7 +87,9 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
74
87
  private val focusProxy: EditText by lazy {
75
88
  EditText(context).also { proxy ->
76
89
  proxy.layoutParams = LayoutParams(0, 0)
77
- 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
78
93
  proxy.isFocusableInTouchMode = true
79
94
  proxy.showSoftInputOnFocus = false
80
95
  proxy.isClickable = false
@@ -84,11 +99,145 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
84
99
  }
85
100
 
86
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
+
114
+ // Sync textSize so KeyboardControllerSelectionWatcher computes the correct
115
+ // cursor Y via android.text.Layout.getLineBottom() — used as `customHeight`
116
+ // in KeyboardAwareScrollView to determine how far to scroll.
117
+ fontSize?.let {
118
+ focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP, it)
119
+ Log.d(TAG, " proxy textSize set to ${focusProxy.textSize}px (${it}sp)")
120
+ }
121
+
87
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
+
132
+ // setSelection triggers a selection change (lastSelectionStart starts at -1),
133
+ // guaranteeing the watcher fires on the next pre-draw frame even if focus
134
+ // was just transferred.
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, "──────────────────────────")
88
143
  }
89
144
 
90
145
  private fun clearFocusProxy() {
146
+ Log.d(TAG, "clearFocusProxy: proxy.isFocused=${focusProxy.isFocused} hasFocus=${focusProxy.hasFocus()}")
91
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
+ }
92
241
  }
93
242
 
94
243
  private val shouldUseAndroidLayout = true
@@ -98,6 +247,12 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
98
247
  // Force proxy bounds to match ControlledInputView so keyboard-controller reads
99
248
  // the correct width/height/absolutePosition when the proxy is focused.
100
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
+ }
101
256
  }
102
257
 
103
258
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -131,10 +286,15 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
131
286
 
132
287
  override fun onAttachedToWindow() {
133
288
  super.onAttachedToWindow()
289
+ Log.d(TAG, "onAttachedToWindow: id=$id")
290
+ viewTreeObserver.addOnGlobalFocusChangeListener(proxyFocusLostListener)
134
291
  bindComposeToWindowLifecycle()
135
292
  }
136
293
 
137
294
  override fun onDetachedFromWindow() {
295
+ Log.d(TAG, "onDetachedFromWindow: id=$id")
296
+ viewTreeObserver.removeOnGlobalFocusChangeListener(proxyFocusLostListener)
297
+ isRestoringComposeFocus = false
138
298
  if (usesLocalFallbackLifecycle) {
139
299
  lifecycleRegistry.currentState = Lifecycle.State.CREATED
140
300
  }
@@ -179,6 +339,8 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
179
339
  }
180
340
 
181
341
  fun blur() {
342
+ Log.d(TAG, "blur() called from JS ref")
343
+ isRestoringComposeFocus = false
182
344
  blurSignal.value = blurSignal.value + 1
183
345
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
184
346
  imm.hideSoftInputFromWindow(windowToken, 0)
@@ -187,6 +349,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
187
349
  }
188
350
 
189
351
  fun focus() {
352
+ Log.d(TAG, "focus() called from JS ref")
190
353
  focusSignal.value = focusSignal.value + 1
191
354
  }
192
355
 
@@ -254,30 +417,47 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
254
417
  )
255
418
  },
256
419
  onFocus = {
257
- val surfaceId = UIManagerHelper.getSurfaceId(context)
258
- val viewId = this@ControlledInputView.id
259
- UIManagerHelper
260
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
261
- ?.dispatchEvent(
262
- FocusEvent(
263
- surfaceId,
264
- viewId
265
- )
266
- )
267
- 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
+ }
268
436
  },
269
437
  onBlur = {
270
- val surfaceId = UIManagerHelper.getSurfaceId(context)
271
- val viewId = this@ControlledInputView.id
272
- UIManagerHelper
273
- .getEventDispatcherForReactTag(context as ReactContext, viewId)
274
- ?.dispatchEvent(
275
- BlurEvent(
276
- surfaceId,
277
- viewId
278
- )
279
- )
280
- 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
+ }
281
461
  },
282
462
  focusRequester = focusRequester
283
463
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-controlled-input",
3
- "version": "0.16.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,