react-native-platform-components 0.5.3 → 0.5.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.
Files changed (28) hide show
  1. package/README.md +6 -1
  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 +19 -2
  11. package/ios/PCSelectionMenu.mm +42 -0
  12. package/ios/PCSelectionMenu.swift +17 -0
  13. package/lib/module/SelectionMenu.js +1 -8
  14. package/lib/module/SelectionMenu.js.map +1 -1
  15. package/lib/typescript/src/SelectionMenu.d.ts.map +1 -1
  16. package/package.json +1 -1
  17. package/react-native.config.js +13 -0
  18. package/shared/PCDatePickerComponentDescriptors-custom.h +14 -43
  19. package/shared/PCDatePickerShadowNode-custom.cpp +35 -0
  20. package/shared/PCDatePickerShadowNode-custom.h +40 -18
  21. package/shared/PCDatePickerState-custom.h +53 -1
  22. package/shared/PCSelectionMenuComponentDescriptors-custom.h +15 -18
  23. package/shared/PCSelectionMenuShadowNode-custom.cpp +42 -21
  24. package/shared/PCSelectionMenuShadowNode-custom.h +23 -10
  25. package/shared/PCSelectionMenuState-custom.h +65 -0
  26. package/shared/README.md +179 -0
  27. package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +9 -0
  28. package/src/SelectionMenu.tsx +2 -12
package/README.md CHANGED
@@ -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
  }
@@ -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
@@ -184,19 +185,35 @@ public final class PCDatePickerView: UIControl,
184
185
  private func attachInlinePickerIfNeeded() {
185
186
  guard picker.superview !== self else { return }
186
187
 
188
+ // Deactivate any previous inline constraints
189
+ NSLayoutConstraint.deactivate(inlineConstraints)
190
+ inlineConstraints.removeAll()
191
+
187
192
  picker.removeFromSuperview()
188
193
  addSubview(picker)
189
194
 
190
- NSLayoutConstraint.activate([
195
+ inlineConstraints = [
191
196
  picker.topAnchor.constraint(equalTo: topAnchor),
192
197
  picker.bottomAnchor.constraint(equalTo: bottomAnchor),
193
198
  picker.leadingAnchor.constraint(equalTo: leadingAnchor),
194
199
  picker.trailingAnchor.constraint(equalTo: trailingAnchor),
195
- ])
200
+ ]
201
+ NSLayoutConstraint.activate(inlineConstraints)
202
+
203
+ // Force picker to recalculate its internal layout after being moved between view hierarchies.
204
+ // Re-applying the style resets internal layout state that can become stale after modal use.
205
+ if #available(iOS 13.4, *) {
206
+ let currentStyle = picker.preferredDatePickerStyle
207
+ picker.preferredDatePickerStyle = .automatic
208
+ picker.preferredDatePickerStyle = currentStyle
209
+ }
210
+ picker.sizeToFit()
196
211
  }
197
212
 
198
213
  private func detachInlinePickerIfNeeded() {
199
214
  if picker.superview === self {
215
+ NSLayoutConstraint.deactivate(inlineConstraints)
216
+ inlineConstraints.removeAll()
200
217
  picker.removeFromSuperview()
201
218
  }
202
219
  }
@@ -9,6 +9,7 @@
9
9
  #import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
10
10
  #import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
11
11
  #import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
12
+ #import <react/renderer/core/LayoutPrimitives.h>
12
13
 
13
14
  #if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
14
15
  #import <PlatformComponents/PlatformComponents-Swift.h>
@@ -17,6 +18,8 @@
17
18
  #endif
18
19
 
19
20
  #import "PCSelectionMenuComponentDescriptors-custom.h"
21
+ #import "PCSelectionMenuShadowNode-custom.h"
22
+ #import "PCSelectionMenuState-custom.h"
20
23
 
21
24
  using namespace facebook::react;
22
25
 
@@ -33,8 +36,15 @@ static inline bool OptionsEqual(
33
36
  }
34
37
  } // namespace
35
38
 
39
+ @interface PCSelectionMenu ()
40
+
41
+ - (void)updateMeasurements;
42
+
43
+ @end
44
+
36
45
  @implementation PCSelectionMenu {
37
46
  PCSelectionMenuView *_view;
47
+ MeasuringPCSelectionMenuShadowNode::ConcreteState::Shared _state;
38
48
  }
39
49
 
40
50
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -167,6 +177,38 @@ static inline bool OptionsEqual(
167
177
  }
168
178
 
169
179
  [super updateProps:props oldProps:oldProps];
180
+
181
+ // Update measurements when props change that affect layout
182
+ [self updateMeasurements];
183
+ }
184
+
185
+ #pragma mark - State (Measuring)
186
+
187
+ - (void)updateState:(const State::Shared &)state
188
+ oldState:(const State::Shared &)oldState {
189
+ _state = std::static_pointer_cast<
190
+ const MeasuringPCSelectionMenuShadowNode::ConcreteState>(state);
191
+
192
+ if (oldState == nullptr) {
193
+ // First time: compute initial size.
194
+ [self updateMeasurements];
195
+ }
196
+
197
+ [super updateState:state oldState:oldState];
198
+ }
199
+
200
+ - (void)updateMeasurements {
201
+ if (_state == nullptr)
202
+ return;
203
+
204
+ // Use the real width Yoga gave us
205
+ const CGFloat w = self.bounds.size.width > 1 ? self.bounds.size.width : 320;
206
+
207
+ CGSize size = [_view sizeForLayoutWithConstrainedTo:CGSizeMake(w, 0)];
208
+
209
+ PCSelectionMenuStateFrameSize next;
210
+ next.frameSize = {(Float)size.width, (Float)size.height};
211
+ _state->updateState(std::move(next));
170
212
  }
171
213
 
172
214
  @end
@@ -370,6 +370,23 @@ public final class PCSelectionMenuView: UIControl {
370
370
  )
371
371
  return CGSize(width: UIView.noIntrinsicMetric, height: h)
372
372
  }
373
+
374
+ /// Called by the measuring pipeline to get the size for Yoga layout.
375
+ /// Headless mode returns zero so Yoga reserves nothing.
376
+ @objc public func sizeForLayout(withConstrainedTo constrainedSize: CGSize) -> CGSize {
377
+ guard anchorMode == "inline" else { return .zero }
378
+
379
+ guard let host = hostingController else {
380
+ return CGSize(width: constrainedSize.width, height: PCConstants.minTouchTargetHeight)
381
+ }
382
+
383
+ host.view.setNeedsLayout()
384
+ host.view.layoutIfNeeded()
385
+
386
+ let w = constrainedSize.width > 1 ? constrainedSize.width : PCConstants.fallbackWidth
387
+ let fitted = host.sizeThatFits(in: CGSize(width: w, height: .greatestFiniteMagnitude))
388
+ return CGSize(width: constrainedSize.width, height: max(PCConstants.minTouchTargetHeight, fitted.height))
389
+ }
373
390
  }
374
391
 
375
392
  // MARK: - Glass Menu Cell