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
|
-
|
|
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
|
-
|
|
92
|
-
focusProxy.setTextSize(TypedValue.COMPLEX_UNIT_SP,
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
)
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
)
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
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,
|