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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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": [
|
|
149
|
+
"requireBranch": [
|
|
150
|
+
"main",
|
|
151
|
+
"test-keboard--controller"
|
|
152
|
+
]
|
|
146
153
|
},
|
|
147
154
|
"npm": {
|
|
148
155
|
"publish": true,
|