react-native-platform-components 1.0.0 → 1.2.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 (51) hide show
  1. package/README.md +29 -45
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +46 -2
  4. package/android/src/main/java/com/platformcomponents/PCNativeTheme.kt +174 -0
  5. package/android/src/main/java/com/platformcomponents/PCNativeThemeModule.kt +42 -0
  6. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +34 -1
  7. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +14 -0
  8. package/android/src/main/java/com/platformcomponents/PCThemeSupport.kt +40 -5
  9. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +22 -5
  10. package/android/src/main/res/values/styles.xml +131 -0
  11. package/ios/PCNativeTheme.swift +56 -0
  12. package/ios/PCNativeThemeModule.mm +36 -0
  13. package/lib/commonjs/NativePlatformComponentsTheme.js +10 -0
  14. package/lib/commonjs/NativePlatformComponentsTheme.js.map +1 -0
  15. package/lib/commonjs/NativeTheme.js +51 -0
  16. package/lib/commonjs/NativeTheme.js.map +1 -0
  17. package/lib/commonjs/index.js +11 -0
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/index.web.js +10 -1
  20. package/lib/commonjs/index.web.js.map +1 -1
  21. package/lib/module/NativePlatformComponentsTheme.js +7 -0
  22. package/lib/module/NativePlatformComponentsTheme.js.map +1 -0
  23. package/lib/module/NativeTheme.js +45 -0
  24. package/lib/module/NativeTheme.js.map +1 -0
  25. package/lib/module/index.js +1 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/index.web.js +7 -0
  28. package/lib/module/index.web.js.map +1 -1
  29. package/lib/typescript/commonjs/docs/docusaurus.config.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/src/NativePlatformComponentsTheme.d.ts +11 -0
  31. package/lib/typescript/commonjs/src/NativePlatformComponentsTheme.d.ts.map +1 -0
  32. package/lib/typescript/commonjs/src/NativeTheme.d.ts +30 -0
  33. package/lib/typescript/commonjs/src/NativeTheme.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/src/index.d.ts +1 -0
  35. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/src/index.web.d.ts +8 -1
  37. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  38. package/lib/typescript/module/docs/docusaurus.config.d.ts.map +1 -1
  39. package/lib/typescript/module/src/NativePlatformComponentsTheme.d.ts +11 -0
  40. package/lib/typescript/module/src/NativePlatformComponentsTheme.d.ts.map +1 -0
  41. package/lib/typescript/module/src/NativeTheme.d.ts +30 -0
  42. package/lib/typescript/module/src/NativeTheme.d.ts.map +1 -0
  43. package/lib/typescript/module/src/index.d.ts +1 -0
  44. package/lib/typescript/module/src/index.d.ts.map +1 -1
  45. package/lib/typescript/module/src/index.web.d.ts +8 -1
  46. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  47. package/package.json +5 -2
  48. package/src/NativePlatformComponentsTheme.ts +13 -0
  49. package/src/NativeTheme.ts +53 -0
  50. package/src/index.tsx +1 -0
  51. package/src/index.web.tsx +17 -1
package/README.md CHANGED
@@ -7,6 +7,33 @@
7
7
 
8
8
  Native **DatePicker**, **ContextMenu**, **SelectionMenu**, **SegmentedControl** and **LiquidGlass** for React Native. Every component is the real platform widget on both iOS and Android (UIKit and SwiftUI on iOS, Material 3 on Android), behind one typed, declarative API. No JavaScript re-implementations.
9
9
 
10
+ <table>
11
+ <tr>
12
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/datepicker">DatePicker</a></td>
13
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/contextmenu">ContextMenu</a></td>
14
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/selectionmenu">SelectionMenu</a></td>
15
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/segmentedcontrol">SegmentedControl</a></td>
16
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/liquidglass">LiquidGlass</a></td>
17
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/guides/theming">Theme</a></td>
18
+ </tr>
19
+ <tr>
20
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/datepicker"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-datepicker.gif" width="100" alt="DatePicker on iOS" /></a></td>
21
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/contextmenu"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-contextmenu.gif" width="100" alt="ContextMenu on iOS" /></a></td>
22
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/selectionmenu"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-selectionmenu.gif" width="100" alt="SelectionMenu on iOS" /></a></td>
23
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/segmentedcontrol"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-segmentedcontrol.gif" width="100" alt="SegmentedControl on iOS" /></a></td>
24
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/liquidglass"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-liquidglass.gif" width="100" alt="LiquidGlass on iOS" /></a></td>
25
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/guides/theming"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-theme.gif" width="100" alt="Brand color and dark mode on iOS" /></a></td>
26
+ </tr>
27
+ <tr>
28
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/datepicker"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-datepicker.gif" width="100" alt="DatePicker on Android" /></a></td>
29
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/contextmenu"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-contextmenu.gif" width="100" alt="ContextMenu on Android" /></a></td>
30
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/selectionmenu"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-selectionmenu.gif" width="100" alt="SelectionMenu on Android" /></a></td>
31
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/components/segmentedcontrol"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-segmentedcontrol.gif" width="100" alt="SegmentedControl on Android" /></a></td>
32
+ <td align="center" valign="middle"><sub>iOS 26+ only.<br/>Plain View on Android.</sub></td>
33
+ <td align="center"><a href="https://jarx-concepts.github.io/react-native-platform-components/guides/theming"><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-theme.gif" width="100" alt="Brand color and dark mode on Android" /></a></td>
34
+ </tr>
35
+ </table>
36
+
10
37
  ```sh
11
38
  npm install react-native-platform-components
12
39
  ```
@@ -47,49 +74,6 @@ That renders `UISegmentedControl` on iOS and Material 3 segmented buttons (`Mate
47
74
  | **SegmentedControl** | `UISegmentedControl` | `MaterialButtonToggleGroup` (Material 3 segmented buttons) | [SegmentedControl](https://jarx-concepts.github.io/react-native-platform-components/components/segmentedcontrol) |
48
75
  | **LiquidGlass** | `UIGlassEffect` (iOS 26+) | Fallback `View` | [LiquidGlass](https://jarx-concepts.github.io/react-native-platform-components/components/liquidglass) |
49
76
 
50
- <table>
51
- <tr>
52
- <td align="center"><strong>iOS DatePicker</strong></td>
53
- <td align="center"><strong>Android DatePicker</strong></td>
54
- </tr>
55
- <tr>
56
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-datepicker.gif" height="550" /></td>
57
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-datepicker.gif" height="550" /></td>
58
- </tr>
59
- <tr>
60
- <td align="center"><strong>iOS ContextMenu</strong></td>
61
- <td align="center"><strong>Android ContextMenu</strong></td>
62
- </tr>
63
- <tr>
64
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-contextmenu.gif" height="550" /></td>
65
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-contextmenu.gif" height="550" /></td>
66
- </tr>
67
- <tr>
68
- <td align="center"><strong>iOS SelectionMenu</strong></td>
69
- <td align="center"><strong>Android SelectionMenu</strong></td>
70
- </tr>
71
- <tr>
72
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-selectionmenu.gif" height="550" /></td>
73
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-selectionmenu.gif" height="550" /></td>
74
- </tr>
75
- <tr>
76
- <td align="center"><strong>iOS SegmentedControl</strong></td>
77
- <td align="center"><strong>Android SegmentedControl</strong></td>
78
- </tr>
79
- <tr>
80
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-segmentedcontrol.gif" height="550" /></td>
81
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-segmentedcontrol.gif" height="550" /></td>
82
- </tr>
83
- <tr>
84
- <td align="center"><strong>iOS LiquidGlass</strong></td>
85
- <td align="center"><strong>Android LiquidGlass</strong></td>
86
- </tr>
87
- <tr>
88
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-liquidglass.gif" height="550" /></td>
89
- <td align="center"><em>iOS 26+ only</em><br/><br/>On Android, renders as a<br/>regular View with optional<br/>fallback background color.</td>
90
- </tr>
91
- </table>
92
-
93
77
  **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.
94
78
 
95
79
  **Using it in a shipped app?** Open a PR to list it here.
@@ -139,7 +123,7 @@ The library is built for the New Architecture (Fabric + Codegen) and does not su
139
123
  | Expo SDK | 54, with a dev client or EAS Build (not Expo Go) |
140
124
  | React | 19 |
141
125
  | iOS | 13 (LiquidGlass needs iOS 26) |
142
- | Android | API 24 (Android 7.0); Material Components 1.12 is bundled |
126
+ | Android | API 24 (Android 7.0); Material Components 1.14 is bundled |
143
127
 
144
128
  Full details: [Installation](https://jarx-concepts.github.io/react-native-platform-components/installation).
145
129
 
@@ -151,7 +135,7 @@ Everything else lives on the documentation site:
151
135
  - [DatePicker](https://jarx-concepts.github.io/react-native-platform-components/components/datepicker), [ContextMenu](https://jarx-concepts.github.io/react-native-platform-components/components/contextmenu), [SelectionMenu](https://jarx-concepts.github.io/react-native-platform-components/components/selectionmenu), [SegmentedControl](https://jarx-concepts.github.io/react-native-platform-components/components/segmentedcontrol), [LiquidGlass](https://jarx-concepts.github.io/react-native-platform-components/components/liquidglass): props, platform options and behavior
152
136
  - [Android Theme Configuration](https://jarx-concepts.github.io/react-native-platform-components/guides/android-theme): Material 3 setup and what happens without it
153
137
  - [Icons](https://jarx-concepts.github.io/react-native-platform-components/guides/icons): SF Symbols, drawables and images
154
- - [Theming and Colors](https://jarx-concepts.github.io/react-native-platform-components/guides/theming): design philosophy, theming, accepted color formats
138
+ - [Theming and Colors](https://jarx-concepts.github.io/react-native-platform-components/guides/theming): light/dark mode and your brand color from JavaScript (`useNativeTheme`), accepted color formats
155
139
 
156
140
  ## Contributing
157
141
 
@@ -76,5 +76,5 @@ def kotlin_version = getExtOrDefault("kotlinVersion")
76
76
  dependencies {
77
77
  implementation "com.facebook.react:react-android"
78
78
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
79
- implementation "com.google.android.material:material:1.12.0"
79
+ implementation "com.google.android.material:material:1.14.0"
80
80
  }
@@ -74,6 +74,12 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
74
74
  // --- Modal state ---
75
75
  private var showingModal = false
76
76
 
77
+ // --- Native theme ---
78
+ // Widgets read theme colors when they are created, so they are rebuilt when the
79
+ // native theme changes (brand color, light/dark switch).
80
+ private var builtThemeVersion = PCNativeTheme.version
81
+ private val nativeThemeListener = PCNativeTheme.Listener { rebuildForNativeTheme() }
82
+
77
83
  init {
78
84
  rebuildUI()
79
85
  }
@@ -226,6 +232,33 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
226
232
  syncInlineFromState()
227
233
  }
228
234
 
235
+ override fun onAttachedToWindow() {
236
+ super.onAttachedToWindow()
237
+ PCNativeTheme.addListener(nativeThemeListener)
238
+ PCNativeTheme.attach(context)
239
+ if (builtThemeVersion != PCNativeTheme.version) rebuildForNativeTheme()
240
+ }
241
+
242
+ override fun onDetachedFromWindow() {
243
+ PCNativeTheme.removeListener(nativeThemeListener)
244
+ super.onDetachedFromWindow()
245
+ }
246
+
247
+ /**
248
+ * A theme change isn't a React commit, so nothing measures the rebuilt inline
249
+ * pickers; lay them out in the frame Yoga already gave this view.
250
+ */
251
+ private fun rebuildForNativeTheme() {
252
+ PCThemeSupport.clearColorStateListCaches(this)
253
+ rebuildUI()
254
+ if (!isInline() || width == 0 || height == 0) return
255
+ measure(
256
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
257
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
258
+ )
259
+ layout(left, top, right, bottom)
260
+ }
261
+
229
262
  // -----------------------------
230
263
  // UI construction
231
264
  // -----------------------------
@@ -233,6 +266,7 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
233
266
  private fun isInline(): Boolean = presentation == "inline" || presentation == "embedded"
234
267
 
235
268
  private fun rebuildUI() {
269
+ builtThemeVersion = PCNativeTheme.version
236
270
  removeAllViews()
237
271
  inlineContainer = null
238
272
  inlineDatePicker = null
@@ -742,10 +776,20 @@ class PCDatePickerView(context: Context) : FrameLayout(context), ReactScrollView
742
776
  * theme instead (0 = use the activity theme).
743
777
  */
744
778
  private fun m3CalendarTheme(act: FragmentActivity): Int =
745
- PCThemeSupport.materialDialogThemeOverride(act, "DatePicker", R.style.PCMaterial3CalendarDialogTheme)
779
+ PCThemeSupport.materialDialogThemeOverride(
780
+ act,
781
+ "DatePicker",
782
+ R.style.PCMaterial3CalendarDialogTheme,
783
+ R.style.PCMaterial3CalendarDialogTheme_NativeTheme
784
+ )
746
785
 
747
786
  private fun m3TimePickerTheme(act: FragmentActivity): Int =
748
- PCThemeSupport.materialDialogThemeOverride(act, "DatePicker", R.style.PCMaterial3TimePickerDialogTheme)
787
+ PCThemeSupport.materialDialogThemeOverride(
788
+ act,
789
+ "DatePicker",
790
+ R.style.PCMaterial3TimePickerDialogTheme,
791
+ R.style.PCMaterial3TimePickerDialogTheme_NativeTheme
792
+ )
749
793
 
750
794
  private fun buildM3CalendarConstraints(): CalendarConstraints? {
751
795
  val min = minDateMs
@@ -0,0 +1,174 @@
1
+ package com.platformcomponents
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.ContextWrapper
6
+ import android.content.pm.PackageManager
7
+ import android.content.res.Configuration
8
+ import android.os.Build
9
+ import android.util.Log
10
+ import androidx.core.content.OnConfigurationChangedProvider
11
+ import androidx.core.util.Consumer
12
+ import com.facebook.react.bridge.UiThreadUtil
13
+ import com.facebook.react.uimanager.ThemedReactContext
14
+ import com.google.android.material.color.DynamicColors
15
+ import com.google.android.material.color.DynamicColorsOptions
16
+ import java.lang.ref.WeakReference
17
+ import java.util.WeakHashMap
18
+
19
+ /**
20
+ * The native theme set from JS with `useNativeTheme` / `setNativeTheme`.
21
+ *
22
+ * The brand color seeds Material dynamic colors on the activity, so Material and
23
+ * AppCompat widgets created afterwards use a Material 3 scheme generated from it.
24
+ * Content-based dynamic colors need Android 13+ (and some Android 12 devices); on
25
+ * older versions the app's own theme colors stay in place.
26
+ *
27
+ * Widgets resolve theme colors when they are created, so library views register as
28
+ * listeners and rebuild on every change: a new brand color, or a light/dark switch
29
+ * (including `Appearance.setColorScheme`), which also needs the scheme regenerated.
30
+ *
31
+ * Main thread only.
32
+ */
33
+ internal object PCNativeTheme {
34
+ private const val TAG = "PlatformComponents"
35
+
36
+ fun interface Listener {
37
+ fun onNativeThemeChanged()
38
+ }
39
+
40
+ /** Brand color from JS, or null to use the app's theme colors. */
41
+ var primaryColor: Int? = null
42
+ private set
43
+
44
+ /** Bumped on every change; views compare it to the version they were built with. */
45
+ var version = 0
46
+ private set
47
+
48
+ /** True when widgets should re-apply the brand colors over their own theme. */
49
+ val isActive: Boolean
50
+ get() = primaryColor != null && DynamicColors.isDynamicColorAvailable()
51
+
52
+ private data class Applied(val primaryColor: Int, val nightMode: Int)
53
+
54
+ private var warnedUnsupported = false
55
+
56
+ private val listeners = LinkedHashSet<Listener>()
57
+ private val applied = WeakHashMap<Activity, Applied>()
58
+ private val observed = WeakHashMap<Activity, Consumer<Configuration>>()
59
+
60
+ fun addListener(listener: Listener) {
61
+ listeners.add(listener)
62
+ }
63
+
64
+ fun removeListener(listener: Listener) {
65
+ listeners.remove(listener)
66
+ }
67
+
68
+ fun setPrimaryColor(activity: Activity?, color: Int?) {
69
+ UiThreadUtil.assertOnUiThread()
70
+ if (color == primaryColor) return
71
+ primaryColor = color
72
+ activity?.let {
73
+ observe(it)
74
+ apply(it)
75
+ }
76
+ changed()
77
+ }
78
+
79
+ /**
80
+ * Called when a library view attaches: follows its activity's configuration and
81
+ * applies the current theme if the activity doesn't have it yet (for example after
82
+ * the activity was recreated).
83
+ */
84
+ fun attach(context: Context) {
85
+ val activity = findActivity(context) ?: return
86
+ observe(activity)
87
+ if (applied[activity] != wanted(activity)) {
88
+ apply(activity)
89
+ changed()
90
+ }
91
+ }
92
+
93
+ fun findActivity(context: Context): Activity? {
94
+ (context as? ThemedReactContext)?.currentActivity?.let { return it }
95
+ var current: Context? = context
96
+ while (current is ContextWrapper) {
97
+ if (current is Activity) return current
98
+ current = current.baseContext
99
+ }
100
+ return null
101
+ }
102
+
103
+ private fun nightMode(context: Context): Int =
104
+ context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
105
+
106
+ /** What the activity should have applied: null when there is no brand color. */
107
+ private fun wanted(activity: Activity): Applied? =
108
+ primaryColor?.let { Applied(it, nightMode(activity)) }
109
+
110
+ private fun apply(activity: Activity) {
111
+ val color = primaryColor
112
+
113
+ if (color == null) {
114
+ // Re-applying the activity theme resets the color attributes the overlay set.
115
+ if (applied.remove(activity) != null) {
116
+ activityThemeResId(activity)?.let { activity.theme.applyStyle(it, true) }
117
+ }
118
+ return
119
+ }
120
+
121
+ applied[activity] = Applied(color, nightMode(activity))
122
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !DynamicColors.isDynamicColorAvailable()) {
123
+ if (!warnedUnsupported) {
124
+ warnedUnsupported = true
125
+ Log.w(TAG, "useNativeTheme: brand colors need Android 13 or later; keeping the app theme colors.")
126
+ }
127
+ return
128
+ }
129
+
130
+ // Generates the scheme for the activity's current light/dark mode, overrides the
131
+ // Material "personalized" color resources with it and applies them to the theme.
132
+ // Material 1.13+ is needed to apply it more than once to the same activity.
133
+ DynamicColors.applyToActivityIfAvailable(
134
+ activity,
135
+ DynamicColorsOptions.Builder().setContentBasedSource(color).build()
136
+ )
137
+ activity.theme.applyStyle(R.style.PCNativeThemeOverlay, true)
138
+ }
139
+
140
+ private fun observe(activity: Activity) {
141
+ if (observed.containsKey(activity)) return
142
+ val provider = activity as? OnConfigurationChangedProvider ?: return
143
+
144
+ // The listener lives as long as the activity; don't let it keep the activity alive.
145
+ val activityRef = WeakReference(activity)
146
+ var lastNightMode = nightMode(activity)
147
+ val listener = Consumer<Configuration> { config ->
148
+ val nightMode = config.uiMode and Configuration.UI_MODE_NIGHT_MASK
149
+ if (nightMode == lastNightMode) return@Consumer
150
+ lastNightMode = nightMode
151
+ val current = activityRef.get() ?: return@Consumer
152
+ // AppCompat re-applies the activity theme on a night mode switch, and the
153
+ // scheme depends on the mode, so the brand colors are applied again.
154
+ if (primaryColor != null) apply(current)
155
+ changed()
156
+ }
157
+ provider.addOnConfigurationChangedListener(listener)
158
+ observed[activity] = listener
159
+ }
160
+
161
+ private fun changed() {
162
+ version += 1
163
+ listeners.toList().forEach { it.onNativeThemeChanged() }
164
+ }
165
+
166
+ private fun activityThemeResId(activity: Activity): Int? =
167
+ try {
168
+ activity.packageManager.getActivityInfo(activity.componentName, 0).themeResource
169
+ .takeIf { it != 0 }
170
+ ?: activity.applicationInfo.theme.takeIf { it != 0 }
171
+ } catch (_: PackageManager.NameNotFoundException) {
172
+ null
173
+ }
174
+ }
@@ -0,0 +1,42 @@
1
+ package com.platformcomponents
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.bridge.ColorPropConverter
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.ReadableType
8
+ import com.facebook.react.bridge.UiThreadUtil
9
+
10
+ /** `setNativeTheme` / `useNativeTheme` on Android. See [PCNativeTheme]. */
11
+ class PCNativeThemeModule(reactContext: ReactApplicationContext) :
12
+ NativePlatformComponentsThemeSpec(reactContext) {
13
+
14
+ override fun setTheme(theme: ReadableMap) {
15
+ val primary: Any? =
16
+ when (if (theme.hasKey("primary")) theme.getType("primary") else ReadableType.Null) {
17
+ ReadableType.Number -> theme.getDouble("primary")
18
+ ReadableType.Map -> theme.getMap("primary")
19
+ else -> null
20
+ }
21
+
22
+ UiThreadUtil.runOnUiThread {
23
+ val activity = reactApplicationContext.currentActivity
24
+ val color =
25
+ primary?.let {
26
+ try {
27
+ // Resolves PlatformColor resource paths as well as plain colors.
28
+ ColorPropConverter.getColor(it, activity ?: reactApplicationContext)
29
+ } catch (e: RuntimeException) {
30
+ Log.w(TAG, "setNativeTheme: could not resolve colors.primary", e)
31
+ null
32
+ }
33
+ }
34
+ PCNativeTheme.setPrimaryColor(activity, color)
35
+ }
36
+ }
37
+
38
+ companion object {
39
+ const val NAME = "PlatformComponentsTheme"
40
+ private const val TAG = "PlatformComponents"
41
+ }
42
+ }
@@ -11,6 +11,7 @@ import android.widget.FrameLayout
11
11
  import android.widget.LinearLayout
12
12
  import com.facebook.react.bridge.WritableNativeMap
13
13
  import com.facebook.react.uimanager.PixelUtil
14
+ import com.facebook.react.uimanager.ReactCompoundViewGroup
14
15
  import com.facebook.react.uimanager.StateWrapper
15
16
  import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
16
17
  import com.facebook.react.views.scroll.ReactScrollViewHelper
@@ -20,7 +21,10 @@ import com.google.android.material.badge.BadgeUtils
20
21
  import com.google.android.material.button.MaterialButton
21
22
  import com.google.android.material.button.MaterialButtonToggleGroup
22
23
 
23
- class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScrollViewHelper.HasStateWrapper {
24
+ class PCSegmentedControlView(context: Context) :
25
+ FrameLayout(context),
26
+ ReactScrollViewHelper.HasStateWrapper,
27
+ ReactCompoundViewGroup {
24
28
 
25
29
  data class Segment(
26
30
  val label: String,
@@ -88,6 +92,15 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
88
92
  /** Bumped on every rebuild so late image loads can't touch stale buttons. */
89
93
  private var rebuildGeneration = 0
90
94
 
95
+ // --- Native theme ---
96
+ // Widgets read theme colors when they are created, so they are rebuilt when the
97
+ // native theme changes (brand color, light/dark switch).
98
+ private var builtThemeVersion = PCNativeTheme.version
99
+ private val nativeThemeListener = PCNativeTheme.Listener {
100
+ PCThemeSupport.clearColorStateListCaches(this)
101
+ rebuildUI()
102
+ }
103
+
91
104
  init {
92
105
  minimumHeight = (PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP * resources.displayMetrics.density).toInt()
93
106
  // Badges overhang the button corner
@@ -182,9 +195,29 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
182
195
  rebuildUI()
183
196
  }
184
197
 
198
+ // The buttons have generated view ids, which React Native's touch handling would
199
+ // take for React tags and dispatch JS touch events to unrelated views. Claim the
200
+ // touch for this view instead; the buttons still receive it natively.
201
+ override fun interceptsTouchEvent(touchX: Float, touchY: Float): Boolean = true
202
+
203
+ override fun reactTagForTouch(touchX: Float, touchY: Float): Int = id
204
+
205
+ override fun onAttachedToWindow() {
206
+ super.onAttachedToWindow()
207
+ PCNativeTheme.addListener(nativeThemeListener)
208
+ PCNativeTheme.attach(context)
209
+ if (builtThemeVersion != PCNativeTheme.version) nativeThemeListener.onNativeThemeChanged()
210
+ }
211
+
212
+ override fun onDetachedFromWindow() {
213
+ PCNativeTheme.removeListener(nativeThemeListener)
214
+ super.onDetachedFromWindow()
215
+ }
216
+
185
217
  // ---- UI Building ----
186
218
 
187
219
  private fun rebuildUI() {
220
+ builtThemeVersion = PCNativeTheme.version
188
221
  removeAllViews()
189
222
  buttonIdToSegment.clear()
190
223
  rebuildGeneration += 1
@@ -64,6 +64,15 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
64
64
  private var headlessDismissAfterSelect = false
65
65
  private var headlessOpenToken = 0
66
66
 
67
+ // --- Native theme ---
68
+ // Widgets read theme colors when they are created, so they are rebuilt when the
69
+ // native theme changes (brand color, light/dark switch).
70
+ private var builtThemeVersion = PCNativeTheme.version
71
+ private val nativeThemeListener = PCNativeTheme.Listener {
72
+ PCThemeSupport.clearColorStateListCaches(this)
73
+ rebuildUI()
74
+ }
75
+
67
76
  init {
68
77
  minimumHeight = 0
69
78
  minimumWidth = 0
@@ -306,6 +315,7 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
306
315
  // ---- UI building ----
307
316
 
308
317
  private fun rebuildUI() {
318
+ builtThemeVersion = PCNativeTheme.version
309
319
  if (headlessMenuShowing) {
310
320
  headlessDismissProgrammatic = true
311
321
  headlessMenu?.dismiss()
@@ -713,6 +723,9 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
713
723
 
714
724
  override fun onAttachedToWindow() {
715
725
  super.onAttachedToWindow()
726
+ PCNativeTheme.addListener(nativeThemeListener)
727
+ PCNativeTheme.attach(context)
728
+ if (builtThemeVersion != PCNativeTheme.version) nativeThemeListener.onNativeThemeChanged()
716
729
  // When attached, trigger a measure/layout pass to ensure correct sizing
717
730
  if (anchorMode == "inline") {
718
731
  // Use ViewTreeObserver to wait until after the first layout pass
@@ -741,6 +754,7 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
741
754
 
742
755
  override fun onDetachedFromWindow() {
743
756
  detachInlineDropdownOverlay()
757
+ PCNativeTheme.removeListener(nativeThemeListener)
744
758
  super.onDetachedFromWindow()
745
759
  }
746
760
 
@@ -1,11 +1,15 @@
1
1
  package com.platformcomponents
2
2
 
3
3
  import android.content.Context
4
+ import android.content.res.Resources
4
5
  import android.util.Log
5
6
  import android.util.TypedValue
6
7
  import android.view.ContextThemeWrapper
8
+ import android.view.View
9
+ import android.view.ViewGroup
7
10
  import androidx.annotation.AttrRes
8
11
  import androidx.appcompat.R as AppCompatR
12
+ import androidx.core.content.res.ResourcesCompat
9
13
  import com.google.android.material.R as MaterialR
10
14
 
11
15
  /**
@@ -39,9 +43,34 @@ internal object PCThemeSupport {
39
43
  * renders with Material 3 defaults instead of throwing.
40
44
  */
41
45
  fun materialContext(context: Context, component: String): Context {
42
- if (hasMaterialTheme(context)) return context
43
- warnMaterial(component)
44
- return ContextThemeWrapper(context, MaterialR.style.Theme_Material3_DayNight_NoActionBar)
46
+ val base =
47
+ if (hasMaterialTheme(context)) {
48
+ context
49
+ } else {
50
+ warnMaterial(component)
51
+ ContextThemeWrapper(context, MaterialR.style.Theme_Material3_DayNight_NoActionBar)
52
+ }
53
+ if (!PCNativeTheme.isActive) return base
54
+ // Re-applies the native theme's brand colors, which the Material 3 fallback resets.
55
+ return ContextThemeWrapper(base, R.style.PCNativeThemeOverlay)
56
+ }
57
+
58
+ /**
59
+ * Clears AndroidX's color state list cache for the themes the views under [root] were
60
+ * built with. Material widgets load their colors through that cache, keyed by theme,
61
+ * and widgets rebuilt for a new brand color use the same theme keys as the ones they
62
+ * replace, so without this they would get the previous brand colors back.
63
+ */
64
+ fun clearColorStateListCaches(root: View) {
65
+ val themes = HashSet<Resources.Theme>()
66
+ fun collect(view: View) {
67
+ themes.add(view.context.theme)
68
+ if (view is ViewGroup) {
69
+ for (i in 0 until view.childCount) collect(view.getChildAt(i))
70
+ }
71
+ }
72
+ collect(root)
73
+ themes.forEach { ResourcesCompat.clearCachesForTheme(it) }
45
74
  }
46
75
 
47
76
  /**
@@ -57,11 +86,17 @@ internal object PCThemeSupport {
57
86
  /**
58
87
  * Theme override for MaterialDatePicker / MaterialTimePicker when the activity
59
88
  * theme is not a Material theme. Returns 0 (no override) when it is.
89
+ * [nativeThemeFallback] is the variant that keeps the native theme's brand colors.
60
90
  */
61
- fun materialDialogThemeOverride(context: Context, component: String, fallbackTheme: Int): Int {
91
+ fun materialDialogThemeOverride(
92
+ context: Context,
93
+ component: String,
94
+ fallbackTheme: Int,
95
+ nativeThemeFallback: Int
96
+ ): Int {
62
97
  if (hasMaterialTheme(context)) return 0
63
98
  warnMaterial(component)
64
- return fallbackTheme
99
+ return if (PCNativeTheme.isActive) nativeThemeFallback else fallbackTheme
65
100
  }
66
101
 
67
102
  private fun resolves(context: Context, @AttrRes attr: Int): Boolean =
@@ -1,12 +1,13 @@
1
1
  package com.platformcomponents
2
2
 
3
- import com.facebook.react.ReactPackage
3
+ import com.facebook.react.BaseReactPackage
4
4
  import com.facebook.react.bridge.NativeModule
5
5
  import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
6
8
  import com.facebook.react.uimanager.ViewManager
7
- import java.util.ArrayList
8
9
 
9
- class PlatformComponentsViewPackage : ReactPackage {
10
+ class PlatformComponentsViewPackage : BaseReactPackage() {
10
11
  override fun createViewManagers(
11
12
  reactContext: ReactApplicationContext
12
13
  ): List<ViewManager<*, *>> {
@@ -19,7 +20,23 @@ class PlatformComponentsViewPackage : ReactPackage {
19
20
  )
20
21
  }
21
22
 
22
- override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
23
- return emptyList()
23
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
24
+ when (name) {
25
+ PCNativeThemeModule.NAME -> PCNativeThemeModule(reactContext)
26
+ else -> null
27
+ }
28
+
29
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
30
+ mapOf(
31
+ PCNativeThemeModule.NAME to
32
+ ReactModuleInfo(
33
+ name = PCNativeThemeModule.NAME,
34
+ className = PCNativeThemeModule::class.java.name,
35
+ canOverrideExistingModule = false,
36
+ needsEagerInit = false,
37
+ isCxxModule = false,
38
+ isTurboModule = true,
39
+ )
40
+ )
24
41
  }
25
42
  }