react-native-controlled-input 0.12.3 → 0.13.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.
@@ -89,5 +89,6 @@ dependencies {
89
89
  implementation 'androidx.compose.material3:material3'
90
90
  implementation 'androidx.activity:activity-compose:1.12.2'
91
91
  implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
92
+ implementation 'androidx.savedstate:savedstate-ktx:1.2.1'
92
93
  debugImplementation 'androidx.compose.ui:ui-tooling'
93
94
  }
@@ -2,8 +2,10 @@ package com.controlledinput
2
2
 
3
3
  import android.content.Context
4
4
  import android.util.AttributeSet
5
+ import android.view.View
5
6
  import android.view.inputmethod.InputMethodManager
6
7
  import android.widget.LinearLayout
8
+ import androidx.annotation.UiThread
7
9
  import androidx.compose.runtime.LaunchedEffect
8
10
  import androidx.compose.runtime.collectAsState
9
11
  import androidx.compose.runtime.getValue
@@ -15,11 +17,26 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
15
17
  import androidx.lifecycle.Lifecycle
16
18
  import androidx.lifecycle.LifecycleOwner
17
19
  import androidx.lifecycle.LifecycleRegistry
20
+ import androidx.lifecycle.findViewTreeLifecycleOwner
18
21
  import androidx.lifecycle.setViewTreeLifecycleOwner
22
+ import androidx.savedstate.SavedStateRegistryOwner
23
+ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
19
24
  import com.facebook.react.bridge.ReactContext
20
25
  import com.facebook.react.uimanager.UIManagerHelper
21
26
  import kotlinx.coroutines.flow.MutableStateFlow
22
27
 
28
+ /**
29
+ * Keep this class in package `com.controlledinput` with simple name `ControlledInputView`:
30
+ * apps that patch `react-native-keyboard-controller` locate the host via
31
+ * `Class.name == "com.controlledinput.ControlledInputView"` for Compose (non-EditText) focus.
32
+ *
33
+ * RN + Jetpack Compose hosting view aligned with Expo UI / [ExpoComposeView] + [ExpoView]:
34
+ * - [shouldUseAndroidLayout]: requestLayout posts measureAndLayout (RN #17968)
35
+ * - onMeasure skips child [ComposeView] until attached (window + WindowRecomposer)
36
+ *
37
+ * @see expo.modules.kotlin.views.ExpoComposeView
38
+ * @see expo.modules.kotlin.views.ExpoView
39
+ */
23
40
  class ControlledInputView : LinearLayout, LifecycleOwner {
24
41
  constructor(context: Context) : super(context) {
25
42
  configureComponent(context)
@@ -44,92 +61,136 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
44
61
  private val blurSignal = MutableStateFlow(0)
45
62
  private val focusSignal = MutableStateFlow(0)
46
63
  private lateinit var composeView: ComposeView
64
+ private var usesLocalFallbackLifecycle = false
65
+ private var windowLifecycleBound = false
66
+
67
+ /** Same role as ExpoComposeView(withHostingView = true) → ExpoView.shouldUseAndroidLayout */
68
+ private val shouldUseAndroidLayout = true
47
69
 
48
70
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
49
- if (composeView.isAttachedToWindow) {
50
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
51
- } else {
52
- val width = maxOf(0, MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight)
53
- val height = maxOf(0, MeasureSpec.getSize(heightMeasureSpec) - paddingTop - paddingBottom)
54
- val child = composeView.getChildAt(0)
55
- if (child == null) {
56
- setMeasuredDimension(width, height)
57
- return
58
- }
59
- child.measure(
60
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.getMode(widthMeasureSpec)),
61
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)),
62
- )
71
+ // ExpoComposeView.onMeasure — do not measure ComposeView until attached to a window.
72
+ if (shouldUseAndroidLayout && !isAttachedToWindow) {
63
73
  setMeasuredDimension(
64
- child.measuredWidth + paddingLeft + paddingRight,
65
- child.measuredHeight + paddingTop + paddingBottom
74
+ MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(0),
75
+ MeasureSpec.getSize(heightMeasureSpec).coerceAtLeast(0)
66
76
  )
77
+ return
67
78
  }
79
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
80
+ }
81
+
82
+ /**
83
+ * ExpoView.requestLayout / measureAndLayout — Fabric/Yoga often won't drive Android layout
84
+ * for native children; this mirrors expo-modules-core behavior.
85
+ */
86
+ override fun requestLayout() {
87
+ super.requestLayout()
88
+ if (shouldUseAndroidLayout) {
89
+ post { measureAndLayout() }
90
+ }
91
+ }
92
+
93
+ @UiThread
94
+ private fun measureAndLayout() {
95
+ measure(
96
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
97
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
98
+ )
99
+ layout(left, top, right, bottom)
68
100
  }
69
101
 
70
102
  override fun onAttachedToWindow() {
71
103
  super.onAttachedToWindow()
72
- setViewTreeLifecycleOwner(this)
73
- lifecycleRegistry.currentState = Lifecycle.State.RESUMED
104
+ bindComposeToWindowLifecycle()
74
105
  }
75
106
 
76
107
  override fun onDetachedFromWindow() {
108
+ if (usesLocalFallbackLifecycle) {
109
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
110
+ }
77
111
  super.onDetachedFromWindow()
78
- lifecycleRegistry.currentState = Lifecycle.State.CREATED
112
+ }
113
+
114
+ private fun bindComposeToWindowLifecycle() {
115
+ if (windowLifecycleBound) {
116
+ return
117
+ }
118
+ windowLifecycleBound = true
119
+
120
+ val activity = (context as? ReactContext)?.currentActivity
121
+ val activityOwner = activity as? LifecycleOwner
122
+ if (activityOwner != null) {
123
+ usesLocalFallbackLifecycle = false
124
+ composeView.setViewTreeLifecycleOwner(activityOwner)
125
+ val savedStateOwner = activity as? SavedStateRegistryOwner
126
+ if (savedStateOwner != null) {
127
+ composeView.setViewTreeSavedStateRegistryOwner(savedStateOwner)
128
+ }
129
+ } else {
130
+ findViewTreeLifecycleOwnerFromAncestors()?.let { parentOwner ->
131
+ usesLocalFallbackLifecycle = false
132
+ composeView.setViewTreeLifecycleOwner(parentOwner)
133
+ } ?: run {
134
+ usesLocalFallbackLifecycle = true
135
+ composeView.setViewTreeLifecycleOwner(this)
136
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
137
+ }
138
+ }
139
+ }
140
+
141
+ private fun findViewTreeLifecycleOwnerFromAncestors(): LifecycleOwner? {
142
+ var parent = this.parent as? View ?: return null
143
+ while (true) {
144
+ parent.findViewTreeLifecycleOwner()?.let {
145
+ return it
146
+ }
147
+ parent = parent.parent as? View ?: return null
148
+ }
79
149
  }
80
150
 
81
151
  fun blur() {
82
- // триггерим compose снять фокус
83
152
  blurSignal.value = blurSignal.value + 1
84
-
85
- // на всякий случай прячем клавиатуру на уровне View
86
153
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
87
154
  imm.hideSoftInputFromWindow(windowToken, 0)
88
-
89
- // и снимаем фокус у самого android view (не всегда достаточно, но не мешает)
90
155
  clearFocus()
91
156
  }
92
157
 
93
158
  fun focus() {
94
- // триггерим compose запросить фокус
95
159
  focusSignal.value = focusSignal.value + 1
96
160
  }
97
161
 
98
162
  private fun configureComponent(context: Context) {
99
163
  setBackgroundColor(android.graphics.Color.TRANSPARENT)
164
+ clipChildren = false
165
+ clipToPadding = false
100
166
 
101
167
  layoutParams = LayoutParams(
102
168
  LayoutParams.MATCH_PARENT,
103
169
  LayoutParams.MATCH_PARENT
104
170
  )
105
171
 
106
- composeView = ComposeView(context)
107
- composeView.also { it ->
108
- it.layoutParams = LayoutParams(
172
+ viewModel = JetpackComposeViewModel()
173
+
174
+ composeView = ComposeView(context).also { cv ->
175
+ cv.layoutParams = LayoutParams(
109
176
  LayoutParams.MATCH_PARENT,
110
177
  LayoutParams.MATCH_PARENT
111
178
  )
112
- it.setBackgroundColor(android.graphics.Color.TRANSPARENT)
113
-
114
- it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
115
-
116
- viewModel = JetpackComposeViewModel()
117
-
118
- it.setContent {
179
+ cv.setBackgroundColor(android.graphics.Color.TRANSPARENT)
180
+ cv.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
181
+ cv.setContent {
119
182
  val value = viewModel.value.collectAsState().value
120
183
  val blurTick by blurSignal.collectAsState()
121
184
  val focusTick by focusSignal.collectAsState()
122
185
  val focusManager = LocalFocusManager.current
123
186
  val focusRequester = remember { FocusRequester() }
124
187
 
125
- // при каждом blurTick снимаем фокус в compose
126
188
  LaunchedEffect(blurTick) {
127
189
  if (blurTick > 0) {
128
190
  focusManager.clearFocus(force = true)
129
191
  }
130
192
  }
131
193
 
132
- // при каждом focusTick запрашиваем фокус в compose
133
194
  LaunchedEffect(focusTick) {
134
195
  if (focusTick > 0) {
135
196
  focusRequester.requestFocus()
@@ -148,7 +209,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
148
209
  returnKeyType = viewModel.returnKeyType,
149
210
  onTextChange = { value ->
150
211
  val surfaceId = UIManagerHelper.getSurfaceId(context)
151
- val viewId = this.id
212
+ val viewId = this@ControlledInputView.id
152
213
  UIManagerHelper
153
214
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
154
215
  ?.dispatchEvent(
@@ -161,7 +222,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
161
222
  },
162
223
  onFocus = {
163
224
  val surfaceId = UIManagerHelper.getSurfaceId(context)
164
- val viewId = this.id
225
+ val viewId = this@ControlledInputView.id
165
226
  UIManagerHelper
166
227
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
167
228
  ?.dispatchEvent(
@@ -173,7 +234,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
173
234
  },
174
235
  onBlur = {
175
236
  val surfaceId = UIManagerHelper.getSurfaceId(context)
176
- val viewId = this.id
237
+ val viewId = this@ControlledInputView.id
177
238
  UIManagerHelper
178
239
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
179
240
  ?.dispatchEvent(
@@ -186,8 +247,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
186
247
  focusRequester = focusRequester
187
248
  )
188
249
  }
189
- addView(it)
190
-
250
+ addView(cv)
191
251
  }
192
252
  }
193
- }
253
+ }
@@ -28,6 +28,15 @@ using namespace facebook::react;
28
28
  return concreteComponentDescriptorProvider<ControlledInputViewComponentDescriptor>();
29
29
  }
30
30
 
31
+ /// `react-native-keyboard-controller` resolves `UIResponder.reactViewTag` as
32
+ /// `(firstResponder as UIView).superview.tag`. The field's superview is this content view,
33
+ /// so its `tag` must match the Fabric component's react tag or keyboard events disagree
34
+ /// on `target` and `KeyboardAwareScrollView` mis-animates (iOS hide jumps).
35
+ - (void)syncContentViewReactTagFromContainer
36
+ {
37
+ _inputView.tag = self.tag;
38
+ }
39
+
31
40
  - (instancetype)initWithFrame:(CGRect)frame
32
41
  {
33
42
  if (self = [super initWithFrame:frame]) {
@@ -38,11 +47,18 @@ using namespace facebook::react;
38
47
  _inputView.delegate = self;
39
48
 
40
49
  self.contentView = _inputView;
50
+ [self syncContentViewReactTagFromContainer];
41
51
  }
42
52
 
43
53
  return self;
44
54
  }
45
55
 
56
+ - (void)layoutSubviews
57
+ {
58
+ [super layoutSubviews];
59
+ [self syncContentViewReactTagFromContainer];
60
+ }
61
+
46
62
  - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
47
63
  {
48
64
  const auto &oldViewProps = *std::static_pointer_cast<ControlledInputViewProps const>(_props);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-controlled-input",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "React Native Controlled Inputative Controlled Input",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",