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.
Files changed (32) hide show
  1. package/README.md +13 -8
  2. package/android/build.gradle +3 -1
  3. package/android/src/main/java/com/platformcomponents/PCConstants.kt +3 -0
  4. package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +53 -1
  5. package/android/src/main/java/com/platformcomponents/PCDatePickerViewManager.kt +14 -0
  6. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +169 -10
  7. package/android/src/main/java/com/platformcomponents/PCSelectionMenuViewManager.kt +14 -0
  8. package/android/src/main/jni/CMakeLists.txt +47 -0
  9. package/android/src/main/jni/OnLoad.cpp +33 -0
  10. package/ios/PCDatePickerView.swift +58 -2
  11. package/ios/PCSelectionMenu.mm +42 -0
  12. package/ios/PCSelectionMenu.swift +17 -0
  13. package/lib/module/SelectionMenu.js +7 -14
  14. package/lib/module/SelectionMenu.js.map +1 -1
  15. package/lib/typescript/src/SelectionMenu.d.ts +6 -5
  16. package/lib/typescript/src/SelectionMenu.d.ts.map +1 -1
  17. package/lib/typescript/src/sharedTypes.d.ts +3 -1
  18. package/lib/typescript/src/sharedTypes.d.ts.map +1 -1
  19. package/package.json +3 -2
  20. package/react-native.config.js +13 -0
  21. package/shared/PCDatePickerComponentDescriptors-custom.h +14 -43
  22. package/shared/PCDatePickerShadowNode-custom.cpp +35 -0
  23. package/shared/PCDatePickerShadowNode-custom.h +40 -18
  24. package/shared/PCDatePickerState-custom.h +53 -1
  25. package/shared/PCSelectionMenuComponentDescriptors-custom.h +15 -18
  26. package/shared/PCSelectionMenuShadowNode-custom.cpp +42 -21
  27. package/shared/PCSelectionMenuShadowNode-custom.h +23 -10
  28. package/shared/PCSelectionMenuState-custom.h +65 -0
  29. package/shared/README.md +179 -0
  30. package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +9 -0
  31. package/src/SelectionMenu.tsx +15 -24
  32. 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
- inlineMode
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="dateAndTime"
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 **inline** and **headless** modes.
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
- | `inlineMode` | `boolean` | If true, renders native inline picker UI |
203
- | `visible` | `boolean` | Controls headless mode menu visibility |
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
- - **Headless mode** (default): Menu visibility controlled by `visible` prop. Use for custom trigger UI.
211
- - **Inline mode** (`inlineMode={true}`): Native picker UI rendered inline. Menu managed internally.
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, headless mode uses a custom popover to enable programmatic presentation. For the full native menu experience (system animations, scroll physics), use inline mode. This is an intentional trade-off: headless gives you control over the trigger UI, inline gives you the complete system menu behavior.
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
 
@@ -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
- private val minInlineHeightPx: Int by lazy {
63
- (PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP * resources.displayMetrics.density).toInt()
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, but never allow a collapsed height
75
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
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
- val measuredH = measuredHeight
78
- if (measuredH < minInlineHeightPx) {
79
- setMeasuredDimension(measuredWidth, minInlineHeightPx)
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) "✓ ${opt.label}" else " ${opt.label}"
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
- NSLayoutConstraint.activate([
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
  }