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.
- package/README.md +6 -1
- 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 +19 -2
- package/ios/PCSelectionMenu.mm +42 -0
- package/ios/PCSelectionMenu.swift +17 -0
- package/lib/module/SelectionMenu.js +1 -8
- package/lib/module/SelectionMenu.js.map +1 -1
- package/lib/typescript/src/SelectionMenu.d.ts.map +1 -1
- package/package.json +1 -1
- 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 +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="
|
|
182
|
+
mode="date"
|
|
180
183
|
onConfirm={(d) => setDate(d)}
|
|
184
|
+
ios={{preferredStyle: 'inline'}}
|
|
185
|
+
android={{material: 'system'}}
|
|
181
186
|
/>
|
|
182
187
|
);
|
|
183
188
|
}
|
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
|
|
@@ -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
|
-
|
|
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
|
}
|
package/ios/PCSelectionMenu.mm
CHANGED
|
@@ -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
|