react-native-platform-components 0.6.1 → 0.7.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.
- package/README.md +153 -44
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +241 -0
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +105 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/ios/PCDatePickerView.swift +16 -13
- package/ios/PCSegmentedControl.h +10 -0
- package/ios/PCSegmentedControl.mm +194 -0
- package/ios/PCSegmentedControl.swift +200 -0
- package/lib/commonjs/SegmentedControl.js +93 -0
- package/lib/commonjs/SegmentedControl.js.map +1 -0
- package/lib/commonjs/SegmentedControlNativeComponent.ts +79 -0
- package/lib/commonjs/index.js +11 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/SegmentedControl.js +87 -0
- package/lib/module/SegmentedControl.js.map +1 -0
- package/lib/module/SegmentedControlNativeComponent.ts +79 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts +62 -0
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +63 -0
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +1 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/SegmentedControl.d.ts +62 -0
- package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +63 -0
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +1 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/package.json +4 -3
- package/react-native.config.js +1 -0
- package/shared/PCSegmentedControlComponentDescriptors-custom.h +22 -0
- package/shared/PCSegmentedControlShadowNode-custom.cpp +54 -0
- package/shared/PCSegmentedControlShadowNode-custom.h +56 -0
- package/shared/PCSegmentedControlState-custom.h +62 -0
- package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +1 -0
- package/src/SegmentedControl.tsx +178 -0
- package/src/SegmentedControlNativeComponent.ts +79 -0
- package/src/index.tsx +1 -0
package/README.md
CHANGED
|
@@ -3,55 +3,59 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/react-native-platform-components)
|
|
4
4
|
[](https://www.npmjs.com/package/react-native-platform-components)
|
|
5
5
|
|
|
6
|
+
High-quality **native UI components for React Native**, implemented with platform-first APIs and exposed through clean, typed JavaScript interfaces.
|
|
7
|
+
|
|
8
|
+
This library focuses on **true native behavior**, not JavaScript re-implementations.
|
|
9
|
+
|
|
6
10
|
<table>
|
|
7
11
|
<tr>
|
|
8
|
-
<td
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<td
|
|
37
|
-
|
|
38
|
-
<p>This library focuses on <strong>true native behavior</strong>, not JavaScript re-implementations — providing:</p>
|
|
39
|
-
<ul>
|
|
40
|
-
<li><strong>DatePicker</strong> – native date & time pickers with modal and embedded presentations</li>
|
|
41
|
-
<li><strong>ContextMenu</strong> – native context menus with long-press activation (UIContextMenuInteraction on iOS, PopupMenu on Android)</li>
|
|
42
|
-
<li><strong>SelectionMenu</strong> – native selection menus (Material on Android, system menus on iOS)</li>
|
|
43
|
-
</ul>
|
|
44
|
-
<p>The goal is to provide components that:</p>
|
|
45
|
-
<ul>
|
|
46
|
-
<li>Feel <strong>100% native</strong> on each platform</li>
|
|
47
|
-
<li>Support modern platform design systems (Material 3 on Android, system pickers on iOS)</li>
|
|
48
|
-
<li>Offer <strong>headless</strong> and <strong>inline</strong> modes for maximum layout control</li>
|
|
49
|
-
<li>Integrate cleanly with <strong>React Native Codegen / Fabric</strong></li>
|
|
50
|
-
</ul>
|
|
51
|
-
</td>
|
|
12
|
+
<td align="center"><strong>iOS DatePicker</strong></td>
|
|
13
|
+
<td align="center"><strong>Android DatePicker</strong></td>
|
|
14
|
+
</tr>
|
|
15
|
+
<tr>
|
|
16
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-datepicker.gif" height="550" /></td>
|
|
17
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-datepicker.gif" height="550" /></td>
|
|
18
|
+
</tr>
|
|
19
|
+
<tr>
|
|
20
|
+
<td align="center"><strong>iOS ContextMenu</strong></td>
|
|
21
|
+
<td align="center"><strong>Android ContextMenu</strong></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<tr>
|
|
24
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-contextmenu.gif" height="550" /></td>
|
|
25
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-contextmenu.gif" height="550" /></td>
|
|
26
|
+
</tr>
|
|
27
|
+
<tr>
|
|
28
|
+
<td align="center"><strong>iOS SelectionMenu</strong></td>
|
|
29
|
+
<td align="center"><strong>Android SelectionMenu</strong></td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr>
|
|
32
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-selectionmenu.gif" height="550" /></td>
|
|
33
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-selectionmenu.gif" height="550" /></td>
|
|
34
|
+
</tr>
|
|
35
|
+
<tr>
|
|
36
|
+
<td align="center"><strong>iOS SegmentedControl</strong></td>
|
|
37
|
+
<td align="center"><strong>Android SegmentedControl</strong></td>
|
|
38
|
+
</tr>
|
|
39
|
+
<tr>
|
|
40
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-segmentedcontrol.gif" height="550" /></td>
|
|
41
|
+
<td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-segmentedcontrol.gif" height="550" /></td>
|
|
52
42
|
</tr>
|
|
53
43
|
</table>
|
|
54
44
|
|
|
45
|
+
### Components
|
|
46
|
+
|
|
47
|
+
- **DatePicker** – native date & time pickers with modal and embedded presentations
|
|
48
|
+
- **ContextMenu** – native context menus with long-press activation (UIContextMenuInteraction on iOS, PopupMenu on Android)
|
|
49
|
+
- **SelectionMenu** – native selection menus (Material on Android, system menus on iOS)
|
|
50
|
+
- **SegmentedControl** – native segmented controls (UISegmentedControl on iOS, MaterialButtonToggleGroup on Android)
|
|
51
|
+
|
|
52
|
+
### Goals
|
|
53
|
+
|
|
54
|
+
- Feel **100% native** on each platform
|
|
55
|
+
- Support modern platform design systems (Material 3 on Android, system pickers on iOS)
|
|
56
|
+
- Offer **headless** and **inline** modes for maximum layout control
|
|
57
|
+
- Integrate cleanly with **React Native Codegen / Fabric**
|
|
58
|
+
|
|
55
59
|
---
|
|
56
60
|
|
|
57
61
|
## Installation
|
|
@@ -325,6 +329,65 @@ export function Example() {
|
|
|
325
329
|
|
|
326
330
|
---
|
|
327
331
|
|
|
332
|
+
### SegmentedControl
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
import { SegmentedControl } from 'react-native-platform-components';
|
|
336
|
+
|
|
337
|
+
const segments = [
|
|
338
|
+
{ label: 'Day', value: 'day' },
|
|
339
|
+
{ label: 'Week', value: 'week' },
|
|
340
|
+
{ label: 'Month', value: 'month' },
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
export function Example() {
|
|
344
|
+
const [selected, setSelected] = React.useState('day');
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<SegmentedControl
|
|
348
|
+
segments={segments}
|
|
349
|
+
selectedValue={selected}
|
|
350
|
+
onSelect={(value) => setSelected(value)}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### SegmentedControl (With Icons)
|
|
357
|
+
|
|
358
|
+
```tsx
|
|
359
|
+
import { SegmentedControl } from 'react-native-platform-components';
|
|
360
|
+
import { Platform } from 'react-native';
|
|
361
|
+
|
|
362
|
+
const segments = [
|
|
363
|
+
{
|
|
364
|
+
label: 'List',
|
|
365
|
+
value: 'list',
|
|
366
|
+
icon: Platform.OS === 'ios' ? 'list.bullet' : 'list_bullet',
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
label: 'Grid',
|
|
370
|
+
value: 'grid',
|
|
371
|
+
icon: Platform.OS === 'ios' ? 'square.grid.2x2' : 'grid_view',
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
export function Example() {
|
|
376
|
+
const [selected, setSelected] = React.useState('list');
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<SegmentedControl
|
|
380
|
+
segments={segments}
|
|
381
|
+
selectedValue={selected}
|
|
382
|
+
onSelect={(value) => setSelected(value)}
|
|
383
|
+
ios={{ apportionsSegmentWidthsByContent: true }}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
328
391
|
## Components
|
|
329
392
|
|
|
330
393
|
## DatePicker
|
|
@@ -450,6 +513,50 @@ Native selection menu with **modal** and **embedded** modes.
|
|
|
450
513
|
|
|
451
514
|
---
|
|
452
515
|
|
|
516
|
+
## SegmentedControl
|
|
517
|
+
|
|
518
|
+
Native segmented control using **UISegmentedControl** on iOS and **MaterialButtonToggleGroup** on Android.
|
|
519
|
+
|
|
520
|
+
### Props
|
|
521
|
+
|
|
522
|
+
| Prop | Type | Description |
|
|
523
|
+
|------|------|-------------|
|
|
524
|
+
| `segments` | `SegmentedControlSegment[]` | Array of segments to display |
|
|
525
|
+
| `selectedValue` | `string \| null` | Currently selected segment's `value` |
|
|
526
|
+
| `disabled` | `boolean` | Disables the entire control |
|
|
527
|
+
| `onSelect` | `(value: string, index: number) => void` | Called when user selects a segment |
|
|
528
|
+
|
|
529
|
+
### SegmentedControlSegment
|
|
530
|
+
|
|
531
|
+
| Property | Type | Description |
|
|
532
|
+
|----------|------|-------------|
|
|
533
|
+
| `label` | `string` | Display text for the segment |
|
|
534
|
+
| `value` | `string` | Unique value returned in callbacks |
|
|
535
|
+
| `disabled` | `boolean` | Disables this specific segment |
|
|
536
|
+
| `icon` | `string` | Icon name (SF Symbol on iOS, drawable on Android) |
|
|
537
|
+
|
|
538
|
+
### iOS Props (`ios`)
|
|
539
|
+
|
|
540
|
+
| Prop | Type | Description |
|
|
541
|
+
|------|------|-------------|
|
|
542
|
+
| `momentary` | `boolean` | If true, segments don't show selected state |
|
|
543
|
+
| `apportionsSegmentWidthsByContent` | `boolean` | If true, segment widths are proportional to content |
|
|
544
|
+
| `selectedSegmentTintColor` | `string` | Tint color for selected segment (hex string) |
|
|
545
|
+
|
|
546
|
+
### Android Props (`android`)
|
|
547
|
+
|
|
548
|
+
| Prop | Type | Description |
|
|
549
|
+
|------|------|-------------|
|
|
550
|
+
| `selectionRequired` | `boolean` | If true, one segment must always be selected |
|
|
551
|
+
|
|
552
|
+
### Icon Support
|
|
553
|
+
|
|
554
|
+
Icons work the same as ContextMenu:
|
|
555
|
+
- **iOS**: Use SF Symbol names (e.g., `'list.bullet'`, `'square.grid.2x2'`)
|
|
556
|
+
- **Android**: Use drawable resource names (e.g., `'list_bullet'`, `'grid_view'`)
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
453
560
|
## Design Philosophy
|
|
454
561
|
|
|
455
562
|
- **Native first** — no JS re-implementation of pickers
|
|
@@ -554,6 +661,8 @@ const actions = [
|
|
|
554
661
|
|
|
555
662
|
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.
|
|
556
663
|
|
|
664
|
+
**Have a component request?** If there's a native UI component you'd like to see added, [open an issue](https://github.com/JarX-Concepts/react-native-platform-components/issues/new) describing the component and its native APIs on iOS and Android.
|
|
665
|
+
|
|
557
666
|
## License
|
|
558
667
|
|
|
559
668
|
MIT
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
package com.platformcomponents
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.text.TextUtils
|
|
5
|
+
import android.view.View
|
|
6
|
+
import android.widget.FrameLayout
|
|
7
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
8
|
+
import com.facebook.react.uimanager.PixelUtil
|
|
9
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
10
|
+
import com.facebook.react.views.scroll.ReactScrollViewHelper
|
|
11
|
+
import com.google.android.material.button.MaterialButton
|
|
12
|
+
import com.google.android.material.button.MaterialButtonToggleGroup
|
|
13
|
+
|
|
14
|
+
class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScrollViewHelper.HasStateWrapper {
|
|
15
|
+
|
|
16
|
+
data class Segment(
|
|
17
|
+
val label: String,
|
|
18
|
+
val value: String,
|
|
19
|
+
val disabled: Boolean,
|
|
20
|
+
val icon: String
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
companion object {
|
|
24
|
+
private const val TAG = "PCSegmentedControl"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- State Wrapper for Fabric state updates ---
|
|
28
|
+
override var stateWrapper: StateWrapper? = null
|
|
29
|
+
|
|
30
|
+
private var lastReportedWidth: Float = 0f
|
|
31
|
+
private var lastReportedHeight: Float = 0f
|
|
32
|
+
|
|
33
|
+
// --- Props ---
|
|
34
|
+
var segments: List<Segment> = emptyList()
|
|
35
|
+
var selectedValue: String = "" // sentinel for none
|
|
36
|
+
var interactivity: String = "enabled" // "enabled" | "disabled"
|
|
37
|
+
var selectionRequired: Boolean = false
|
|
38
|
+
|
|
39
|
+
// --- Events ---
|
|
40
|
+
var onSelect: ((index: Int, value: String) -> Unit)? = null
|
|
41
|
+
|
|
42
|
+
// --- UI ---
|
|
43
|
+
private var toggleGroup: MaterialButtonToggleGroup? = null
|
|
44
|
+
private val buttonIdToSegment: MutableMap<Int, Segment> = mutableMapOf()
|
|
45
|
+
private var suppressCallbacks = false
|
|
46
|
+
|
|
47
|
+
init {
|
|
48
|
+
minimumHeight = (PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP * resources.displayMetrics.density).toInt()
|
|
49
|
+
rebuildUI()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- Public apply* (called by manager) ----
|
|
53
|
+
|
|
54
|
+
fun applySegments(newSegments: List<Segment>) {
|
|
55
|
+
if (segments == newSegments) return
|
|
56
|
+
segments = newSegments
|
|
57
|
+
rebuildUI()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun applySelectedValue(value: String) {
|
|
61
|
+
if (selectedValue == value) return
|
|
62
|
+
selectedValue = value
|
|
63
|
+
updateSelection()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun applyInteractivity(value: String?) {
|
|
67
|
+
val newValue = if (value == "disabled") "disabled" else "enabled"
|
|
68
|
+
if (interactivity == newValue) return
|
|
69
|
+
interactivity = newValue
|
|
70
|
+
updateEnabled()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun applyAndroidProps(required: Boolean) {
|
|
74
|
+
if (selectionRequired != required) {
|
|
75
|
+
selectionRequired = required
|
|
76
|
+
rebuildUI()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- UI Building ----
|
|
81
|
+
|
|
82
|
+
private fun rebuildUI() {
|
|
83
|
+
removeAllViews()
|
|
84
|
+
buttonIdToSegment.clear()
|
|
85
|
+
|
|
86
|
+
val group = MaterialButtonToggleGroup(context).apply {
|
|
87
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
88
|
+
isSingleSelection = true
|
|
89
|
+
isSelectionRequired = selectionRequired
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Calculate if we need compact mode (many segments or long labels)
|
|
93
|
+
val totalLabelLength = segments.sumOf { it.label.length }
|
|
94
|
+
val useCompactMode = segments.size > 3 || totalLabelLength > 20
|
|
95
|
+
|
|
96
|
+
for ((index, segment) in segments.withIndex()) {
|
|
97
|
+
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
|
98
|
+
id = View.generateViewId()
|
|
99
|
+
text = segment.label
|
|
100
|
+
isAllCaps = false // Preserve original text casing
|
|
101
|
+
contentDescription = segment.label // For accessibility and Detox matching
|
|
102
|
+
isEnabled = !segment.disabled && interactivity == "enabled"
|
|
103
|
+
|
|
104
|
+
// Enable text truncation with ellipsis when space is limited
|
|
105
|
+
ellipsize = TextUtils.TruncateAt.END
|
|
106
|
+
maxLines = 1
|
|
107
|
+
|
|
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()
|
|
113
|
+
}
|
|
114
|
+
|
|
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
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
buttonIdToSegment[button.id] = segment
|
|
134
|
+
group.addView(button)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
group.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
138
|
+
if (suppressCallbacks) return@addOnButtonCheckedListener
|
|
139
|
+
if (!isChecked) return@addOnButtonCheckedListener
|
|
140
|
+
|
|
141
|
+
val segment = buttonIdToSegment[checkedId] ?: return@addOnButtonCheckedListener
|
|
142
|
+
val index = segments.indexOf(segment)
|
|
143
|
+
if (index >= 0) {
|
|
144
|
+
onSelect?.invoke(index, segment.value)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
addView(group)
|
|
149
|
+
toggleGroup = group
|
|
150
|
+
|
|
151
|
+
updateSelection()
|
|
152
|
+
updateEnabled()
|
|
153
|
+
requestLayout()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private fun updateSelection() {
|
|
157
|
+
suppressCallbacks = true
|
|
158
|
+
val group = toggleGroup ?: return
|
|
159
|
+
|
|
160
|
+
if (selectedValue.isEmpty()) {
|
|
161
|
+
group.clearChecked()
|
|
162
|
+
} else {
|
|
163
|
+
for ((id, segment) in buttonIdToSegment) {
|
|
164
|
+
if (segment.value == selectedValue) {
|
|
165
|
+
group.check(id)
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
suppressCallbacks = false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun updateEnabled() {
|
|
174
|
+
val enabled = interactivity == "enabled"
|
|
175
|
+
alpha = if (enabled) 1f else 0.5f
|
|
176
|
+
|
|
177
|
+
val group = toggleGroup ?: return
|
|
178
|
+
for (i in 0 until group.childCount) {
|
|
179
|
+
val button = group.getChildAt(i) as? MaterialButton ?: continue
|
|
180
|
+
val segment = buttonIdToSegment[button.id] ?: continue
|
|
181
|
+
button.isEnabled = enabled && !segment.disabled
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- Measurement ----
|
|
186
|
+
|
|
187
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
188
|
+
// Measure children with UNSPECIFIED height to get intrinsic size
|
|
189
|
+
val unconstrainedHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
190
|
+
super.onMeasure(widthMeasureSpec, unconstrainedHeightSpec)
|
|
191
|
+
|
|
192
|
+
// Get the intrinsic height from children
|
|
193
|
+
val childHeight = if (toggleGroup != null) {
|
|
194
|
+
toggleGroup!!.measuredHeight
|
|
195
|
+
} else {
|
|
196
|
+
0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Use the maximum of child height and minimum touch target
|
|
200
|
+
val intrinsicHeight = childHeight.coerceAtLeast(minimumHeight)
|
|
201
|
+
|
|
202
|
+
// IMPORTANT: Always use intrinsic height regardless of Yoga constraints.
|
|
203
|
+
// Fabric may give us 0 height initially before state is updated.
|
|
204
|
+
// The state update from onLayout will trigger proper re-layout.
|
|
205
|
+
setMeasuredDimension(measuredWidth, intrinsicHeight)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
209
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
210
|
+
updateFrameSizeState()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Update Fabric state with the measured frame size.
|
|
215
|
+
* This allows the shadow node to use actual measured dimensions for Yoga layout.
|
|
216
|
+
*/
|
|
217
|
+
private fun updateFrameSizeState() {
|
|
218
|
+
val wrapper = stateWrapper ?: return
|
|
219
|
+
val group = toggleGroup ?: return
|
|
220
|
+
|
|
221
|
+
// Measure the toggle group with exact width and unspecified height
|
|
222
|
+
val widthSpec = MeasureSpec.makeMeasureSpec(width.coerceAtLeast(1), MeasureSpec.EXACTLY)
|
|
223
|
+
val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
|
224
|
+
group.measure(widthSpec, heightSpec)
|
|
225
|
+
|
|
226
|
+
val widthDp = PixelUtil.toDIPFromPixel(width.toFloat())
|
|
227
|
+
val heightDp = PixelUtil.toDIPFromPixel(group.measuredHeight.toFloat())
|
|
228
|
+
|
|
229
|
+
// Only update if changed
|
|
230
|
+
if (widthDp != lastReportedWidth || heightDp != lastReportedHeight) {
|
|
231
|
+
lastReportedWidth = widthDp
|
|
232
|
+
lastReportedHeight = heightDp
|
|
233
|
+
|
|
234
|
+
val stateData = WritableNativeMap().apply {
|
|
235
|
+
putDouble("width", widthDp.toDouble())
|
|
236
|
+
putDouble("height", heightDp.toDouble())
|
|
237
|
+
}
|
|
238
|
+
wrapper.updateState(stateData)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package com.platformcomponents
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReadableArray
|
|
4
|
+
import com.facebook.react.bridge.ReadableMap
|
|
5
|
+
import com.facebook.react.uimanager.ReactStylesDiffMap
|
|
6
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
7
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
8
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
9
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
10
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
11
|
+
import com.facebook.react.uimanager.events.Event
|
|
12
|
+
import com.facebook.react.uimanager.events.RCTEventEmitter
|
|
13
|
+
import com.facebook.react.viewmanagers.PCSegmentedControlManagerDelegate
|
|
14
|
+
import com.facebook.react.viewmanagers.PCSegmentedControlManagerInterface
|
|
15
|
+
|
|
16
|
+
class PCSegmentedControlViewManager :
|
|
17
|
+
SimpleViewManager<PCSegmentedControlView>(),
|
|
18
|
+
PCSegmentedControlManagerInterface<PCSegmentedControlView> {
|
|
19
|
+
|
|
20
|
+
companion object {
|
|
21
|
+
private const val TAG = "PCSegmentedControl"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private val delegate: ViewManagerDelegate<PCSegmentedControlView> =
|
|
25
|
+
PCSegmentedControlManagerDelegate(this)
|
|
26
|
+
|
|
27
|
+
override fun getName(): String = "PCSegmentedControl"
|
|
28
|
+
|
|
29
|
+
override fun getDelegate(): ViewManagerDelegate<PCSegmentedControlView> = delegate
|
|
30
|
+
|
|
31
|
+
override fun createViewInstance(reactContext: ThemedReactContext): PCSegmentedControlView {
|
|
32
|
+
return PCSegmentedControlView(reactContext)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pass the StateWrapper to the view so it can update Fabric state with measured dimensions.
|
|
37
|
+
*/
|
|
38
|
+
override fun updateState(
|
|
39
|
+
view: PCSegmentedControlView,
|
|
40
|
+
props: ReactStylesDiffMap,
|
|
41
|
+
stateWrapper: StateWrapper
|
|
42
|
+
): Any? {
|
|
43
|
+
view.stateWrapper = stateWrapper
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun addEventEmitters(reactContext: ThemedReactContext, view: PCSegmentedControlView) {
|
|
48
|
+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
|
|
49
|
+
|
|
50
|
+
view.onSelect = { index, value ->
|
|
51
|
+
dispatcher?.dispatchEvent(SelectEvent(view.id, index, value))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// segments: array of {label, value, disabled, icon}
|
|
56
|
+
override fun setSegments(view: PCSegmentedControlView, value: ReadableArray?) {
|
|
57
|
+
val out = ArrayList<PCSegmentedControlView.Segment>()
|
|
58
|
+
if (value != null) {
|
|
59
|
+
for (i in 0 until value.size()) {
|
|
60
|
+
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))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
view.applySegments(out)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override fun setSelectedValue(view: PCSegmentedControlView, value: String?) {
|
|
72
|
+
// Spec sentinel: empty string means "no selection"
|
|
73
|
+
view.applySelectedValue(value ?: "")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun setInteractivity(view: PCSegmentedControlView, value: String?) {
|
|
77
|
+
view.applyInteractivity(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override fun setAndroid(view: PCSegmentedControlView, value: ReadableMap?) {
|
|
81
|
+
val selectionRequired = value != null && value.hasKey("selectionRequired") &&
|
|
82
|
+
!value.isNull("selectionRequired") && value.getString("selectionRequired") == "true"
|
|
83
|
+
view.applyAndroidProps(selectionRequired)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun setIos(view: PCSegmentedControlView, value: ReadableMap?) {
|
|
87
|
+
// Android ignores iOS config
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Events ---
|
|
91
|
+
private class SelectEvent(
|
|
92
|
+
surfaceId: Int,
|
|
93
|
+
private val index: Int,
|
|
94
|
+
private val value: String
|
|
95
|
+
) : Event<SelectEvent>(surfaceId) {
|
|
96
|
+
override fun getEventName(): String = "topSelect"
|
|
97
|
+
override fun dispatch(rctEventEmitter: RCTEventEmitter) {
|
|
98
|
+
val payload = com.facebook.react.bridge.Arguments.createMap().apply {
|
|
99
|
+
putInt("index", index)
|
|
100
|
+
putString("value", value)
|
|
101
|
+
}
|
|
102
|
+
rctEventEmitter.receiveEvent(viewTag, eventName, payload)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -283,30 +283,33 @@ public final class PCDatePickerView: UIControl,
|
|
|
283
283
|
// Prevent "settle" events right as we present.
|
|
284
284
|
suppressNextChangesBriefly()
|
|
285
285
|
|
|
286
|
-
// Check if using inline style (full calendar) - needs larger popover size
|
|
287
|
-
var isInlineStyle = false
|
|
288
|
-
if #available(iOS 13.4, *) {
|
|
289
|
-
isInlineStyle = picker.preferredDatePickerStyle == .inline
|
|
290
|
-
}
|
|
291
|
-
|
|
292
286
|
let vc = UIViewController()
|
|
293
287
|
picker.translatesAutoresizingMaskIntoConstraints = false
|
|
294
288
|
vc.view.addSubview(picker)
|
|
295
289
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
290
|
+
let useInlineFallback: Bool
|
|
291
|
+
if #available(iOS 26.0, *) {
|
|
292
|
+
useInlineFallback = false
|
|
293
|
+
} else {
|
|
294
|
+
useInlineFallback = true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if useInlineFallback {
|
|
298
|
+
// Pre–Liquid Glass fallback
|
|
299
299
|
vc.view.backgroundColor = .systemBackground
|
|
300
300
|
vc.view.isOpaque = true
|
|
301
|
+
|
|
301
302
|
NSLayoutConstraint.activate([
|
|
302
|
-
picker.topAnchor.constraint(equalTo: vc.view.topAnchor
|
|
303
|
-
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor
|
|
304
|
-
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor
|
|
305
|
-
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor
|
|
303
|
+
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
|
304
|
+
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|
|
305
|
+
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor),
|
|
306
|
+
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor),
|
|
306
307
|
])
|
|
307
308
|
} else {
|
|
309
|
+
// Liquid Glass path
|
|
308
310
|
vc.view.backgroundColor = .clear
|
|
309
311
|
vc.view.isOpaque = false
|
|
312
|
+
|
|
310
313
|
NSLayoutConstraint.activate([
|
|
311
314
|
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
|
312
315
|
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|