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.
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
190
|
-
|
|
246
|
+
addView(cv)
|
|
191
247
|
}
|
|
192
248
|
}
|
|
193
|
-
}
|
|
249
|
+
}
|
package/package.json
CHANGED