react-native-platform-components 0.5.3 → 0.5.5
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/README.md +13 -8
- package/android/build.gradle +3 -1
- package/android/src/main/java/com/platformcomponents/PCConstants.kt +3 -0
- package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +53 -1
- package/android/src/main/java/com/platformcomponents/PCDatePickerViewManager.kt +14 -0
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +169 -10
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuViewManager.kt +14 -0
- package/android/src/main/jni/CMakeLists.txt +47 -0
- package/android/src/main/jni/OnLoad.cpp +33 -0
- package/ios/PCDatePickerView.swift +58 -2
- package/ios/PCSelectionMenu.mm +42 -0
- package/ios/PCSelectionMenu.swift +17 -0
- package/lib/module/SelectionMenu.js +7 -14
- package/lib/module/SelectionMenu.js.map +1 -1
- package/lib/typescript/src/SelectionMenu.d.ts +6 -5
- package/lib/typescript/src/SelectionMenu.d.ts.map +1 -1
- package/lib/typescript/src/sharedTypes.d.ts +3 -1
- package/lib/typescript/src/sharedTypes.d.ts.map +1 -1
- package/package.json +3 -2
- package/react-native.config.js +13 -0
- package/shared/PCDatePickerComponentDescriptors-custom.h +14 -43
- package/shared/PCDatePickerShadowNode-custom.cpp +35 -0
- package/shared/PCDatePickerShadowNode-custom.h +40 -18
- package/shared/PCDatePickerState-custom.h +53 -1
- package/shared/PCSelectionMenuComponentDescriptors-custom.h +15 -18
- package/shared/PCSelectionMenuShadowNode-custom.cpp +42 -21
- package/shared/PCSelectionMenuShadowNode-custom.h +23 -10
- package/shared/PCSelectionMenuState-custom.h +65 -0
- package/shared/README.md +179 -0
- package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +9 -0
- package/src/SelectionMenu.tsx +15 -24
- package/src/sharedTypes.ts +4 -1
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ export function Example() {
|
|
|
125
125
|
<SelectionMenu
|
|
126
126
|
options={options}
|
|
127
127
|
selected={value}
|
|
128
|
-
|
|
128
|
+
presentation="embedded"
|
|
129
129
|
placeholder="Select fruit"
|
|
130
130
|
onSelect={(data) => setValue(data)}
|
|
131
131
|
android={{ material: 'm3' }}
|
|
@@ -153,11 +153,14 @@ export function Example() {
|
|
|
153
153
|
date={date}
|
|
154
154
|
visible={visible}
|
|
155
155
|
presentation="modal"
|
|
156
|
+
mode="date"
|
|
156
157
|
onConfirm={(d) => {
|
|
157
158
|
setDate(d);
|
|
158
159
|
setVisible(false);
|
|
159
160
|
}}
|
|
160
161
|
onClosed={() => setVisible(false)}
|
|
162
|
+
ios={{preferredStyle: 'inline'}}
|
|
163
|
+
android={{material: 'system'}}
|
|
161
164
|
/>
|
|
162
165
|
</>
|
|
163
166
|
);
|
|
@@ -176,8 +179,10 @@ export function Example() {
|
|
|
176
179
|
<DatePicker
|
|
177
180
|
date={date}
|
|
178
181
|
presentation="embedded"
|
|
179
|
-
mode="
|
|
182
|
+
mode="date"
|
|
180
183
|
onConfirm={(d) => setDate(d)}
|
|
184
|
+
ios={{preferredStyle: 'inline'}}
|
|
185
|
+
android={{material: 'system'}}
|
|
181
186
|
/>
|
|
182
187
|
);
|
|
183
188
|
}
|
|
@@ -189,7 +194,7 @@ export function Example() {
|
|
|
189
194
|
|
|
190
195
|
## SelectionMenu
|
|
191
196
|
|
|
192
|
-
Native selection menu with **
|
|
197
|
+
Native selection menu with **modal** and **embedded** modes.
|
|
193
198
|
|
|
194
199
|
### Props
|
|
195
200
|
|
|
@@ -199,18 +204,18 @@ Native selection menu with **inline** and **headless** modes.
|
|
|
199
204
|
| `selected` | `string \| null` | Currently selected option's `data` value |
|
|
200
205
|
| `disabled` | `boolean` | Disables the menu |
|
|
201
206
|
| `placeholder` | `string` | Placeholder text when no selection |
|
|
202
|
-
| `
|
|
203
|
-
| `visible` | `boolean` | Controls
|
|
207
|
+
| `presentation` | `'modal' \| 'embedded'` | Presentation mode (default: `'modal'`) |
|
|
208
|
+
| `visible` | `boolean` | Controls modal mode menu visibility |
|
|
204
209
|
| `onSelect` | `(data, label, index) => void` | Called when user selects an option |
|
|
205
210
|
| `onRequestClose` | `() => void` | Called when menu is dismissed without selection |
|
|
206
211
|
| `android.material` | `'system' \| 'm3'` | Material Design style preference |
|
|
207
212
|
|
|
208
213
|
### Modes
|
|
209
214
|
|
|
210
|
-
- **
|
|
211
|
-
- **
|
|
215
|
+
- **Modal mode** (default): Menu visibility controlled by `visible` prop. Use for custom trigger UI.
|
|
216
|
+
- **Embedded mode** (`presentation="embedded"`): Native picker UI rendered inline. Menu managed internally.
|
|
212
217
|
|
|
213
|
-
> **Note:** On iOS,
|
|
218
|
+
> **Note:** On iOS, modal mode uses a custom popover to enable programmatic presentation. For the full native menu experience (system animations, scroll physics), use embedded mode. This is an intentional trade-off: modal gives you control over the trigger UI, embedded gives you the complete system menu behavior.
|
|
214
219
|
|
|
215
220
|
---
|
|
216
221
|
|
package/android/build.gradle
CHANGED
|
@@ -27,16 +27,18 @@ def getExtOrIntegerDefault(name) {
|
|
|
27
27
|
|
|
28
28
|
android {
|
|
29
29
|
namespace "com.platformcomponents"
|
|
30
|
-
|
|
30
|
+
ndkVersion rootProject.ext.has("ndkVersion") ? rootProject.ext.get("ndkVersion") : "26.1.10909125"
|
|
31
31
|
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
|
|
32
32
|
|
|
33
33
|
defaultConfig {
|
|
34
34
|
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
35
35
|
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
36
|
+
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
buildFeatures {
|
|
39
40
|
buildConfig true
|
|
41
|
+
prefab true
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
buildTypes {
|
|
@@ -10,6 +10,9 @@ object PCConstants {
|
|
|
10
10
|
/** Minimum touch target height in dp (Material Design 3 recommends 48dp, we use 56dp for text fields) */
|
|
11
11
|
const val MIN_TOUCH_TARGET_HEIGHT_DP = 56f
|
|
12
12
|
|
|
13
|
+
/** Minimum height for M3 TextInputLayout with floating label (needs extra space for hint + text) */
|
|
14
|
+
const val MIN_M3_TEXT_INPUT_HEIGHT_DP = 72f
|
|
15
|
+
|
|
13
16
|
// MARK: - Fallback Dimensions
|
|
14
17
|
|
|
15
18
|
/** Fallback width in dp when constraint width is unavailable */
|
|
@@ -9,13 +9,18 @@ import android.util.Log
|
|
|
9
9
|
import android.view.Gravity
|
|
10
10
|
import android.view.View
|
|
11
11
|
import android.view.ViewGroup
|
|
12
|
+
import android.view.ViewTreeObserver
|
|
12
13
|
import android.widget.DatePicker
|
|
13
14
|
import android.widget.FrameLayout
|
|
14
15
|
import android.widget.LinearLayout
|
|
15
16
|
import android.widget.TimePicker
|
|
16
17
|
import androidx.appcompat.app.AlertDialog
|
|
17
18
|
import androidx.fragment.app.FragmentActivity
|
|
19
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
20
|
+
import com.facebook.react.uimanager.PixelUtil
|
|
21
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
18
22
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
23
|
+
import com.facebook.react.views.scroll.ReactScrollViewHelper
|
|
19
24
|
import com.google.android.material.datepicker.CalendarConstraints
|
|
20
25
|
import com.google.android.material.datepicker.MaterialDatePicker
|
|
21
26
|
import com.google.android.material.timepicker.MaterialTimePicker
|
|
@@ -26,12 +31,18 @@ import java.util.TimeZone
|
|
|
26
31
|
import kotlin.math.max
|
|
27
32
|
import kotlin.math.min
|
|
28
33
|
|
|
29
|
-
class PCDatePickerView(context: Context) : FrameLayout(context) {
|
|
34
|
+
class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollViewHelper.HasStateWrapper {
|
|
30
35
|
|
|
31
36
|
companion object {
|
|
32
37
|
private const val TAG = "PCDatePicker"
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// --- State Wrapper for Fabric state updates ---
|
|
41
|
+
override var stateWrapper: StateWrapper? = null
|
|
42
|
+
|
|
43
|
+
private var lastReportedWidth: Float = 0f
|
|
44
|
+
private var lastReportedHeight: Float = 0f
|
|
45
|
+
|
|
35
46
|
// --- Public props (set by manager) ---
|
|
36
47
|
private var mode: String = "date" // "date" | "time" | "dateAndTime"
|
|
37
48
|
private var presentation: String = "modal" // "inline" | "modal" | "popover" | "sheet" | "auto" (we treat non-inline as modal-ish)
|
|
@@ -76,6 +87,46 @@ class PCDatePickerView(context: Context) : FrameLayout(context) {
|
|
|
76
87
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
91
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
92
|
+
if (isInline()) {
|
|
93
|
+
updateFrameSizeState()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update Fabric state with the measured frame size.
|
|
99
|
+
* This allows the shadow node to use actual measured dimensions for Yoga layout.
|
|
100
|
+
*/
|
|
101
|
+
private fun updateFrameSizeState() {
|
|
102
|
+
val wrapper = stateWrapper ?: return
|
|
103
|
+
|
|
104
|
+
// Measure the inline container's preferred height
|
|
105
|
+
inlineContainer?.let { container ->
|
|
106
|
+
container.measure(
|
|
107
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
108
|
+
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
val widthDp = PixelUtil.toDIPFromPixel(width.toFloat())
|
|
112
|
+
val heightDp = PixelUtil.toDIPFromPixel(container.measuredHeight.toFloat())
|
|
113
|
+
|
|
114
|
+
// Only update state if the size actually changed (avoid infinite loops)
|
|
115
|
+
if (widthDp != lastReportedWidth || heightDp != lastReportedHeight) {
|
|
116
|
+
lastReportedWidth = widthDp
|
|
117
|
+
lastReportedHeight = heightDp
|
|
118
|
+
|
|
119
|
+
Log.d(TAG, "updateFrameSizeState: width=$widthDp, height=$heightDp")
|
|
120
|
+
|
|
121
|
+
val stateData = WritableNativeMap().apply {
|
|
122
|
+
putDouble("width", widthDp.toDouble())
|
|
123
|
+
putDouble("height", heightDp.toDouble())
|
|
124
|
+
}
|
|
125
|
+
wrapper.updateState(stateData)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
79
130
|
// -----------------------------
|
|
80
131
|
// Manager-facing apply* methods
|
|
81
132
|
// -----------------------------
|
|
@@ -198,6 +249,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context) {
|
|
|
198
249
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
|
199
250
|
)
|
|
200
251
|
orientation = LinearLayout.VERTICAL
|
|
252
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
201
253
|
}
|
|
202
254
|
|
|
203
255
|
// date and/or time
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package com.platformcomponents
|
|
2
2
|
|
|
3
|
+
import com.facebook.react.uimanager.ReactStylesDiffMap
|
|
3
4
|
import com.facebook.react.bridge.ReadableMap
|
|
4
5
|
import com.facebook.react.uimanager.SimpleViewManager
|
|
6
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
5
7
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
6
8
|
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
7
9
|
import com.facebook.react.uimanager.UIManagerHelper
|
|
@@ -25,6 +27,18 @@ class PCDatePickerViewManager :
|
|
|
25
27
|
return PCDatePickerView(reactContext)
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Pass the StateWrapper to the view so it can update Fabric state with measured dimensions.
|
|
32
|
+
*/
|
|
33
|
+
override fun updateState(
|
|
34
|
+
view: PCDatePickerView,
|
|
35
|
+
props: ReactStylesDiffMap,
|
|
36
|
+
stateWrapper: StateWrapper
|
|
37
|
+
): Any? {
|
|
38
|
+
view.stateWrapper = stateWrapper
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
override fun addEventEmitters(reactContext: ThemedReactContext, view: PCDatePickerView) {
|
|
29
43
|
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
|
|
30
44
|
|
|
@@ -5,16 +5,21 @@ import android.text.InputType
|
|
|
5
5
|
import android.util.Log
|
|
6
6
|
import android.view.View
|
|
7
7
|
import android.view.ViewGroup
|
|
8
|
+
import android.view.ViewTreeObserver
|
|
8
9
|
import android.widget.AdapterView
|
|
9
10
|
import android.widget.ArrayAdapter
|
|
10
11
|
import android.widget.FrameLayout
|
|
11
12
|
import android.widget.LinearLayout
|
|
12
13
|
import android.widget.Spinner
|
|
13
14
|
import androidx.appcompat.widget.PopupMenu
|
|
15
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
16
|
+
import com.facebook.react.uimanager.PixelUtil
|
|
17
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
18
|
+
import com.facebook.react.views.scroll.ReactScrollViewHelper
|
|
14
19
|
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
|
15
20
|
import com.google.android.material.textfield.TextInputLayout
|
|
16
21
|
|
|
17
|
-
class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
22
|
+
class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollViewHelper.HasStateWrapper {
|
|
18
23
|
|
|
19
24
|
data class Option(val label: String, val data: String)
|
|
20
25
|
|
|
@@ -22,6 +27,12 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
22
27
|
private const val TAG = "PCSelectionMenu"
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
// --- State Wrapper for Fabric state updates ---
|
|
31
|
+
override var stateWrapper: StateWrapper? = null
|
|
32
|
+
|
|
33
|
+
private var lastReportedWidth: Float = 0f
|
|
34
|
+
private var lastReportedHeight: Float = 0f
|
|
35
|
+
|
|
25
36
|
// --- Props ---
|
|
26
37
|
var options: List<Option> = emptyList()
|
|
27
38
|
var selectedData: String = "" // sentinel for none
|
|
@@ -56,12 +67,14 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
56
67
|
init {
|
|
57
68
|
minimumHeight = 0
|
|
58
69
|
minimumWidth = 0
|
|
70
|
+
// Allow content to draw outside bounds if Yoga assigns less space than needed
|
|
71
|
+
clipChildren = false
|
|
72
|
+
clipToPadding = false
|
|
59
73
|
rebuildUI()
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
76
|
+
// Track if we've requested a layout update after first measure
|
|
77
|
+
private var hasRequestedLayoutUpdate = false
|
|
65
78
|
|
|
66
79
|
// Headless needs a non-zero anchor rect for dropdown
|
|
67
80
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
@@ -71,12 +84,121 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
71
84
|
return
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
// Inline: measure children
|
|
75
|
-
|
|
87
|
+
// Inline mode: Always measure children with UNSPECIFIED height to get intrinsic size.
|
|
88
|
+
// This ensures TextInputLayout/Spinner can report their true desired height
|
|
89
|
+
// regardless of what constraints React Native passed.
|
|
90
|
+
val unconstrainedHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
91
|
+
super.onMeasure(widthMeasureSpec, unconstrainedHeightSpec)
|
|
92
|
+
|
|
93
|
+
// Get intrinsic height from children, enforce minimum (which varies by mode)
|
|
94
|
+
// minimumHeight is set in buildInline() based on the material mode
|
|
95
|
+
val intrinsicHeight = measuredHeight.coerceAtLeast(minimumHeight)
|
|
96
|
+
|
|
97
|
+
// For inline mode, always use intrinsic height to prevent clipping.
|
|
98
|
+
// React Native's Yoga doesn't know our content size, so it may pass
|
|
99
|
+
// constraints that would clip the content. We prioritize showing the
|
|
100
|
+
// full control over strictly respecting layout constraints.
|
|
101
|
+
setMeasuredDimension(measuredWidth, intrinsicHeight)
|
|
102
|
+
}
|
|
76
103
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
// Override requestLayout to handle React Native's layout timing.
|
|
105
|
+
// This ensures that after children are added/measured, we trigger
|
|
106
|
+
// a re-layout that React Native's Yoga can pick up.
|
|
107
|
+
override fun requestLayout() {
|
|
108
|
+
super.requestLayout()
|
|
109
|
+
// Post a measure/layout pass to ensure the view is properly sized
|
|
110
|
+
// after React Native's initial layout pass
|
|
111
|
+
if (!hasRequestedLayoutUpdate && anchorMode == "inline") {
|
|
112
|
+
hasRequestedLayoutUpdate = true
|
|
113
|
+
post(measureAndLayout)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private val measureAndLayout = Runnable {
|
|
118
|
+
if (!isAttachedToWindow || anchorMode != "inline") return@Runnable
|
|
119
|
+
|
|
120
|
+
// Re-measure to get correct intrinsic height
|
|
121
|
+
measure(
|
|
122
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
123
|
+
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
124
|
+
)
|
|
125
|
+
// Force layout with measured height - this overrides Yoga's assigned bounds
|
|
126
|
+
// to ensure the component isn't clipped
|
|
127
|
+
layout(left, top, right, top + measuredHeight)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
131
|
+
if (anchorMode != "inline") {
|
|
132
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For inline mode, we may need to override Yoga's assigned height.
|
|
137
|
+
// First, measure ourselves to get intrinsic height.
|
|
138
|
+
val widthSpec = MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY)
|
|
139
|
+
val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
140
|
+
measure(widthSpec, heightSpec)
|
|
141
|
+
|
|
142
|
+
val intrinsicHeight = measuredHeight
|
|
143
|
+
val assignedHeight = bottom - top
|
|
144
|
+
|
|
145
|
+
// If Yoga gave us less height than we need, resize
|
|
146
|
+
val actualBottom = if (assignedHeight < intrinsicHeight) {
|
|
147
|
+
top + intrinsicHeight
|
|
148
|
+
} else {
|
|
149
|
+
bottom
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Layout children with the correct bounds
|
|
153
|
+
super.onLayout(changed, left, top, right, actualBottom)
|
|
154
|
+
|
|
155
|
+
// Update Fabric state with measured dimensions
|
|
156
|
+
updateFrameSizeState()
|
|
157
|
+
|
|
158
|
+
// If we resized, update our own bounds
|
|
159
|
+
if (actualBottom != bottom) {
|
|
160
|
+
// Use setFrame to update our bounds without triggering another layout pass
|
|
161
|
+
post {
|
|
162
|
+
if (isAttachedToWindow && anchorMode == "inline") {
|
|
163
|
+
layout(left, top, right, actualBottom)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Update Fabric state with the measured frame size.
|
|
171
|
+
* This allows the shadow node to use actual measured dimensions for Yoga layout.
|
|
172
|
+
*/
|
|
173
|
+
private fun updateFrameSizeState() {
|
|
174
|
+
if (anchorMode != "inline") return
|
|
175
|
+
val wrapper = stateWrapper ?: return
|
|
176
|
+
|
|
177
|
+
// Get the actual inline widget
|
|
178
|
+
val inlineWidget: View? = inlineLayout ?: inlineSpinner
|
|
179
|
+
if (inlineWidget == null) return
|
|
180
|
+
|
|
181
|
+
// Measure the widget with exact width and unspecified height
|
|
182
|
+
val widthSpec = MeasureSpec.makeMeasureSpec(width.coerceAtLeast(1), MeasureSpec.EXACTLY)
|
|
183
|
+
val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
184
|
+
inlineWidget.measure(widthSpec, heightSpec)
|
|
185
|
+
|
|
186
|
+
val widthDp = PixelUtil.toDIPFromPixel(width.toFloat())
|
|
187
|
+
val rawHeightPx = inlineWidget.measuredHeight
|
|
188
|
+
val rawHeightDp = PixelUtil.toDIPFromPixel(rawHeightPx.toFloat())
|
|
189
|
+
|
|
190
|
+
Log.d(TAG, "updateFrameSizeState: widget=${inlineWidget.javaClass.simpleName}, rawHeightPx=$rawHeightPx, rawHeightDp=$rawHeightDp, minimumHeight=$minimumHeight, density=${resources.displayMetrics.density}")
|
|
191
|
+
|
|
192
|
+
// Only update if changed
|
|
193
|
+
if (widthDp != lastReportedWidth || rawHeightDp != lastReportedHeight) {
|
|
194
|
+
lastReportedWidth = widthDp
|
|
195
|
+
lastReportedHeight = rawHeightDp
|
|
196
|
+
|
|
197
|
+
val stateData = WritableNativeMap().apply {
|
|
198
|
+
putDouble("width", widthDp.toDouble())
|
|
199
|
+
putDouble("height", rawHeightDp.toDouble())
|
|
200
|
+
}
|
|
201
|
+
wrapper.updateState(stateData)
|
|
80
202
|
}
|
|
81
203
|
}
|
|
82
204
|
|
|
@@ -188,6 +310,7 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
188
310
|
headlessMenuShowing = false
|
|
189
311
|
headlessDismissProgrammatic = false
|
|
190
312
|
headlessDismissAfterSelect = false
|
|
313
|
+
hasRequestedLayoutUpdate = false
|
|
191
314
|
|
|
192
315
|
// Headless should be invisible but anchorable.
|
|
193
316
|
alpha = if (anchorMode == "headless") 0.01f else 1f
|
|
@@ -208,6 +331,14 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
208
331
|
private fun buildInline() {
|
|
209
332
|
val mode = parseMaterial(androidMaterial)
|
|
210
333
|
|
|
334
|
+
// Set minimum height based on mode - M3 TextInputLayout needs more space for floating label
|
|
335
|
+
val minHeightDp = if (mode == MaterialMode.M3) {
|
|
336
|
+
PCConstants.MIN_M3_TEXT_INPUT_HEIGHT_DP
|
|
337
|
+
} else {
|
|
338
|
+
PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP
|
|
339
|
+
}
|
|
340
|
+
minimumHeight = (minHeightDp * resources.displayMetrics.density).toInt()
|
|
341
|
+
|
|
211
342
|
if (mode == MaterialMode.M3) {
|
|
212
343
|
// M3 exposed dropdown menu - the standard Material 3 way
|
|
213
344
|
val til = TextInputLayout(context).apply {
|
|
@@ -536,7 +667,7 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
536
667
|
menu.clear()
|
|
537
668
|
val selectedIdx = options.indexOfFirst { it.data == selectedData }
|
|
538
669
|
options.forEachIndexed { index, opt ->
|
|
539
|
-
val label = if (index == selectedIdx) "✓
|
|
670
|
+
val label = if (index == selectedIdx) "✓ ${opt.label}" else opt.label
|
|
540
671
|
menu.add(0, index, index, label)
|
|
541
672
|
}
|
|
542
673
|
}
|
|
@@ -551,6 +682,34 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
|
|
|
551
682
|
}
|
|
552
683
|
}
|
|
553
684
|
|
|
685
|
+
override fun onAttachedToWindow() {
|
|
686
|
+
super.onAttachedToWindow()
|
|
687
|
+
// When attached, trigger a measure/layout pass to ensure correct sizing
|
|
688
|
+
if (anchorMode == "inline") {
|
|
689
|
+
// Use ViewTreeObserver to wait until after the first layout pass
|
|
690
|
+
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
|
691
|
+
override fun onGlobalLayout() {
|
|
692
|
+
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
|
693
|
+
|
|
694
|
+
if (!isAttachedToWindow || anchorMode != "inline") return
|
|
695
|
+
|
|
696
|
+
// Measure to get intrinsic height
|
|
697
|
+
measure(
|
|
698
|
+
MeasureSpec.makeMeasureSpec(width.coerceAtLeast(1), MeasureSpec.EXACTLY),
|
|
699
|
+
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
val intrinsicHeight = measuredHeight.coerceAtLeast(minimumHeight)
|
|
703
|
+
|
|
704
|
+
// If current height is too small, force layout with correct height
|
|
705
|
+
if (height < intrinsicHeight) {
|
|
706
|
+
layout(left, top, right, top + intrinsicHeight)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
})
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
554
713
|
override fun onDetachedFromWindow() {
|
|
555
714
|
detachInlineDropdownOverlay()
|
|
556
715
|
super.onDetachedFromWindow()
|
|
@@ -3,7 +3,9 @@ package com.platformcomponents
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import com.facebook.react.bridge.ReadableArray
|
|
5
5
|
import com.facebook.react.bridge.ReadableMap
|
|
6
|
+
import com.facebook.react.uimanager.ReactStylesDiffMap
|
|
6
7
|
import com.facebook.react.uimanager.SimpleViewManager
|
|
8
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
7
9
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
8
10
|
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
9
11
|
import com.facebook.react.uimanager.UIManagerHelper
|
|
@@ -31,6 +33,18 @@ class PCSelectionMenuViewManager :
|
|
|
31
33
|
return PCSelectionMenuView(reactContext)
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Pass the StateWrapper to the view so it can update Fabric state with measured dimensions.
|
|
38
|
+
*/
|
|
39
|
+
override fun updateState(
|
|
40
|
+
view: PCSelectionMenuView,
|
|
41
|
+
props: ReactStylesDiffMap,
|
|
42
|
+
stateWrapper: StateWrapper
|
|
43
|
+
): Any? {
|
|
44
|
+
view.stateWrapper = stateWrapper
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
override fun addEventEmitters(reactContext: ThemedReactContext, view: PCSelectionMenuView) {
|
|
35
49
|
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
|
|
36
50
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the MIT license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
#
|
|
6
|
+
# Custom CMakeLists.txt that extends the codegen with custom shadow node code
|
|
7
|
+
|
|
8
|
+
cmake_minimum_required(VERSION 3.13)
|
|
9
|
+
set(CMAKE_VERBOSE_MAKEFILE on)
|
|
10
|
+
|
|
11
|
+
# Get the codegen-generated sources
|
|
12
|
+
set(PC_CODEGEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../build/generated/source/codegen/jni")
|
|
13
|
+
|
|
14
|
+
# Codegen sources
|
|
15
|
+
file(GLOB react_codegen_SRCS CONFIGURE_DEPENDS
|
|
16
|
+
${PC_CODEGEN_DIR}/*.cpp
|
|
17
|
+
${PC_CODEGEN_DIR}/react/renderer/components/PlatformComponentsViewSpec/*.cpp
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Custom shadow node sources
|
|
21
|
+
file(GLOB_RECURSE LIB_CUSTOM_SRCS CONFIGURE_DEPENDS
|
|
22
|
+
${CMAKE_CURRENT_SOURCE_DIR}/../../../../shared/*.cpp
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
add_library(
|
|
26
|
+
react_codegen_PlatformComponentsViewSpec
|
|
27
|
+
OBJECT
|
|
28
|
+
${react_codegen_SRCS}
|
|
29
|
+
${LIB_CUSTOM_SRCS}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# IMPORTANT: Put shared directory FIRST so our custom headers shadow the codegen ones
|
|
33
|
+
target_include_directories(react_codegen_PlatformComponentsViewSpec
|
|
34
|
+
PUBLIC
|
|
35
|
+
${CMAKE_CURRENT_SOURCE_DIR}/../../../../shared
|
|
36
|
+
${PC_CODEGEN_DIR}
|
|
37
|
+
${PC_CODEGEN_DIR}/react/renderer/components/PlatformComponentsViewSpec
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
target_link_libraries(
|
|
41
|
+
react_codegen_PlatformComponentsViewSpec
|
|
42
|
+
fbjni
|
|
43
|
+
jsi
|
|
44
|
+
reactnative
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
target_compile_reactnative_options(react_codegen_PlatformComponentsViewSpec PRIVATE)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#include <fbjni/fbjni.h>
|
|
2
|
+
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
|
|
3
|
+
#include <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
|
|
4
|
+
|
|
5
|
+
#include "PCSelectionMenuShadowNode-custom.h"
|
|
6
|
+
#include "PCSelectionMenuComponentDescriptors-custom.h"
|
|
7
|
+
|
|
8
|
+
namespace facebook::react {
|
|
9
|
+
|
|
10
|
+
// Define the component name (must match the codegen-generated one)
|
|
11
|
+
// Note: This is declared extern in multiple places, we define it once here
|
|
12
|
+
const char PCSelectionMenuComponentName[] = "PCSelectionMenu";
|
|
13
|
+
|
|
14
|
+
} // namespace facebook::react
|
|
15
|
+
|
|
16
|
+
// Export our custom component descriptor registration
|
|
17
|
+
// This function should be called by the app instead of the codegen-generated one
|
|
18
|
+
extern "C" void PlatformComponents_registerCustomComponentDescriptors(
|
|
19
|
+
std::shared_ptr<const facebook::react::ComponentDescriptorProviderRegistry> registry) {
|
|
20
|
+
using namespace facebook::react;
|
|
21
|
+
|
|
22
|
+
// Register SelectionMenu with our custom measuring shadow node
|
|
23
|
+
registry->add(concreteComponentDescriptorProvider<MeasuringPCSelectionMenuComponentDescriptor>());
|
|
24
|
+
|
|
25
|
+
// DatePicker uses the default generated descriptor
|
|
26
|
+
registry->add(concreteComponentDescriptorProvider<PCDatePickerComponentDescriptor>());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
|
|
30
|
+
return facebook::jni::initialize(vm, [] {
|
|
31
|
+
// Library loaded
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -11,6 +11,7 @@ public final class PCDatePickerView: UIControl,
|
|
|
11
11
|
// MARK: - UI
|
|
12
12
|
private let picker = UIDatePicker()
|
|
13
13
|
private var modalVC: UIViewController?
|
|
14
|
+
private var inlineConstraints: [NSLayoutConstraint] = []
|
|
14
15
|
|
|
15
16
|
// Suppress “programmatic” valueChanged events (apply props / initial present settle).
|
|
16
17
|
private var suppressChangeEvents = false
|
|
@@ -119,6 +120,41 @@ public final class PCDatePickerView: UIControl,
|
|
|
119
120
|
|
|
120
121
|
// MARK: - Layout / Sizing
|
|
121
122
|
|
|
123
|
+
private var lastLayoutBounds: CGRect = .zero
|
|
124
|
+
private var needsStyleReset = false
|
|
125
|
+
|
|
126
|
+
public override func layoutSubviews() {
|
|
127
|
+
super.layoutSubviews()
|
|
128
|
+
|
|
129
|
+
// For embedded presentation, manually center the picker after Auto Layout
|
|
130
|
+
// This ensures consistent centering regardless of UIDatePicker's internal state
|
|
131
|
+
if presentation == "embedded" && picker.superview === self && bounds.width > 0 {
|
|
132
|
+
let widthChanged = abs(bounds.width - lastLayoutBounds.width) > 1
|
|
133
|
+
if needsStyleReset || widthChanged {
|
|
134
|
+
if #available(iOS 13.4, *) {
|
|
135
|
+
let currentStyle = picker.preferredDatePickerStyle
|
|
136
|
+
picker.preferredDatePickerStyle = .automatic
|
|
137
|
+
picker.preferredDatePickerStyle = currentStyle
|
|
138
|
+
}
|
|
139
|
+
picker.sizeToFit()
|
|
140
|
+
needsStyleReset = false
|
|
141
|
+
}
|
|
142
|
+
lastLayoutBounds = bounds
|
|
143
|
+
|
|
144
|
+
// After constraints do their thing, manually adjust picker position to center it
|
|
145
|
+
let pickerSize = picker.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
146
|
+
let xOffset = (bounds.width - pickerSize.width) / 2
|
|
147
|
+
if xOffset > 0 {
|
|
148
|
+
picker.frame = CGRect(
|
|
149
|
+
x: xOffset,
|
|
150
|
+
y: 0,
|
|
151
|
+
width: pickerSize.width,
|
|
152
|
+
height: bounds.height
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
private func invalidateSize() {
|
|
123
159
|
invalidateIntrinsicContentSize()
|
|
124
160
|
setNeedsLayout()
|
|
@@ -184,19 +220,39 @@ public final class PCDatePickerView: UIControl,
|
|
|
184
220
|
private func attachInlinePickerIfNeeded() {
|
|
185
221
|
guard picker.superview !== self else { return }
|
|
186
222
|
|
|
223
|
+
// Deactivate any previous inline constraints
|
|
224
|
+
NSLayoutConstraint.deactivate(inlineConstraints)
|
|
225
|
+
inlineConstraints.removeAll()
|
|
226
|
+
|
|
187
227
|
picker.removeFromSuperview()
|
|
188
228
|
addSubview(picker)
|
|
189
229
|
|
|
190
|
-
|
|
230
|
+
// Mark that we need a style reset on next layout pass
|
|
231
|
+
// This ensures centering is recalculated after Yoga provides correct bounds
|
|
232
|
+
needsStyleReset = true
|
|
233
|
+
|
|
234
|
+
inlineConstraints = [
|
|
191
235
|
picker.topAnchor.constraint(equalTo: topAnchor),
|
|
192
236
|
picker.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
193
237
|
picker.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
194
238
|
picker.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
195
|
-
]
|
|
239
|
+
]
|
|
240
|
+
NSLayoutConstraint.activate(inlineConstraints)
|
|
241
|
+
|
|
242
|
+
// Force picker to recalculate its internal layout after being moved between view hierarchies.
|
|
243
|
+
// Re-applying the style resets internal layout state that can become stale after modal use.
|
|
244
|
+
if #available(iOS 13.4, *) {
|
|
245
|
+
let currentStyle = picker.preferredDatePickerStyle
|
|
246
|
+
picker.preferredDatePickerStyle = .automatic
|
|
247
|
+
picker.preferredDatePickerStyle = currentStyle
|
|
248
|
+
}
|
|
249
|
+
picker.sizeToFit()
|
|
196
250
|
}
|
|
197
251
|
|
|
198
252
|
private func detachInlinePickerIfNeeded() {
|
|
199
253
|
if picker.superview === self {
|
|
254
|
+
NSLayoutConstraint.deactivate(inlineConstraints)
|
|
255
|
+
inlineConstraints.removeAll()
|
|
200
256
|
picker.removeFromSuperview()
|
|
201
257
|
}
|
|
202
258
|
}
|