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