react-native-controlled-input 0.12.2 → 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)
@@ -43,69 +56,137 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
43
56
  internal lateinit var viewModel: JetpackComposeViewModel
44
57
  private val blurSignal = MutableStateFlow(0)
45
58
  private val focusSignal = MutableStateFlow(0)
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
65
+
66
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
67
+ // ExpoComposeView.onMeasure — do not measure ComposeView until attached to a window.
68
+ if (shouldUseAndroidLayout && !isAttachedToWindow) {
69
+ setMeasuredDimension(
70
+ MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(0),
71
+ MeasureSpec.getSize(heightMeasureSpec).coerceAtLeast(0)
72
+ )
73
+ return
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)
96
+ }
46
97
 
47
98
  override fun onAttachedToWindow() {
48
- setViewTreeLifecycleOwner(this)
49
- lifecycleRegistry.currentState = Lifecycle.State.RESUMED
50
99
  super.onAttachedToWindow()
100
+ bindComposeToWindowLifecycle()
51
101
  }
52
102
 
53
103
  override fun onDetachedFromWindow() {
104
+ if (usesLocalFallbackLifecycle) {
105
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
106
+ }
54
107
  super.onDetachedFromWindow()
55
- 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
+ }
56
145
  }
57
146
 
58
147
  fun blur() {
59
- // триггерим compose снять фокус
60
148
  blurSignal.value = blurSignal.value + 1
61
-
62
- // на всякий случай прячем клавиатуру на уровне View
63
149
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
64
150
  imm.hideSoftInputFromWindow(windowToken, 0)
65
-
66
- // и снимаем фокус у самого android view (не всегда достаточно, но не мешает)
67
151
  clearFocus()
68
152
  }
69
153
 
70
154
  fun focus() {
71
- // триггерим compose запросить фокус
72
155
  focusSignal.value = focusSignal.value + 1
73
156
  }
74
157
 
75
158
  private fun configureComponent(context: Context) {
76
159
  setBackgroundColor(android.graphics.Color.TRANSPARENT)
160
+ clipChildren = false
161
+ clipToPadding = false
77
162
 
78
163
  layoutParams = LayoutParams(
79
164
  LayoutParams.MATCH_PARENT,
80
165
  LayoutParams.MATCH_PARENT
81
166
  )
82
167
 
83
- ComposeView(context).also {
84
- it.layoutParams = LayoutParams(
168
+ viewModel = JetpackComposeViewModel()
169
+
170
+ composeView = ComposeView(context).also { cv ->
171
+ cv.layoutParams = LayoutParams(
85
172
  LayoutParams.MATCH_PARENT,
86
173
  LayoutParams.MATCH_PARENT
87
174
  )
88
- it.setBackgroundColor(android.graphics.Color.TRANSPARENT)
89
-
90
- it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
91
-
92
- viewModel = JetpackComposeViewModel()
93
-
94
- it.setContent {
175
+ cv.setBackgroundColor(android.graphics.Color.TRANSPARENT)
176
+ cv.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
177
+ cv.setContent {
95
178
  val value = viewModel.value.collectAsState().value
96
179
  val blurTick by blurSignal.collectAsState()
97
180
  val focusTick by focusSignal.collectAsState()
98
181
  val focusManager = LocalFocusManager.current
99
182
  val focusRequester = remember { FocusRequester() }
100
183
 
101
- // при каждом blurTick снимаем фокус в compose
102
184
  LaunchedEffect(blurTick) {
103
185
  if (blurTick > 0) {
104
186
  focusManager.clearFocus(force = true)
105
187
  }
106
188
  }
107
189
 
108
- // при каждом focusTick запрашиваем фокус в compose
109
190
  LaunchedEffect(focusTick) {
110
191
  if (focusTick > 0) {
111
192
  focusRequester.requestFocus()
@@ -124,7 +205,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
124
205
  returnKeyType = viewModel.returnKeyType,
125
206
  onTextChange = { value ->
126
207
  val surfaceId = UIManagerHelper.getSurfaceId(context)
127
- val viewId = this.id
208
+ val viewId = this@ControlledInputView.id
128
209
  UIManagerHelper
129
210
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
130
211
  ?.dispatchEvent(
@@ -137,7 +218,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
137
218
  },
138
219
  onFocus = {
139
220
  val surfaceId = UIManagerHelper.getSurfaceId(context)
140
- val viewId = this.id
221
+ val viewId = this@ControlledInputView.id
141
222
  UIManagerHelper
142
223
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
143
224
  ?.dispatchEvent(
@@ -149,7 +230,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
149
230
  },
150
231
  onBlur = {
151
232
  val surfaceId = UIManagerHelper.getSurfaceId(context)
152
- val viewId = this.id
233
+ val viewId = this@ControlledInputView.id
153
234
  UIManagerHelper
154
235
  .getEventDispatcherForReactTag(context as ReactContext, viewId)
155
236
  ?.dispatchEvent(
@@ -162,8 +243,7 @@ class ControlledInputView : LinearLayout, LifecycleOwner {
162
243
  focusRequester = focusRequester
163
244
  )
164
245
  }
165
- addView(it)
166
-
246
+ addView(cv)
167
247
  }
168
248
  }
169
- }
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-controlled-input",
3
- "version": "0.12.2",
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",