react-native-platform-components 0.6.0 → 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 +211 -44
- package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +2 -2
- 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/PCSelectionMenuView.kt +4 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/app.plugin.cjs +4 -0
- package/expo-module.config.json +4 -0
- package/ios/PCContextMenu.swift +65 -22
- package/ios/PCDatePickerView.swift +28 -11
- package/ios/PCSegmentedControl.h +10 -0
- package/ios/PCSegmentedControl.mm +194 -0
- package/ios/PCSegmentedControl.swift +200 -0
- package/lib/commonjs/ContextMenu.js +118 -0
- package/lib/commonjs/ContextMenu.js.map +1 -0
- package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
- package/lib/commonjs/DatePicker.js +86 -0
- package/lib/commonjs/DatePicker.js.map +1 -0
- package/lib/commonjs/DatePickerNativeComponent.ts +69 -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/SelectionMenu.js +73 -0
- package/lib/commonjs/SelectionMenu.js.map +1 -0
- package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
- package/lib/commonjs/index.js +61 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/sharedTypes.js +6 -0
- package/lib/commonjs/sharedTypes.js.map +1 -0
- 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/package.json +1 -0
- package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
- 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/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
- package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePicker.d.ts +40 -0
- package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
- 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/SelectionMenu.d.ts +47 -0
- package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +6 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
- package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
- package/package.json +32 -12
- package/plugin/build/index.cjs +26 -0
- package/plugin/build/index.d.ts +22 -0
- package/plugin/build/index.d.ts.map +1 -0
- package/plugin/tsconfig.json +16 -0
- 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/lib/typescript/src/ContextMenu.d.ts.map +0 -1
- package/lib/typescript/src/ContextMenuNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/DatePicker.d.ts.map +0 -1
- package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
- package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
- /package/lib/typescript/{src → commonjs/src}/ContextMenu.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/ContextMenuNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
- /package/lib/typescript/{package.json → module/package.json} +0 -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
|
|
@@ -78,6 +82,64 @@ pod install
|
|
|
78
82
|
- Supports **Material 3** styling
|
|
79
83
|
- No additional setup required beyond autolinking
|
|
80
84
|
|
|
85
|
+
### Expo (Managed Workflow)
|
|
86
|
+
|
|
87
|
+
> **Note:** This library is **not supported in Expo Go**. It requires native code and must be used with [Expo Dev Client](https://docs.expo.dev/develop/development-builds/introduction/) or EAS Build.
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx expo install react-native-platform-components
|
|
91
|
+
npx expo prebuild
|
|
92
|
+
npx expo run:ios
|
|
93
|
+
# or
|
|
94
|
+
npx expo run:android
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The library includes an Expo config plugin that handles all native configuration automatically. No manual native setup is required.
|
|
98
|
+
|
|
99
|
+
**EAS Build:**
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
eas build --platform ios
|
|
103
|
+
eas build --platform android
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Config Plugin:**
|
|
107
|
+
|
|
108
|
+
Add to your `app.json`:
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"expo": {
|
|
112
|
+
"plugins": [
|
|
113
|
+
["react-native-platform-components/app.plugin", {}]
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For a complete working example, see the [`example-expo/`](./example-expo) directory.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## React Native New Architecture
|
|
124
|
+
|
|
125
|
+
This library is built for the **React Native New Architecture** (Fabric + TurboModules).
|
|
126
|
+
|
|
127
|
+
| Feature | Status |
|
|
128
|
+
|---------|--------|
|
|
129
|
+
| Fabric (New Renderer) | Supported |
|
|
130
|
+
| Codegen | Used for type-safe native bindings |
|
|
131
|
+
| TurboModules | N/A (view components only) |
|
|
132
|
+
| Old Architecture | Not supported |
|
|
133
|
+
|
|
134
|
+
**Tested with:**
|
|
135
|
+
- React Native 0.81+ (bare and Expo)
|
|
136
|
+
- Expo SDK 54+
|
|
137
|
+
|
|
138
|
+
**Requirements:**
|
|
139
|
+
- New Architecture must be enabled in your app
|
|
140
|
+
- For bare React Native: set `newArchEnabled=true` in `gradle.properties` (Android) and use the `RCT_NEW_ARCH_ENABLED` flag (iOS)
|
|
141
|
+
- For Expo: set `"newArchEnabled": true` in `app.json`
|
|
142
|
+
|
|
81
143
|
---
|
|
82
144
|
|
|
83
145
|
## Quick Start
|
|
@@ -267,6 +329,65 @@ export function Example() {
|
|
|
267
329
|
|
|
268
330
|
---
|
|
269
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
|
+
|
|
270
391
|
## Components
|
|
271
392
|
|
|
272
393
|
## DatePicker
|
|
@@ -392,6 +513,50 @@ Native selection menu with **modal** and **embedded** modes.
|
|
|
392
513
|
|
|
393
514
|
---
|
|
394
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
|
+
|
|
395
560
|
## Design Philosophy
|
|
396
561
|
|
|
397
562
|
- **Native first** — no JS re-implementation of pickers
|
|
@@ -496,6 +661,8 @@ const actions = [
|
|
|
496
661
|
|
|
497
662
|
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.
|
|
498
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
|
+
|
|
499
666
|
## License
|
|
500
667
|
|
|
501
668
|
MIT
|
|
@@ -9,12 +9,12 @@ import android.view.Menu
|
|
|
9
9
|
import android.view.MotionEvent
|
|
10
10
|
import android.view.View
|
|
11
11
|
import android.view.ViewConfiguration
|
|
12
|
-
import android.widget.FrameLayout
|
|
13
12
|
import androidx.appcompat.widget.PopupMenu
|
|
14
13
|
import androidx.core.content.ContextCompat
|
|
15
14
|
import androidx.core.graphics.drawable.DrawableCompat
|
|
15
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
16
16
|
|
|
17
|
-
class PCContextMenuView(context: Context) :
|
|
17
|
+
class PCContextMenuView(context: Context) : ReactViewGroup(context) {
|
|
18
18
|
|
|
19
19
|
data class Action(
|
|
20
20
|
val id: String,
|
|
@@ -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
|
+
}
|
|
@@ -341,12 +341,16 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
|
|
|
341
341
|
|
|
342
342
|
if (mode == MaterialMode.M3) {
|
|
343
343
|
// M3 exposed dropdown menu - the standard Material 3 way
|
|
344
|
+
// Must set box background mode BEFORE setting endIconMode to avoid IllegalStateException
|
|
344
345
|
val til = TextInputLayout(context).apply {
|
|
345
346
|
layoutParams = FrameLayout.LayoutParams(
|
|
346
347
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
347
348
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
|
348
349
|
)
|
|
350
|
+
// Set box background mode first - required for END_ICON_DROPDOWN_MENU
|
|
351
|
+
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
|
|
349
352
|
hint = placeholder
|
|
353
|
+
// Now safe to set the dropdown icon
|
|
350
354
|
endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU
|
|
351
355
|
}
|
|
352
356
|
|