react-native-platform-components 0.8.10 → 1.0.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 (67) hide show
  1. package/README.md +68 -891
  2. package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +17 -3
  3. package/android/src/main/java/com/platformcomponents/PCImageLoader.kt +88 -0
  4. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +292 -31
  5. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +74 -8
  6. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +2 -1
  7. package/android/src/main/java/com/platformcomponents/PCThemeSupport.kt +94 -0
  8. package/android/src/main/res/values/styles.xml +40 -0
  9. package/ios/PCImageLoader.swift +58 -0
  10. package/ios/PCSegmentedControl.mm +85 -25
  11. package/ios/PCSegmentedControl.swift +231 -308
  12. package/lib/commonjs/DatePickerNativeComponent.ts +4 -2
  13. package/lib/commonjs/SegmentedControl.js +138 -6
  14. package/lib/commonjs/SegmentedControl.js.map +1 -1
  15. package/lib/commonjs/SegmentedControlNativeComponent.ts +62 -8
  16. package/lib/commonjs/SelectionMenuNativeComponent.ts +4 -2
  17. package/lib/commonjs/index.web.js.map +1 -1
  18. package/lib/module/DatePickerNativeComponent.ts +4 -2
  19. package/lib/module/SegmentedControl.js +138 -7
  20. package/lib/module/SegmentedControl.js.map +1 -1
  21. package/lib/module/SegmentedControlNativeComponent.ts +62 -8
  22. package/lib/module/SelectionMenuNativeComponent.ts +4 -2
  23. package/lib/module/index.web.js.map +1 -1
  24. package/lib/typescript/commonjs/docs/docusaurus.config.d.ts +4 -0
  25. package/lib/typescript/commonjs/docs/docusaurus.config.d.ts.map +1 -0
  26. package/lib/typescript/commonjs/docs/sidebars.d.ts +4 -0
  27. package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -0
  28. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts +2 -2
  29. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +111 -6
  31. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +46 -7
  33. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts +2 -2
  35. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/src/index.web.d.ts +36 -1
  37. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  38. package/lib/typescript/module/docs/docusaurus.config.d.ts +4 -0
  39. package/lib/typescript/module/docs/docusaurus.config.d.ts.map +1 -0
  40. package/lib/typescript/module/docs/sidebars.d.ts +4 -0
  41. package/lib/typescript/module/docs/sidebars.d.ts.map +1 -0
  42. package/lib/typescript/module/src/DatePicker.d.ts +1 -1
  43. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -1
  44. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +2 -2
  45. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -1
  46. package/lib/typescript/module/src/SegmentedControl.d.ts +111 -6
  47. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -1
  48. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +46 -7
  49. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  50. package/lib/typescript/module/src/SelectionMenu.d.ts +1 -1
  51. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -1
  52. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +2 -2
  53. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/module/src/index.d.ts +6 -6
  55. package/lib/typescript/module/src/index.d.ts.map +1 -1
  56. package/lib/typescript/module/src/index.web.d.ts +36 -1
  57. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  58. package/package.json +62 -37
  59. package/plugin/build/index.cjs +58 -17
  60. package/plugin/build/index.d.ts +32 -15
  61. package/plugin/build/index.d.ts.map +1 -1
  62. package/plugin/tsconfig.json +9 -4
  63. package/src/DatePickerNativeComponent.ts +4 -2
  64. package/src/SegmentedControl.tsx +238 -11
  65. package/src/SegmentedControlNativeComponent.ts +62 -8
  66. package/src/SelectionMenuNativeComponent.ts +4 -2
  67. package/src/index.web.tsx +34 -1
@@ -465,7 +465,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
465
465
  addView(picker)
466
466
  }
467
467
 
468
- val dlg = AlertDialog.Builder(act)
468
+ val dlg = AlertDialog.Builder(PCThemeSupport.appCompatDialogContext(act, "DatePicker"))
469
469
  .setTitle(androidDialogTitle ?: "")
470
470
  .setView(container)
471
471
  .setPositiveButton(androidPositiveTitle ?: "OK") { _, _ ->
@@ -513,7 +513,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
513
513
  }
514
514
  }
515
515
 
516
- val dlg = AlertDialog.Builder(act)
516
+ val dlg = AlertDialog.Builder(PCThemeSupport.appCompatDialogContext(act, "DatePicker"))
517
517
  .setTitle(androidDialogTitle ?: "")
518
518
  .setView(picker)
519
519
  .setPositiveButton(androidPositiveTitle ?: "OK") { _, _ ->
@@ -580,7 +580,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
580
580
  addView(picker)
581
581
  }
582
582
 
583
- val dlg = AlertDialog.Builder(act)
583
+ val dlg = AlertDialog.Builder(PCThemeSupport.appCompatDialogContext(act, "DatePicker"))
584
584
  .setTitle(androidDialogTitle ?: "")
585
585
  .setView(container)
586
586
  .setPositiveButton(androidPositiveTitle ?: "Next") { _, _ ->
@@ -617,6 +617,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
617
617
 
618
618
  val builder = MaterialDatePicker.Builder.datePicker()
619
619
  .setSelection(ts)
620
+ .setTheme(m3CalendarTheme(act))
620
621
 
621
622
  androidDialogTitle?.let { builder.setTitleText(it) }
622
623
  androidPositiveTitle?.let { builder.setPositiveButtonText(it) }
@@ -660,6 +661,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
660
661
 
661
662
  val is24 = android.text.format.DateFormat.is24HourFormat(act)
662
663
  val builder = MaterialTimePicker.Builder()
664
+ .setTheme(m3TimePickerTheme(act))
663
665
  .setTimeFormat(if (is24) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
664
666
  .setHour(cal.get(Calendar.HOUR_OF_DAY))
665
667
  .setMinute(cal.get(Calendar.MINUTE))
@@ -698,6 +700,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
698
700
 
699
701
  val builder = MaterialDatePicker.Builder.datePicker()
700
702
  .setSelection(ts)
703
+ .setTheme(m3CalendarTheme(act))
701
704
 
702
705
  androidDialogTitle?.let { builder.setTitleText(it) }
703
706
  androidPositiveTitle?.let { builder.setPositiveButtonText(it) }
@@ -733,6 +736,17 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
733
736
  picker.show(act.supportFragmentManager, "PCDatePicker_M3_DATE_THEN_TIME")
734
737
  }
735
738
 
739
+ /**
740
+ * Material pickers resolve their theme from the activity. When the app theme is
741
+ * not a Material theme they would throw, so hand them a full Material 3 dialog
742
+ * theme instead (0 = use the activity theme).
743
+ */
744
+ private fun m3CalendarTheme(act: FragmentActivity): Int =
745
+ PCThemeSupport.materialDialogThemeOverride(act, "DatePicker", R.style.PCMaterial3CalendarDialogTheme)
746
+
747
+ private fun m3TimePickerTheme(act: FragmentActivity): Int =
748
+ PCThemeSupport.materialDialogThemeOverride(act, "DatePicker", R.style.PCMaterial3TimePickerDialogTheme)
749
+
736
750
  private fun buildM3CalendarConstraints(): CalendarConstraints? {
737
751
  val min = minDateMs
738
752
  val max = maxDateMs
@@ -0,0 +1,88 @@
1
+ package com.platformcomponents
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.net.Uri
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.util.Base64
10
+ import android.util.DisplayMetrics
11
+ import android.util.Log
12
+ import android.util.LruCache
13
+ import java.net.HttpURLConnection
14
+ import java.net.URL
15
+ import java.util.concurrent.Executors
16
+ import kotlin.math.roundToInt
17
+
18
+ /**
19
+ * Loads images referenced by React Native asset URIs for native controls that
20
+ * take a Drawable (segmented control icons).
21
+ *
22
+ * Release builds ship local assets as drawable resources, which callers resolve
23
+ * directly. Debug builds (Metro), `file://`, `content://`, `data:` and remote
24
+ * sources go through here on a background thread and are cached in memory.
25
+ */
26
+ object PCImageLoader {
27
+ private const val TAG = "PCImageLoader"
28
+ private const val TIMEOUT_MS = 10_000
29
+
30
+ private val executor = Executors.newFixedThreadPool(2)
31
+ private val mainHandler = Handler(Looper.getMainLooper())
32
+ private val cache = LruCache<String, Bitmap>(32)
33
+
34
+ /**
35
+ * Loads [uri] and delivers the bitmap on the main thread. [scale] is the
36
+ * asset scale React Native resolved (2 for an `@2x` file), used to set the
37
+ * bitmap density so the drawable measures in dp like an `Image` would.
38
+ *
39
+ * Cached images are delivered synchronously.
40
+ */
41
+ fun load(context: Context, uri: String, scale: Float, callback: (Bitmap?) -> Unit) {
42
+ val key = "$uri@${scale}x"
43
+ cache.get(key)?.let {
44
+ callback(it)
45
+ return
46
+ }
47
+
48
+ val appContext = context.applicationContext
49
+ executor.execute {
50
+ val bitmap = try {
51
+ decode(appContext, uri)
52
+ } catch (e: Exception) {
53
+ Log.w(TAG, "Failed to load $uri: ${e.message}")
54
+ null
55
+ }
56
+ if (bitmap != null) {
57
+ bitmap.density = (scale.coerceAtLeast(0.01f) * DisplayMetrics.DENSITY_DEFAULT).roundToInt()
58
+ cache.put(key, bitmap)
59
+ }
60
+ mainHandler.post { callback(bitmap) }
61
+ }
62
+ }
63
+
64
+ private fun decode(context: Context, uri: String): Bitmap? {
65
+ val parsed = Uri.parse(uri)
66
+ return when (parsed.scheme?.lowercase()) {
67
+ "http", "https" -> {
68
+ val connection = URL(uri).openConnection() as HttpURLConnection
69
+ connection.connectTimeout = TIMEOUT_MS
70
+ connection.readTimeout = TIMEOUT_MS
71
+ try {
72
+ connection.inputStream.use { BitmapFactory.decodeStream(it) }
73
+ } finally {
74
+ connection.disconnect()
75
+ }
76
+ }
77
+ "file" -> parsed.path?.let { BitmapFactory.decodeFile(it) }
78
+ "content" -> context.contentResolver.openInputStream(parsed)?.use { BitmapFactory.decodeStream(it) }
79
+ "data" -> {
80
+ val comma = uri.indexOf(',')
81
+ if (comma < 0) return null
82
+ val bytes = Base64.decode(uri.substring(comma + 1), Base64.DEFAULT)
83
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
84
+ }
85
+ else -> null
86
+ }
87
+ }
88
+ }
@@ -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,70 @@ 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
- val group = MaterialButtonToggleGroup(context).apply {
193
+ // Material widgets need a Material theme; fall back to Material 3 defaults instead of crashing.
194
+ val themedContext = PCThemeSupport.materialContext(context, "SegmentedControl")
195
+ val group = MaterialButtonToggleGroup(themedContext).apply {
87
196
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
88
197
  isSingleSelection = true
89
198
  isSelectionRequired = selectionRequired
199
+ clipChildren = false
200
+ clipToPadding = false
90
201
  }
91
202
 
92
203
  // Calculate if we need compact mode (many segments or long labels)
@@ -94,49 +205,59 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
94
205
  val useCompactMode = segments.size > 3 || totalLabelLength > 20
95
206
 
96
207
  for ((index, segment) in segments.withIndex()) {
97
- val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
208
+ // "unlabeled" hides the text of segments that have an icon; segments
209
+ // without one keep their label. "auto" and "labeled" show icon + label.
210
+ val iconOnly = labelVisibility == "unlabeled" && segment.hasIcon
211
+
212
+ val button = MaterialButton(themedContext, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
98
213
  id = View.generateViewId()
99
- text = segment.label
214
+ text = if (iconOnly) "" else segment.label
100
215
  isAllCaps = false // Preserve original text casing
101
- contentDescription = segment.label // For accessibility and Detox matching
216
+ // Screen readers (and Detox) always get the label, even when hidden
217
+ contentDescription = segment.spokenLabel
102
218
  isEnabled = !segment.disabled && interactivity == "enabled"
103
219
 
104
220
  // Enable text truncation with ellipsis when space is limited
105
221
  ellipsize = TextUtils.TruncateAt.END
106
222
  maxLines = 1
107
223
 
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()
224
+ // Material 3 segmented buttons use 12dp horizontal padding and an 8dp
225
+ // icon gap (the outlined button style defaults to 24dp); tighten
226
+ // further when there are many segments or long labels.
227
+ val density = resources.displayMetrics.density
228
+ val horizontalPadding = ((if (useCompactMode) 8 else 12) * density).toInt()
229
+ setPaddingRelative(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
230
+ iconPadding = ((if (useCompactMode) 4 else 8) * density).toInt()
231
+
232
+ if (iconOnly) {
233
+ // Centre the icon instead of leaving it at the start edge
234
+ iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
235
+ iconPadding = 0
113
236
  }
114
237
 
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
- }
238
+ applyIcon(this, segment, generation)
239
+ styleButton(this, segment)
131
240
  }
132
241
 
133
242
  buttonIdToSegment[button.id] = segment
134
- group.addView(button)
243
+ // Share the width equally (like UISegmentedControl) so trailing segments
244
+ // never overflow the control; long labels ellipsize instead.
245
+ group.addView(button, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
246
+ attachBadge(button, segment)
135
247
  }
136
248
 
137
- group.addOnButtonCheckedListener { _, checkedId, isChecked ->
249
+ group.addOnButtonCheckedListener { toggleGroup, checkedId, isChecked ->
138
250
  if (suppressCallbacks) return@addOnButtonCheckedListener
139
- if (!isChecked) return@addOnButtonCheckedListener
251
+
252
+ if (!isChecked) {
253
+ // In single-selection mode switching from A to B reports A as unchecked
254
+ // before B is reported as checked; by then the group already knows B is
255
+ // checked. Only a tap that leaves nothing checked is a real deselection.
256
+ if (toggleGroup.checkedButtonId == View.NO_ID) {
257
+ onSelect?.invoke(-1, "")
258
+ }
259
+ return@addOnButtonCheckedListener
260
+ }
140
261
 
141
262
  val segment = buttonIdToSegment[checkedId] ?: return@addOnButtonCheckedListener
142
263
  val index = segments.indexOf(segment)
@@ -153,6 +274,117 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
153
274
  requestLayout()
154
275
  }
155
276
 
277
+ /**
278
+ * Resolves a segment's icon. Drawable names and release-bundled assets (which
279
+ * React Native ships as drawable resources) resolve synchronously; other
280
+ * image URIs load in the background and are applied when they arrive.
281
+ */
282
+ private fun applyIcon(button: MaterialButton, segment: Segment, generation: Int) {
283
+ if (!segment.iconTinted) {
284
+ button.iconTint = null
285
+ }
286
+
287
+ when (segment.iconType) {
288
+ "drawable" -> {
289
+ button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, segment.iconName)
290
+ }
291
+ "image" -> {
292
+ val uri = segment.iconUri
293
+ val isResourceName = !uri.contains(':')
294
+ if (isResourceName) {
295
+ button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, uri)
296
+ } else {
297
+ PCImageLoader.load(context, uri, segment.iconScale) { bitmap ->
298
+ if (bitmap == null || generation != rebuildGeneration) return@load
299
+ button.icon = BitmapDrawable(resources, bitmap)
300
+ requestLayout()
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Draws a Material badge over the button's top-end corner. The badge lives
309
+ * in the button's overlay, so it needs the button's final bounds: attach
310
+ * after the first layout and follow later size changes.
311
+ */
312
+ private fun attachBadge(button: MaterialButton, segment: Segment) {
313
+ if (segment.badge.isEmpty()) return
314
+
315
+ val inset = (2 * resources.displayMetrics.density).toInt()
316
+ val badge = BadgeDrawable.create(button.context).apply {
317
+ text = segment.badge
318
+ badgeGravity = BadgeDrawable.TOP_END
319
+ badgeBackgroundColor?.let { backgroundColor = it }
320
+ badgeTextColor?.let { setBadgeTextColor(it) }
321
+ }
322
+
323
+ var attached = false
324
+ button.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
325
+ if (!attached) {
326
+ attached = true
327
+ BadgeUtils.attachBadgeDrawable(badge, view)
328
+ // The badge's size is only known once it has an anchor. Offset it by
329
+ // half its size so it sits fully inside the button corner; a badge
330
+ // that overhangs the last segment would be clipped by the control.
331
+ badge.horizontalOffset = badge.intrinsicWidth / 2 + inset
332
+ badge.verticalOffset = badge.intrinsicHeight / 2 + inset
333
+ }
334
+ badge.updateBadgeCoordinates(view)
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Applies the color and font props on top of the Material theme defaults.
340
+ * Buttons are rebuilt whenever a style prop changes, so the theme values
341
+ * read here are always the untouched defaults.
342
+ */
343
+ private fun styleButton(button: MaterialButton, segment: Segment) {
344
+ val checkedState = intArrayOf(android.R.attr.state_checked)
345
+ val disabledState = intArrayOf(-android.R.attr.state_enabled)
346
+
347
+ if (activeTintColor != null || inactiveTintColor != null) {
348
+ val theme = button.textColors
349
+ val disabled = theme.getColorForState(disabledState, theme.defaultColor)
350
+ val checked = activeTintColor ?: theme.getColorForState(checkedState, theme.defaultColor)
351
+ val normal = inactiveTintColor ?: theme.defaultColor
352
+ val tint = ColorStateList(
353
+ arrayOf(disabledState, checkedState, intArrayOf()),
354
+ intArrayOf(disabled, checked, normal)
355
+ )
356
+ button.setTextColor(tint)
357
+ if (segment.iconTinted) {
358
+ button.iconTint = tint
359
+ }
360
+ }
361
+
362
+ selectedSegmentColor?.let { color ->
363
+ val theme = button.backgroundTintList
364
+ val normal = theme?.defaultColor ?: Color.TRANSPARENT
365
+ button.backgroundTintList = ColorStateList(
366
+ arrayOf(checkedState, intArrayOf()),
367
+ intArrayOf(color, normal)
368
+ )
369
+ }
370
+
371
+ rippleColor?.let { button.rippleColor = ColorStateList.valueOf(it) }
372
+ strokeColor?.let { button.strokeColor = ColorStateList.valueOf(it) }
373
+
374
+ if (labelFontSize > 0) {
375
+ button.setTextSize(TypedValue.COMPLEX_UNIT_SP, labelFontSize)
376
+ }
377
+ if (labelFontFamily.isNotEmpty() || labelFontWeight.isNotEmpty() || labelFontStyle.isNotEmpty()) {
378
+ button.typeface = ReactTypefaceUtils.applyStyles(
379
+ button.typeface,
380
+ ReactTypefaceUtils.parseFontStyle(labelFontStyle.ifEmpty { null }),
381
+ ReactTypefaceUtils.parseFontWeight(labelFontWeight.ifEmpty { null }),
382
+ labelFontFamily.ifEmpty { null },
383
+ context.assets
384
+ )
385
+ }
386
+ }
387
+
156
388
  private fun updateSelection() {
157
389
  suppressCallbacks = true
158
390
  val group = toggleGroup ?: return
@@ -182,6 +414,35 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
182
414
  }
183
415
  }
184
416
 
417
+ // ---- Layout ----
418
+
419
+ private var manualLayoutPending = false
420
+
421
+ /**
422
+ * React Native's root view ignores requestLayout after the initial pass, so
423
+ * a native view that changes its own subtree later (rebuilding segments for
424
+ * a prop change, an icon finishing loading) has to measure and lay itself
425
+ * out. Coalesced to one pass per frame.
426
+ */
427
+ override fun requestLayout() {
428
+ super.requestLayout()
429
+ if (!manualLayoutPending) {
430
+ manualLayoutPending = true
431
+ post { runManualLayout() }
432
+ }
433
+ }
434
+
435
+ private fun runManualLayout() {
436
+ manualLayoutPending = false
437
+ // Nothing to do until Yoga has placed us; the regular pass covers that.
438
+ if (width == 0 || height == 0) return
439
+ measure(
440
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
441
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
442
+ )
443
+ layout(left, top, right, bottom)
444
+ }
445
+
185
446
  // ---- Measurement ----
186
447
 
187
448
  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