react-native-platform-components 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +126 -30
  2. package/android/src/main/java/com/platformcomponents/PCImageLoader.kt +88 -0
  3. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +288 -29
  4. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +74 -8
  5. package/ios/PCConstants.swift +39 -3
  6. package/ios/PCImageLoader.swift +58 -0
  7. package/ios/PCSegmentedControl.mm +85 -25
  8. package/ios/PCSegmentedControl.swift +231 -308
  9. package/ios/PCSelectionMenu.swift +60 -14
  10. package/lib/commonjs/DatePickerNativeComponent.ts +4 -2
  11. package/lib/commonjs/SegmentedControl.js +138 -6
  12. package/lib/commonjs/SegmentedControl.js.map +1 -1
  13. package/lib/commonjs/SegmentedControlNativeComponent.ts +62 -8
  14. package/lib/commonjs/SelectionMenuNativeComponent.ts +4 -2
  15. package/lib/commonjs/index.web.js.map +1 -1
  16. package/lib/module/DatePickerNativeComponent.ts +4 -2
  17. package/lib/module/SegmentedControl.js +138 -7
  18. package/lib/module/SegmentedControl.js.map +1 -1
  19. package/lib/module/SegmentedControlNativeComponent.ts +62 -8
  20. package/lib/module/SelectionMenuNativeComponent.ts +4 -2
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts +2 -2
  23. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +111 -6
  25. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +46 -7
  27. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts +2 -2
  29. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/src/index.web.d.ts +36 -1
  31. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  32. package/lib/typescript/module/src/DatePicker.d.ts +1 -1
  33. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -1
  34. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +2 -2
  35. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -1
  36. package/lib/typescript/module/src/SegmentedControl.d.ts +111 -6
  37. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -1
  38. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +46 -7
  39. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  40. package/lib/typescript/module/src/SelectionMenu.d.ts +1 -1
  41. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -1
  42. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +2 -2
  43. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  44. package/lib/typescript/module/src/index.d.ts +6 -6
  45. package/lib/typescript/module/src/index.d.ts.map +1 -1
  46. package/lib/typescript/module/src/index.web.d.ts +36 -1
  47. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  48. package/package.json +30 -25
  49. package/plugin/tsconfig.json +2 -2
  50. package/src/DatePickerNativeComponent.ts +4 -2
  51. package/src/SegmentedControl.tsx +238 -11
  52. package/src/SegmentedControlNativeComponent.ts +62 -8
  53. package/src/SelectionMenuNativeComponent.ts +4 -2
  54. package/src/index.web.tsx +34 -1
@@ -1,13 +1,22 @@
1
1
  package com.platformcomponents
2
2
 
3
3
  import android.content.Context
4
+ import android.content.res.ColorStateList
5
+ import android.graphics.Color
6
+ import android.graphics.drawable.BitmapDrawable
4
7
  import android.text.TextUtils
8
+ import android.util.TypedValue
5
9
  import android.view.View
6
10
  import android.widget.FrameLayout
11
+ import android.widget.LinearLayout
7
12
  import com.facebook.react.bridge.WritableNativeMap
8
13
  import com.facebook.react.uimanager.PixelUtil
9
14
  import com.facebook.react.uimanager.StateWrapper
15
+ import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
10
16
  import com.facebook.react.views.scroll.ReactScrollViewHelper
17
+ import com.facebook.react.views.text.ReactTypefaceUtils
18
+ import com.google.android.material.badge.BadgeDrawable
19
+ import com.google.android.material.badge.BadgeUtils
11
20
  import com.google.android.material.button.MaterialButton
12
21
  import com.google.android.material.button.MaterialButtonToggleGroup
13
22
 
@@ -17,8 +26,24 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
17
26
  val label: String,
18
27
  val value: String,
19
28
  val disabled: Boolean,
20
- val icon: String
21
- )
29
+ /** "", "sfSymbol" (ignored on Android), "drawable" or "image" */
30
+ val iconType: String,
31
+ val iconName: String,
32
+ val iconUri: String,
33
+ val iconScale: Float,
34
+ val iconTinted: Boolean,
35
+ val badge: String,
36
+ val accessibilityLabel: String
37
+ ) {
38
+ val hasIcon: Boolean get() = iconType == "drawable" || iconType == "image"
39
+
40
+ /** What TalkBack announces for the segment, badge included. */
41
+ val spokenLabel: String
42
+ get() {
43
+ val base = accessibilityLabel.ifEmpty { label }
44
+ return if (badge.isEmpty()) base else "$base, $badge"
45
+ }
46
+ }
22
47
 
23
48
  companion object {
24
49
  private const val TAG = "PCSegmentedControl"
@@ -34,9 +59,25 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
34
59
  var segments: List<Segment> = emptyList()
35
60
  var selectedValue: String = "" // sentinel for none
36
61
  var interactivity: String = "enabled" // "enabled" | "disabled"
37
- var selectionRequired: Boolean = false
62
+ var labelVisibility: String = "auto" // "auto" | "labeled" | "unlabeled"
63
+ // Matches iOS, where a UISegmentedControl selection cannot be cleared by tapping.
64
+ var selectionRequired: Boolean = true
65
+
66
+ // --- Styling (null / empty = Material theme default) ---
67
+ var selectedSegmentColor: Int? = null
68
+ var activeTintColor: Int? = null
69
+ var inactiveTintColor: Int? = null
70
+ var rippleColor: Int? = null
71
+ var strokeColor: Int? = null
72
+ var labelFontFamily: String = ""
73
+ var labelFontSize: Float = 0f
74
+ var labelFontWeight: String = ""
75
+ var labelFontStyle: String = ""
76
+ var badgeBackgroundColor: Int? = null
77
+ var badgeTextColor: Int? = null
38
78
 
39
79
  // --- Events ---
80
+ /** index -1 with an empty value means the selection was cleared. */
40
81
  var onSelect: ((index: Int, value: String) -> Unit)? = null
41
82
 
42
83
  // --- UI ---
@@ -44,8 +85,14 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
44
85
  private val buttonIdToSegment: MutableMap<Int, Segment> = mutableMapOf()
45
86
  private var suppressCallbacks = false
46
87
 
88
+ /** Bumped on every rebuild so late image loads can't touch stale buttons. */
89
+ private var rebuildGeneration = 0
90
+
47
91
  init {
48
92
  minimumHeight = (PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP * resources.displayMetrics.density).toInt()
93
+ // Badges overhang the button corner
94
+ clipChildren = false
95
+ clipToPadding = false
49
96
  rebuildUI()
50
97
  }
51
98
 
@@ -70,6 +117,16 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
70
117
  updateEnabled()
71
118
  }
72
119
 
120
+ fun applyLabelVisibility(value: String?) {
121
+ val newValue = when (value) {
122
+ "labeled", "unlabeled" -> value
123
+ else -> "auto"
124
+ }
125
+ if (labelVisibility == newValue) return
126
+ labelVisibility = newValue
127
+ rebuildUI()
128
+ }
129
+
73
130
  fun applyAndroidProps(required: Boolean) {
74
131
  if (selectionRequired != required) {
75
132
  selectionRequired = required
@@ -77,16 +134,68 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
77
134
  }
78
135
  }
79
136
 
137
+ fun applySelectedSegmentColor(color: Int?) {
138
+ if (selectedSegmentColor == color) return
139
+ selectedSegmentColor = color
140
+ rebuildUI()
141
+ }
142
+
143
+ fun applyActiveTintColor(color: Int?) {
144
+ if (activeTintColor == color) return
145
+ activeTintColor = color
146
+ rebuildUI()
147
+ }
148
+
149
+ fun applyInactiveTintColor(color: Int?) {
150
+ if (inactiveTintColor == color) return
151
+ inactiveTintColor = color
152
+ rebuildUI()
153
+ }
154
+
155
+ fun applyRippleColor(color: Int?) {
156
+ if (rippleColor == color) return
157
+ rippleColor = color
158
+ rebuildUI()
159
+ }
160
+
161
+ fun applyStrokeColor(color: Int?) {
162
+ if (strokeColor == color) return
163
+ strokeColor = color
164
+ rebuildUI()
165
+ }
166
+
167
+ fun applyBadgeColors(background: Int?, text: Int?) {
168
+ if (badgeBackgroundColor == background && badgeTextColor == text) return
169
+ badgeBackgroundColor = background
170
+ badgeTextColor = text
171
+ rebuildUI()
172
+ }
173
+
174
+ fun applyLabelStyle(fontFamily: String, fontSize: Float, fontWeight: String, fontStyle: String) {
175
+ if (labelFontFamily == fontFamily && labelFontSize == fontSize &&
176
+ labelFontWeight == fontWeight && labelFontStyle == fontStyle
177
+ ) return
178
+ labelFontFamily = fontFamily
179
+ labelFontSize = fontSize
180
+ labelFontWeight = fontWeight
181
+ labelFontStyle = fontStyle
182
+ rebuildUI()
183
+ }
184
+
80
185
  // ---- UI Building ----
81
186
 
82
187
  private fun rebuildUI() {
83
188
  removeAllViews()
84
189
  buttonIdToSegment.clear()
190
+ rebuildGeneration += 1
191
+ val generation = rebuildGeneration
85
192
 
86
193
  val group = MaterialButtonToggleGroup(context).apply {
87
194
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
88
195
  isSingleSelection = true
89
196
  isSelectionRequired = selectionRequired
197
+ clipChildren = false
198
+ clipToPadding = false
90
199
  }
91
200
 
92
201
  // Calculate if we need compact mode (many segments or long labels)
@@ -94,49 +203,59 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
94
203
  val useCompactMode = segments.size > 3 || totalLabelLength > 20
95
204
 
96
205
  for ((index, segment) in segments.withIndex()) {
206
+ // "unlabeled" hides the text of segments that have an icon; segments
207
+ // without one keep their label. "auto" and "labeled" show icon + label.
208
+ val iconOnly = labelVisibility == "unlabeled" && segment.hasIcon
209
+
97
210
  val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
98
211
  id = View.generateViewId()
99
- text = segment.label
212
+ text = if (iconOnly) "" else segment.label
100
213
  isAllCaps = false // Preserve original text casing
101
- contentDescription = segment.label // For accessibility and Detox matching
214
+ // Screen readers (and Detox) always get the label, even when hidden
215
+ contentDescription = segment.spokenLabel
102
216
  isEnabled = !segment.disabled && interactivity == "enabled"
103
217
 
104
218
  // Enable text truncation with ellipsis when space is limited
105
219
  ellipsize = TextUtils.TruncateAt.END
106
220
  maxLines = 1
107
221
 
108
- // Reduce horizontal padding in compact mode to fit more content
109
- if (useCompactMode) {
110
- val compactPadding = (8 * resources.displayMetrics.density).toInt()
111
- setPaddingRelative(compactPadding, paddingTop, compactPadding, paddingBottom)
112
- iconPadding = (4 * resources.displayMetrics.density).toInt()
222
+ // Material 3 segmented buttons use 12dp horizontal padding and an 8dp
223
+ // icon gap (the outlined button style defaults to 24dp); tighten
224
+ // further when there are many segments or long labels.
225
+ val density = resources.displayMetrics.density
226
+ val horizontalPadding = ((if (useCompactMode) 8 else 12) * density).toInt()
227
+ setPaddingRelative(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
228
+ iconPadding = ((if (useCompactMode) 4 else 8) * density).toInt()
229
+
230
+ if (iconOnly) {
231
+ // Centre the icon instead of leaving it at the start edge
232
+ iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
233
+ iconPadding = 0
113
234
  }
114
235
 
115
- // Set icon if available
116
- if (segment.icon.isNotEmpty()) {
117
- val resId = context.resources.getIdentifier(
118
- segment.icon, "drawable", context.packageName
119
- )
120
- if (resId != 0) {
121
- setIconResource(resId)
122
- }
123
- }
124
-
125
- // Handle click to trigger selection (needed for Detox taps)
126
- setOnClickListener {
127
- if (!suppressCallbacks && isEnabled) {
128
- group.check(id)
129
- }
130
- }
236
+ applyIcon(this, segment, generation)
237
+ styleButton(this, segment)
131
238
  }
132
239
 
133
240
  buttonIdToSegment[button.id] = segment
134
- group.addView(button)
241
+ // Share the width equally (like UISegmentedControl) so trailing segments
242
+ // never overflow the control; long labels ellipsize instead.
243
+ group.addView(button, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
244
+ attachBadge(button, segment)
135
245
  }
136
246
 
137
- group.addOnButtonCheckedListener { _, checkedId, isChecked ->
247
+ group.addOnButtonCheckedListener { toggleGroup, checkedId, isChecked ->
138
248
  if (suppressCallbacks) return@addOnButtonCheckedListener
139
- if (!isChecked) return@addOnButtonCheckedListener
249
+
250
+ if (!isChecked) {
251
+ // In single-selection mode switching from A to B reports A as unchecked
252
+ // before B is reported as checked; by then the group already knows B is
253
+ // checked. Only a tap that leaves nothing checked is a real deselection.
254
+ if (toggleGroup.checkedButtonId == View.NO_ID) {
255
+ onSelect?.invoke(-1, "")
256
+ }
257
+ return@addOnButtonCheckedListener
258
+ }
140
259
 
141
260
  val segment = buttonIdToSegment[checkedId] ?: return@addOnButtonCheckedListener
142
261
  val index = segments.indexOf(segment)
@@ -153,6 +272,117 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
153
272
  requestLayout()
154
273
  }
155
274
 
275
+ /**
276
+ * Resolves a segment's icon. Drawable names and release-bundled assets (which
277
+ * React Native ships as drawable resources) resolve synchronously; other
278
+ * image URIs load in the background and are applied when they arrive.
279
+ */
280
+ private fun applyIcon(button: MaterialButton, segment: Segment, generation: Int) {
281
+ if (!segment.iconTinted) {
282
+ button.iconTint = null
283
+ }
284
+
285
+ when (segment.iconType) {
286
+ "drawable" -> {
287
+ button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, segment.iconName)
288
+ }
289
+ "image" -> {
290
+ val uri = segment.iconUri
291
+ val isResourceName = !uri.contains(':')
292
+ if (isResourceName) {
293
+ button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, uri)
294
+ } else {
295
+ PCImageLoader.load(context, uri, segment.iconScale) { bitmap ->
296
+ if (bitmap == null || generation != rebuildGeneration) return@load
297
+ button.icon = BitmapDrawable(resources, bitmap)
298
+ requestLayout()
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Draws a Material badge over the button's top-end corner. The badge lives
307
+ * in the button's overlay, so it needs the button's final bounds: attach
308
+ * after the first layout and follow later size changes.
309
+ */
310
+ private fun attachBadge(button: MaterialButton, segment: Segment) {
311
+ if (segment.badge.isEmpty()) return
312
+
313
+ val inset = (2 * resources.displayMetrics.density).toInt()
314
+ val badge = BadgeDrawable.create(context).apply {
315
+ text = segment.badge
316
+ badgeGravity = BadgeDrawable.TOP_END
317
+ badgeBackgroundColor?.let { backgroundColor = it }
318
+ badgeTextColor?.let { setBadgeTextColor(it) }
319
+ }
320
+
321
+ var attached = false
322
+ button.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
323
+ if (!attached) {
324
+ attached = true
325
+ BadgeUtils.attachBadgeDrawable(badge, view)
326
+ // The badge's size is only known once it has an anchor. Offset it by
327
+ // half its size so it sits fully inside the button corner; a badge
328
+ // that overhangs the last segment would be clipped by the control.
329
+ badge.horizontalOffset = badge.intrinsicWidth / 2 + inset
330
+ badge.verticalOffset = badge.intrinsicHeight / 2 + inset
331
+ }
332
+ badge.updateBadgeCoordinates(view)
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Applies the color and font props on top of the Material theme defaults.
338
+ * Buttons are rebuilt whenever a style prop changes, so the theme values
339
+ * read here are always the untouched defaults.
340
+ */
341
+ private fun styleButton(button: MaterialButton, segment: Segment) {
342
+ val checkedState = intArrayOf(android.R.attr.state_checked)
343
+ val disabledState = intArrayOf(-android.R.attr.state_enabled)
344
+
345
+ if (activeTintColor != null || inactiveTintColor != null) {
346
+ val theme = button.textColors
347
+ val disabled = theme.getColorForState(disabledState, theme.defaultColor)
348
+ val checked = activeTintColor ?: theme.getColorForState(checkedState, theme.defaultColor)
349
+ val normal = inactiveTintColor ?: theme.defaultColor
350
+ val tint = ColorStateList(
351
+ arrayOf(disabledState, checkedState, intArrayOf()),
352
+ intArrayOf(disabled, checked, normal)
353
+ )
354
+ button.setTextColor(tint)
355
+ if (segment.iconTinted) {
356
+ button.iconTint = tint
357
+ }
358
+ }
359
+
360
+ selectedSegmentColor?.let { color ->
361
+ val theme = button.backgroundTintList
362
+ val normal = theme?.defaultColor ?: Color.TRANSPARENT
363
+ button.backgroundTintList = ColorStateList(
364
+ arrayOf(checkedState, intArrayOf()),
365
+ intArrayOf(color, normal)
366
+ )
367
+ }
368
+
369
+ rippleColor?.let { button.rippleColor = ColorStateList.valueOf(it) }
370
+ strokeColor?.let { button.strokeColor = ColorStateList.valueOf(it) }
371
+
372
+ if (labelFontSize > 0) {
373
+ button.setTextSize(TypedValue.COMPLEX_UNIT_SP, labelFontSize)
374
+ }
375
+ if (labelFontFamily.isNotEmpty() || labelFontWeight.isNotEmpty() || labelFontStyle.isNotEmpty()) {
376
+ button.typeface = ReactTypefaceUtils.applyStyles(
377
+ button.typeface,
378
+ ReactTypefaceUtils.parseFontStyle(labelFontStyle.ifEmpty { null }),
379
+ ReactTypefaceUtils.parseFontWeight(labelFontWeight.ifEmpty { null }),
380
+ labelFontFamily.ifEmpty { null },
381
+ context.assets
382
+ )
383
+ }
384
+ }
385
+
156
386
  private fun updateSelection() {
157
387
  suppressCallbacks = true
158
388
  val group = toggleGroup ?: return
@@ -182,6 +412,35 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
182
412
  }
183
413
  }
184
414
 
415
+ // ---- Layout ----
416
+
417
+ private var manualLayoutPending = false
418
+
419
+ /**
420
+ * React Native's root view ignores requestLayout after the initial pass, so
421
+ * a native view that changes its own subtree later (rebuilding segments for
422
+ * a prop change, an icon finishing loading) has to measure and lay itself
423
+ * out. Coalesced to one pass per frame.
424
+ */
425
+ override fun requestLayout() {
426
+ super.requestLayout()
427
+ if (!manualLayoutPending) {
428
+ manualLayoutPending = true
429
+ post { runManualLayout() }
430
+ }
431
+ }
432
+
433
+ private fun runManualLayout() {
434
+ manualLayoutPending = false
435
+ // Nothing to do until Yoga has placed us; the regular pass covers that.
436
+ if (width == 0 || height == 0) return
437
+ measure(
438
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
439
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
440
+ )
441
+ layout(left, top, right, bottom)
442
+ }
443
+
185
444
  // ---- Measurement ----
186
445
 
187
446
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -52,22 +52,82 @@ class PCSegmentedControlViewManager :
52
52
  }
53
53
  }
54
54
 
55
- // segments: array of {label, value, disabled, icon}
55
+ private fun ReadableMap.stringOr(key: String, fallback: String): String =
56
+ if (hasKey(key) && !isNull(key)) getString(key) ?: fallback else fallback
57
+
58
+ private fun ReadableMap.doubleOr(key: String, fallback: Double): Double =
59
+ if (hasKey(key) && !isNull(key)) getDouble(key) else fallback
60
+
61
+ // segments: array of {label, value, disabled, iconType, iconName, iconUri,
62
+ // iconScale, iconTinted, accessibilityLabel}
56
63
  override fun setSegments(view: PCSegmentedControlView, value: ReadableArray?) {
57
64
  val out = ArrayList<PCSegmentedControlView.Segment>()
58
65
  if (value != null) {
59
66
  for (i in 0 until value.size()) {
60
67
  val m = value.getMap(i) ?: continue
61
- val label = if (m.hasKey("label") && !m.isNull("label")) m.getString("label") ?: "" else ""
62
- val segValue = if (m.hasKey("value") && !m.isNull("value")) m.getString("value") ?: "" else ""
63
- val disabled = m.hasKey("disabled") && !m.isNull("disabled") && m.getString("disabled") == "disabled"
64
- val icon = if (m.hasKey("icon") && !m.isNull("icon")) m.getString("icon") ?: "" else ""
65
- out.add(PCSegmentedControlView.Segment(label = label, value = segValue, disabled = disabled, icon = icon))
68
+ val scale = m.doubleOr("iconScale", 1.0)
69
+ out.add(
70
+ PCSegmentedControlView.Segment(
71
+ label = m.stringOr("label", ""),
72
+ value = m.stringOr("value", ""),
73
+ disabled = m.stringOr("disabled", "enabled") == "disabled",
74
+ iconType = m.stringOr("iconType", ""),
75
+ iconName = m.stringOr("iconName", ""),
76
+ iconUri = m.stringOr("iconUri", ""),
77
+ iconScale = if (scale > 0) scale.toFloat() else 1f,
78
+ iconTinted = m.stringOr("iconTinted", "true") != "false",
79
+ badge = m.stringOr("badge", ""),
80
+ accessibilityLabel = m.stringOr("accessibilityLabel", "")
81
+ )
82
+ )
66
83
  }
67
84
  }
68
85
  view.applySegments(out)
69
86
  }
70
87
 
88
+ override fun setLabelVisibility(view: PCSegmentedControlView, value: String?) {
89
+ view.applyLabelVisibility(value)
90
+ }
91
+
92
+ // Colors arrive already processed by React Native (ARGB ints)
93
+ override fun setSelectedSegmentColor(view: PCSegmentedControlView, value: Int?) {
94
+ view.applySelectedSegmentColor(value)
95
+ }
96
+
97
+ override fun setActiveTintColor(view: PCSegmentedControlView, value: Int?) {
98
+ view.applyActiveTintColor(value)
99
+ }
100
+
101
+ override fun setInactiveTintColor(view: PCSegmentedControlView, value: Int?) {
102
+ view.applyInactiveTintColor(value)
103
+ }
104
+
105
+ override fun setAndroidRippleColor(view: PCSegmentedControlView, value: Int?) {
106
+ view.applyRippleColor(value)
107
+ }
108
+
109
+ override fun setAndroidStrokeColor(view: PCSegmentedControlView, value: Int?) {
110
+ view.applyStrokeColor(value)
111
+ }
112
+
113
+ override fun setBadgeBackgroundColor(view: PCSegmentedControlView, value: Int?) {
114
+ view.applyBadgeColors(value, view.badgeTextColor)
115
+ }
116
+
117
+ override fun setBadgeTextColor(view: PCSegmentedControlView, value: Int?) {
118
+ view.applyBadgeColors(view.badgeBackgroundColor, value)
119
+ }
120
+
121
+ // labelStyle: {fontFamily, fontSize, fontWeight, fontStyle}; empty / 0 = default
122
+ override fun setLabelStyle(view: PCSegmentedControlView, value: ReadableMap?) {
123
+ view.applyLabelStyle(
124
+ fontFamily = value?.stringOr("fontFamily", "") ?: "",
125
+ fontSize = value?.doubleOr("fontSize", 0.0)?.toFloat() ?: 0f,
126
+ fontWeight = value?.stringOr("fontWeight", "") ?: "",
127
+ fontStyle = value?.stringOr("fontStyle", "") ?: ""
128
+ )
129
+ }
130
+
71
131
  override fun setSelectedValue(view: PCSegmentedControlView, value: String?) {
72
132
  // Spec sentinel: empty string means "no selection"
73
133
  view.applySelectedValue(value ?: "")
@@ -78,8 +138,14 @@ class PCSegmentedControlViewManager :
78
138
  }
79
139
 
80
140
  override fun setAndroid(view: PCSegmentedControlView, value: ReadableMap?) {
81
- val selectionRequired = value != null && value.hasKey("selectionRequired") &&
82
- !value.isNull("selectionRequired") && value.getString("selectionRequired") == "true"
141
+ // Spec sentinel: "true" | "false"; missing means the default (required).
142
+ val selectionRequired = if (
143
+ value != null && value.hasKey("selectionRequired") && !value.isNull("selectionRequired")
144
+ ) {
145
+ value.getString("selectionRequired") != "false"
146
+ } else {
147
+ true
148
+ }
83
149
  view.applyAndroidProps(selectionRequired)
84
150
  }
85
151
 
@@ -19,11 +19,47 @@ enum PCConstants {
19
19
  /// Minimum row height in selection menu popover (used as baseline)
20
20
  static let popoverRowHeightMin: CGFloat = 44
21
21
 
22
- /// Dynamic row height that respects user's preferred content size
22
+ /// Horizontal inset applied to each side of a menu row's label
23
+ static let popoverRowHorizontalInset: CGFloat = 16
24
+
25
+ /// Vertical padding added to each menu row's text (top + bottom combined)
26
+ static let popoverRowVerticalPadding: CGFloat = 16
27
+
28
+ /// Estimated single-line row height that respects the user's preferred
29
+ /// content size. Used only as a table-view estimate; actual rows self-size.
23
30
  static var popoverRowHeight: CGFloat {
24
31
  let bodyFont = UIFont.preferredFont(forTextStyle: .body)
25
- // Row height = font line height + vertical padding (16pt total)
26
- return max(popoverRowHeightMin, ceil(bodyFont.lineHeight) + 16)
32
+ return max(popoverRowHeightMin, ceil(bodyFont.lineHeight) + popoverRowVerticalPadding)
33
+ }
34
+
35
+ /// Measured height of a single menu row for the given label and content
36
+ /// width, accounting for multi-line wrapping at the current Dynamic Type
37
+ /// size. Mirrors the Auto Layout sizing of `PCGlassMenuCell` so the popover
38
+ /// container can be sized to fit its content.
39
+ static func popoverRowHeight(forLabel label: String, width: CGFloat) -> CGFloat {
40
+ let bodyFont = UIFont.preferredFont(forTextStyle: .body)
41
+ let textWidth = max(1, width - popoverRowHorizontalInset * 2)
42
+ let bounding = (label as NSString).boundingRect(
43
+ with: CGSize(width: textWidth, height: .greatestFiniteMagnitude),
44
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
45
+ attributes: [.font: bodyFont],
46
+ context: nil
47
+ )
48
+ return max(popoverRowHeightMin, ceil(bounding.height) + popoverRowVerticalPadding)
49
+ }
50
+
51
+ /// Adaptive popover width. Widens for accessibility content size categories
52
+ /// so long labels wrap less aggressively (mirrors system menu behavior).
53
+ /// The caller is responsible for clamping to the available screen width.
54
+ static func popoverWidth(forCategory category: UIContentSizeCategory) -> CGFloat {
55
+ guard category.isAccessibilityCategory else { return popoverWidth }
56
+ switch category {
57
+ case .accessibilityMedium: return 300
58
+ case .accessibilityLarge: return 320
59
+ case .accessibilityExtraLarge: return 340
60
+ case .accessibilityExtraExtraLarge: return 360
61
+ default: return 380 // accessibilityExtraExtraExtraLarge and beyond
62
+ }
27
63
  }
28
64
 
29
65
  /// Vertical padding in selection menu popover (top + bottom)
@@ -0,0 +1,58 @@
1
+ import UIKit
2
+
3
+ /// Loads images referenced by React Native asset URIs for native controls that
4
+ /// take a `UIImage` (segmented control icons).
5
+ ///
6
+ /// Release builds bundle local assets next to the JS bundle, so those arrive as
7
+ /// `file://` URLs and load synchronously. Debug builds and remote sources arrive
8
+ /// as `http(s)://` (Metro) or `data:` URLs and load asynchronously.
9
+ ///
10
+ /// Must be used from the main thread.
11
+ final class PCImageLoader {
12
+ static let shared = PCImageLoader()
13
+
14
+ private var cache: [String: UIImage] = [:]
15
+ private var pending: [String: [(UIImage?) -> Void]] = [:]
16
+
17
+ /// Returns the image immediately when it is cached or readable from disk.
18
+ /// Otherwise starts a load and calls `completion` on the main thread with
19
+ /// the result; the return value is `nil` in that case.
20
+ @discardableResult
21
+ func image(
22
+ uri: String,
23
+ scale: CGFloat,
24
+ completion: @escaping (UIImage?) -> Void
25
+ ) -> UIImage? {
26
+ let key = "\(uri)@\(scale)x"
27
+ if let cached = cache[key] {
28
+ return cached
29
+ }
30
+
31
+ guard let url = URL(string: uri) else { return nil }
32
+
33
+ if url.isFileURL {
34
+ guard let data = try? Data(contentsOf: url),
35
+ let image = UIImage(data: data, scale: scale) else { return nil }
36
+ cache[key] = image
37
+ return image
38
+ }
39
+
40
+ if pending[key] != nil {
41
+ pending[key]?.append(completion)
42
+ return nil
43
+ }
44
+ pending[key] = [completion]
45
+
46
+ URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
47
+ let image = data.flatMap { UIImage(data: $0, scale: scale) }
48
+ DispatchQueue.main.async {
49
+ guard let self else { return }
50
+ if let image { self.cache[key] = image }
51
+ let callbacks = self.pending.removeValue(forKey: key) ?? []
52
+ callbacks.forEach { $0(image) }
53
+ }
54
+ }.resume()
55
+
56
+ return nil
57
+ }
58
+ }