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.
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,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
|
-
|
|
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
|
-
)
|
|
71
|
+
// ExpoComposeView.onMeasure — do not measure ComposeView until attached to a window.
|
|
72
|
+
if (shouldUseAndroidLayout && !isAttachedToWindow) {
|
|
63
73
|
setMeasuredDimension(
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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