react-native-controlled-input 0.12.3 → 0.12.4

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,22 @@ 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
+ * RN + Jetpack Compose hosting view aligned with Expo UI / [ExpoComposeView] + [ExpoView]:
30
+ * - [shouldUseAndroidLayout]: requestLayout posts measureAndLayout (RN #17968)
31
+ * - onMeasure skips child [ComposeView] until attached (window + WindowRecomposer)
32
+ *
33
+ * @see expo.modules.kotlin.views.ExpoComposeView
34
+ * @see expo.modules.kotlin.views.ExpoView
35
+ */
23
36
  class ControlledInputView : LinearLayout, LifecycleOwner {
24
37
  constructor(context: Context) : super(context) {
25
38
  configureComponent(context)
@@ -44,92 +57,136 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
44
57
  private val blurSignal = MutableStateFlow(0)
45
58
  private val focusSignal = MutableStateFlow(0)
46
59
  private lateinit var composeView: ComposeView
60
+ private var usesLocalFallbackLifecycle = false
61
+ private var windowLifecycleBound = false
62
+
63
+ /** Same role as ExpoComposeView(withHostingView = true) → ExpoView.shouldUseAndroidLayout */
64
+ private val shouldUseAndroidLayout = true
47
65
 
48
66
  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
- )
67
+ // ExpoComposeView.onMeasure — do not measure ComposeView until attached to a window.
68
+ if (shouldUseAndroidLayout && !isAttachedToWindow) {
63
69
  setMeasuredDimension(
64
- child.measuredWidth + paddingLeft + paddingRight,
65
- child.measuredHeight + paddingTop + paddingBottom
70
+ MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(0),
71
+ MeasureSpec.getSize(heightMeasureSpec).coerceAtLeast(0)
66
72
  )
73
+ return
67
74
  }
75
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
76
+ }
77
+
78
+ /**
79
+ * ExpoView.requestLayout / measureAndLayout — Fabric/Yoga often won't drive Android layout
80
+ * for native children; this mirrors expo-modules-core behavior.
81
+ */
82
+ override fun requestLayout() {
83
+ super.requestLayout()
84
+ if (shouldUseAndroidLayout) {
85
+ post { measureAndLayout() }
86
+ }
87
+ }
88
+
89
+ @UiThread
90
+ private fun measureAndLayout() {
91
+ measure(
92
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
93
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
94
+ )
95
+ layout(left, top, right, bottom)
68
96
  }
69
97
 
70
98
  override fun onAttachedToWindow() {
71
99
  super.onAttachedToWindow()
72
- setViewTreeLifecycleOwner(this)
73
- lifecycleRegistry.currentState = Lifecycle.State.RESUMED
100
+ bindComposeToWindowLifecycle()
74
101
  }
75
102
 
76
103
  override fun onDetachedFromWindow() {
104
+ if (usesLocalFallbackLifecycle) {
105
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
106
+ }
77
107
  super.onDetachedFromWindow()
78
- lifecycleRegistry.currentState = Lifecycle.State.CREATED
108
+ }
109
+
110
+ private fun bindComposeToWindowLifecycle() {
111
+ if (windowLifecycleBound) {
112
+ return
113
+ }
114
+ windowLifecycleBound = true
115
+
116
+ val activity = (context as? ReactContext)?.currentActivity
117
+ val activityOwner = activity as? LifecycleOwner
118
+ if (activityOwner != null) {
119
+ usesLocalFallbackLifecycle = false
120
+ composeView.setViewTreeLifecycleOwner(activityOwner)
121
+ val savedStateOwner = activity as? SavedStateRegistryOwner
122
+ if (savedStateOwner != null) {
123
+ composeView.setViewTreeSavedStateRegistryOwner(savedStateOwner)
124
+ }
125
+ } else {
126
+ findViewTreeLifecycleOwnerFromAncestors()?.let { parentOwner ->
127
+ usesLocalFallbackLifecycle = false
128
+ composeView.setViewTreeLifecycleOwner(parentOwner)
129
+ } ?: run {
130
+ usesLocalFallbackLifecycle = true
131
+ composeView.setViewTreeLifecycleOwner(this)
132
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
133
+ }
134
+ }
135
+ }
136
+
137
+ private fun findViewTreeLifecycleOwnerFromAncestors(): LifecycleOwner? {
138
+ var parent = this.parent as? View ?: return null
139
+ while (true) {
140
+ parent.findViewTreeLifecycleOwner()?.let {
141
+ return it
142
+ }
143
+ parent = parent.parent as? View ?: return null
144
+ }
79
145
  }
80
146
 
81
147
  fun blur() {
82
- // триггерим compose снять фокус
83
148
  blurSignal.value = blurSignal.value + 1
84
-
85
- // на всякий случай прячем клавиатуру на уровне View
86
149
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
87
150
  imm.hideSoftInputFromWindow(windowToken, 0)
88
-
89
- // и снимаем фокус у самого android view (не всегда достаточно, но не мешает)
90
151
  clearFocus()
91
152
  }
92
153
 
93
154
  fun focus() {
94
- // триггерим compose запросить фокус
95
155
  focusSignal.value = focusSignal.value + 1
96
156
  }
97
157
 
98
158
  private fun configureComponent(context: Context) {
99
159
  setBackgroundColor(android.graphics.Color.TRANSPARENT)
160
+ clipChildren = false
161
+ clipToPadding = false
100
162
 
101
163
  layoutParams = LayoutParams(
102
164
  LayoutParams.MATCH_PARENT,
103
165
  LayoutParams.MATCH_PARENT
104
166
  )
105
167
 
106
- composeView = ComposeView(context)
107
- composeView.also { it ->
108
- it.layoutParams = LayoutParams(
168
+ viewModel = JetpackComposeViewModel()
169
+
170
+ composeView = ComposeView(context).also { cv ->
171
+ cv.layoutParams = LayoutParams(
109
172
  LayoutParams.MATCH_PARENT,
110
173
  LayoutParams.MATCH_PARENT
111
174
  )
112
- it.setBackgroundColor(android.graphics.Color.TRANSPARENT)
113
-
114
- it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
115
-
116
- viewModel = JetpackComposeViewModel()
117
-
118
- it.setContent {
175
+ cv.setBackgroundColor(android.graphics.Color.TRANSPARENT)
176
+ cv.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
177
+ cv.setContent {
119
178
  val value = viewModel.value.collectAsState().value
120
179
  val blurTick by blurSignal.collectAsState()
121
180
  val focusTick by focusSignal.collectAsState()
122
181
  val focusManager = LocalFocusManager.current
123
182
  val focusRequester = remember { FocusRequester() }
124
183
 
125
- // при каждом blurTick снимаем фокус в compose
126
184
  LaunchedEffect(blurTick) {
127
185
  if (blurTick > 0) {
128
186
  focusManager.clearFocus(force = true)
129
187
  }
130
188
  }
131
189
 
132
- // при каждом focusTick запрашиваем фокус в compose
133
190
  LaunchedEffect(focusTick) {
134
191
  if (focusTick > 0) {
135
192
  focusRequester.requestFocus()
@@ -148,7 +205,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
148
205
  returnKeyType = viewModel.returnKeyType,
149
206
  onTextChange = { value ->
150
207
  val surfaceId = UIManagerHelper.getSurfaceId(context)
151
- val viewId = this.id
208
+ val viewId = this@ControlledInputView.id
152
209
  UIManagerHelper
153
210
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
154
211
  ?.dispatchEvent(
@@ -161,7 +218,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
161
218
  },
162
219
  onFocus = {
163
220
  val surfaceId = UIManagerHelper.getSurfaceId(context)
164
- val viewId = this.id
221
+ val viewId = this@ControlledInputView.id
165
222
  UIManagerHelper
166
223
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
167
224
  ?.dispatchEvent(
@@ -173,7 +230,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
173
230
  },
174
231
  onBlur = {
175
232
  val surfaceId = UIManagerHelper.getSurfaceId(context)
176
- val viewId = this.id
233
+ val viewId = this@ControlledInputView.id
177
234
  UIManagerHelper
178
235
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
179
236
  ?.dispatchEvent(
@@ -186,8 +243,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
186
243
  focusRequester = focusRequester
187
244
  )
188
245
  }
189
- addView(it)
190
-
246
+ addView(cv)
191
247
  }
192
248
  }
193
- }
249
+ }
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.12.4",
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",