react-native-platform-components 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +126 -30
  2. package/android/src/main/java/com/platformcomponents/PCImageLoader.kt +88 -0
  3. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +288 -29
  4. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +74 -8
  5. package/ios/PCConstants.swift +39 -3
  6. package/ios/PCImageLoader.swift +58 -0
  7. package/ios/PCSegmentedControl.mm +85 -25
  8. package/ios/PCSegmentedControl.swift +231 -308
  9. package/ios/PCSelectionMenu.swift +60 -14
  10. package/lib/commonjs/DatePickerNativeComponent.ts +4 -2
  11. package/lib/commonjs/SegmentedControl.js +138 -6
  12. package/lib/commonjs/SegmentedControl.js.map +1 -1
  13. package/lib/commonjs/SegmentedControlNativeComponent.ts +62 -8
  14. package/lib/commonjs/SelectionMenuNativeComponent.ts +4 -2
  15. package/lib/commonjs/index.web.js.map +1 -1
  16. package/lib/module/DatePickerNativeComponent.ts +4 -2
  17. package/lib/module/SegmentedControl.js +138 -7
  18. package/lib/module/SegmentedControl.js.map +1 -1
  19. package/lib/module/SegmentedControlNativeComponent.ts +62 -8
  20. package/lib/module/SelectionMenuNativeComponent.ts +4 -2
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts +2 -2
  23. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +111 -6
  25. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +46 -7
  27. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts +2 -2
  29. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/src/index.web.d.ts +36 -1
  31. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  32. package/lib/typescript/module/src/DatePicker.d.ts +1 -1
  33. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -1
  34. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +2 -2
  35. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -1
  36. package/lib/typescript/module/src/SegmentedControl.d.ts +111 -6
  37. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -1
  38. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +46 -7
  39. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  40. package/lib/typescript/module/src/SelectionMenu.d.ts +1 -1
  41. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -1
  42. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +2 -2
  43. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  44. package/lib/typescript/module/src/index.d.ts +6 -6
  45. package/lib/typescript/module/src/index.d.ts.map +1 -1
  46. package/lib/typescript/module/src/index.web.d.ts +36 -1
  47. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  48. package/package.json +30 -25
  49. package/plugin/tsconfig.json +2 -2
  50. package/src/DatePickerNativeComponent.ts +4 -2
  51. package/src/SegmentedControl.tsx +238 -11
  52. package/src/SegmentedControlNativeComponent.ts +62 -8
  53. package/src/SelectionMenuNativeComponent.ts +4 -2
  54. package/src/index.web.tsx +34 -1
package/README.md CHANGED
@@ -371,18 +371,22 @@ export function Example() {
371
371
 
372
372
  ```tsx
373
373
  import { SegmentedControl } from 'react-native-platform-components';
374
- import { Platform } from 'react-native';
375
374
 
376
375
  const segments = [
377
376
  {
378
377
  label: 'List',
379
378
  value: 'list',
380
- icon: Platform.OS === 'ios' ? 'list.bullet' : 'list_bullet',
379
+ // Native symbol on each platform, no Platform.OS branching
380
+ icon: {
381
+ ios: { type: 'sfSymbol', name: 'list.bullet' },
382
+ android: { type: 'drawable', name: 'list_bullet' },
383
+ },
381
384
  },
382
385
  {
383
- label: 'Grid',
384
- value: 'grid',
385
- icon: Platform.OS === 'ios' ? 'square.grid.2x2' : 'grid_view',
386
+ label: 'Alerts',
387
+ value: 'alerts',
388
+ // One image asset shared by both platforms, tinted like a template
389
+ icon: { type: 'image', source: require('./bell.png') },
386
390
  },
387
391
  ];
388
392
 
@@ -394,6 +398,8 @@ export function Example() {
394
398
  segments={segments}
395
399
  selectedValue={selected}
396
400
  onSelect={(value) => setSelected(value)}
401
+ // 'auto' | 'labeled' | 'unlabeled'
402
+ labelVisibility="auto"
397
403
  ios={{ apportionsSegmentWidthsByContent: true }}
398
404
  />
399
405
  );
@@ -611,42 +617,132 @@ Native segmented control using **UISegmentedControl** on iOS and **MaterialButto
611
617
 
612
618
  ### Props
613
619
 
614
- | Prop | Type | Description |
615
- | --------------- | ---------------------------------------- | ------------------------------------ |
616
- | `segments` | `SegmentedControlSegment[]` | Array of segments to display |
617
- | `selectedValue` | `string \| null` | Currently selected segment's `value` |
618
- | `disabled` | `boolean` | Disables the entire control |
619
- | `onSelect` | `(value: string, index: number) => void` | Called when user selects a segment |
620
+ | Prop | Type | Description |
621
+ | ----------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
622
+ | `segments` | `SegmentedControlSegment[]` | Array of segments to display |
623
+ | `selectedValue` | `string \| null` | Currently selected segment's `value` |
624
+ | `disabled` | `boolean` | Disables the entire control |
625
+ | `labelVisibility` | `'auto' \| 'labeled' \| 'unlabeled'` | How labels and icons combine. See [Label visibility](#label-visibility). Default: `'auto'` |
626
+ | `selectedSegmentColor` | `ColorValue` | Background of the selected segment |
627
+ | `activeTintColor` | `ColorValue` | Text and icon color of the selected segment |
628
+ | `inactiveTintColor` | `ColorValue` | Text and icon color of unselected segments |
629
+ | `labelStyle` | `{ fontFamily?, fontSize?, fontWeight?, fontStyle? }` | Font for segment labels. See [Styling](#styling) |
630
+ | `badgeStyle` | `{ backgroundColor?, color? }` | Colors for segment badges. See [Badges](#badges) |
631
+ | `onSelect` | `(value: string, index: number) => void` | Called when user selects a segment |
632
+ | `onDeselect` | `() => void` | Called when the user clears the selection by tapping the selected segment. Android only; requires `android.selectionRequired: false` |
620
633
 
621
634
  ### SegmentedControlSegment
622
635
 
623
- | Property | Type | Description |
624
- | ---------- | --------- | ------------------------------------------------- |
625
- | `label` | `string` | Display text for the segment |
626
- | `value` | `string` | Unique value returned in callbacks |
627
- | `disabled` | `boolean` | Disables this specific segment |
628
- | `icon` | `string` | Icon name (SF Symbol on iOS, drawable on Android) |
636
+ | Property | Type | Description |
637
+ | -------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
638
+ | `label` | `string` | Display text for the segment |
639
+ | `value` | `string` | Unique value returned in callbacks |
640
+ | `disabled` | `boolean` | Disables this specific segment |
641
+ | `icon` | `SegmentedControlIcon` | Optional icon. See [Icon Support](#icon-support-1) |
642
+ | `badge` | `string \| number` | Badge at the segment's top-right corner, e.g. an unread count. See [Badges](#badges) |
643
+ | `accessibilityLabel` | `string` | Screen-reader label. Defaults to `label`. On iOS it applies to icon segments; text segments announce their title |
629
644
 
630
645
  ### iOS Props (`ios`)
631
646
 
632
- | Prop | Type | Description |
633
- | ---------------------------------- | --------- | --------------------------------------------------- |
634
- | `momentary` | `boolean` | If true, segments don't show selected state |
635
- | `apportionsSegmentWidthsByContent` | `boolean` | If true, segment widths are proportional to content |
636
- | `selectedSegmentTintColor` | `string` | Tint color for selected segment (hex string) |
647
+ | Prop | Type | Description |
648
+ | ---------------------------------- | --------- | --------------------------------------------------------------------------- |
649
+ | `momentary` | `boolean` | If true, segments don't show selected state |
650
+ | `apportionsSegmentWidthsByContent` | `boolean` | If true, segment widths are proportional to content |
651
+ | `selectedSegmentTintColor` | `string` | **Deprecated.** Use `selectedSegmentColor`, which works on both platforms. |
637
652
 
638
653
  ### Android Props (`android`)
639
654
 
640
- | Prop | Type | Description |
641
- | ------------------- | --------- | -------------------------------------------- |
642
- | `selectionRequired` | `boolean` | If true, one segment must always be selected |
655
+ | Prop | Type | Description |
656
+ | ------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
657
+ | `selectionRequired` | `boolean` | If true (default), one segment must always be selected. Set to `false` to let a tap on the selected segment clear it and fire `onDeselect`. |
658
+ | `rippleColor` | `ColorValue` | Ripple shown while pressing a segment |
659
+ | `strokeColor` | `ColorValue` | Outline color of the segments |
643
660
 
644
661
  ### Icon Support
645
662
 
646
- Icons work the same as ContextMenu:
663
+ `icon` accepts a single source, or an `{ ios, android }` pair so you never branch on `Platform.OS`:
664
+
665
+ | Shape | iOS | Android |
666
+ | ------------------------------------------- | ----------------------------------- | --------------------------------------------- |
667
+ | `'name'` (string) | SF Symbol name | Drawable resource name |
668
+ | `{ type: 'sfSymbol', name }` | SF Symbol | Ignored (segment shows its label) |
669
+ | `{ type: 'drawable', name }` | Ignored (segment shows its label) | Drawable from `res/drawable` |
670
+ | `{ type: 'image', source, tinted? }` | Image asset or `{ uri }` | Image asset or `{ uri }` |
671
+ | `{ ios: <source>, android: <source> }` | Uses `ios` | Uses `android` |
672
+
673
+ ```tsx
674
+ // A bundled asset works everywhere and is tinted like a template.
675
+ icon: { type: 'image', source: require('./icons/bell.png') }
676
+
677
+ // Keep the original colors of a full-color image.
678
+ icon: { type: 'image', source: require('./icons/logo.png'), tinted: false }
679
+
680
+ // Remote images load asynchronously; pass `scale` for @2x/@3x artwork.
681
+ icon: { type: 'image', source: { uri: 'https://example.com/icon@2x.png', scale: 2 } }
682
+ ```
683
+
684
+ Image icons render at their point size, so ship `@2x` / `@3x` variants sized around 18–22 points. Local assets load synchronously in release builds; in development they stream from Metro and the segment shows its label until the image arrives.
685
+
686
+ ### Label visibility
687
+
688
+ `UISegmentedControl` shows either a title or an image per segment, while Material buttons can show both. `labelVisibility` makes the outcome predictable:
689
+
690
+ | Value | iOS | Android |
691
+ | --------------------- | ------------------------------------------ | -------------------------- |
692
+ | `'auto'` (default) | Icon when the segment has one, else label | Icon and label |
693
+ | `'labeled'` | Label only (icon is not shown) | Icon and label |
694
+ | `'unlabeled'` | Icon when the segment has one, else label | Icon only, else label |
695
+
696
+ Screen readers announce the label (or `accessibilityLabel`) in every mode on both platforms.
697
+
698
+ ### Styling
699
+
700
+ Colors take any React Native `ColorValue` (hex, `rgba()`, named colors, `PlatformColor`, `DynamicColorIOS`). Fonts follow the `Text` style conventions, and each field falls back to the platform default.
701
+
702
+ ```tsx
703
+ <SegmentedControl
704
+ segments={segments}
705
+ selectedValue={selected}
706
+ onSelect={setSelected}
707
+ selectedSegmentColor="#FF6B35"
708
+ activeTintColor="white"
709
+ inactiveTintColor="#8E8E93"
710
+ labelStyle={{ fontWeight: '700', fontSize: 14 }}
711
+ android={{ rippleColor: 'rgba(255, 107, 53, 0.25)', strokeColor: '#FF6B35' }}
712
+ />
713
+ ```
714
+
715
+ | Prop | iOS | Android |
716
+ | ---------------------- | -------------------------------------------- | -------------------------------------------- |
717
+ | `selectedSegmentColor` | `selectedSegmentTintColor` (the pill) | Checked button background |
718
+ | `activeTintColor` | Selected title / template image color | Checked button text and icon tint |
719
+ | `inactiveTintColor` | Normal title / template image color | Unchecked button text and icon tint |
720
+ | `labelStyle` | Title font (default: 13pt system) | Button typeface and size (default: theme) |
721
+ | `android.rippleColor` | — | Press ripple |
722
+ | `android.strokeColor` | — | Button outline |
723
+
724
+ Images with `tinted: false` keep their own colors and ignore the tint props.
725
+
726
+ ### Badges
727
+
728
+ Give a segment a `badge` to show a count or short status at its top-right corner. Numbers render as-is, so format them yourself (`'99+'`); `undefined` hides the badge.
729
+
730
+ ```tsx
731
+ <SegmentedControl
732
+ segments={[
733
+ { label: 'Inbox', value: 'inbox', badge: unreadCount || undefined },
734
+ { label: 'Sent', value: 'sent' },
735
+ { label: 'Drafts', value: 'drafts', badge: 'new' },
736
+ ]}
737
+ selectedValue={mailbox}
738
+ onSelect={setMailbox}
739
+ badgeStyle={{ backgroundColor: '#5856D6', color: 'white' }}
740
+ />
741
+ ```
647
742
 
648
- - **iOS**: Use SF Symbol names (e.g., `'list.bullet'`, `'square.grid.2x2'`)
649
- - **Android**: Use drawable resource names (e.g., `'list_bullet'`, `'grid_view'`)
743
+ - **iOS**: a capsule label drawn over the segment (UISegmentedControl has no badge API). Defaults to system red with white text.
744
+ - **Android**: a Material `BadgeDrawable` attached to the button. Defaults to the theme's error color.
745
+ - Screen readers announce the badge with the segment label ("Inbox, 3").
650
746
 
651
747
  ---
652
748
 
@@ -746,7 +842,7 @@ All color props in this library support the same formats as React Native's `back
746
842
  **Props that accept colors:**
747
843
 
748
844
  - `ContextMenu`: `imageColor` (icon tint)
749
- - `SegmentedControl`: `ios.selectedSegmentTintColor`
845
+ - `SegmentedControl`: `selectedSegmentColor`, `activeTintColor`, `inactiveTintColor`, `badgeStyle.backgroundColor`, `badgeStyle.color`, `android.rippleColor`, `android.strokeColor` (these also accept `PlatformColor` / `DynamicColorIOS`)
750
846
  - `LiquidGlass`: `ios.tintColor`, `android.fallbackBackgroundColor`
751
847
 
752
848
  ```tsx
@@ -897,7 +993,7 @@ Then run `npx expo prebuild` to apply the configuration.
897
993
 
898
994
  ## Icons
899
995
 
900
- ContextMenu supports icons on menu items. Icons are specified by name and resolved differently on each platform.
996
+ ContextMenu supports icons on menu items. Icons are specified by name and resolved differently on each platform. SegmentedControl accepts the same names, plus image assets and per-platform pairs; see [SegmentedControl Icon Support](#icon-support-1).
901
997
 
902
998
  ### iOS
903
999
 
@@ -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
+ }