react-native-platform-components 0.8.10 → 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.
- package/README.md +126 -30
- package/android/src/main/java/com/platformcomponents/PCImageLoader.kt +88 -0
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +288 -29
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +74 -8
- 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/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/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 +29 -25
- package/plugin/tsconfig.json +2 -2
- 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
|
@@ -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,68 @@ 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
|
val group = MaterialButtonToggleGroup(context).apply {
|
|
87
194
|
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
88
195
|
isSingleSelection = true
|
|
89
196
|
isSelectionRequired = selectionRequired
|
|
197
|
+
clipChildren = false
|
|
198
|
+
clipToPadding = false
|
|
90
199
|
}
|
|
91
200
|
|
|
92
201
|
// Calculate if we need compact mode (many segments or long labels)
|
|
@@ -94,49 +203,59 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
|
|
|
94
203
|
val useCompactMode = segments.size > 3 || totalLabelLength > 20
|
|
95
204
|
|
|
96
205
|
for ((index, segment) in segments.withIndex()) {
|
|
206
|
+
// "unlabeled" hides the text of segments that have an icon; segments
|
|
207
|
+
// without one keep their label. "auto" and "labeled" show icon + label.
|
|
208
|
+
val iconOnly = labelVisibility == "unlabeled" && segment.hasIcon
|
|
209
|
+
|
|
97
210
|
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
|
98
211
|
id = View.generateViewId()
|
|
99
|
-
text = segment.label
|
|
212
|
+
text = if (iconOnly) "" else segment.label
|
|
100
213
|
isAllCaps = false // Preserve original text casing
|
|
101
|
-
|
|
214
|
+
// Screen readers (and Detox) always get the label, even when hidden
|
|
215
|
+
contentDescription = segment.spokenLabel
|
|
102
216
|
isEnabled = !segment.disabled && interactivity == "enabled"
|
|
103
217
|
|
|
104
218
|
// Enable text truncation with ellipsis when space is limited
|
|
105
219
|
ellipsize = TextUtils.TruncateAt.END
|
|
106
220
|
maxLines = 1
|
|
107
221
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
222
|
+
// Material 3 segmented buttons use 12dp horizontal padding and an 8dp
|
|
223
|
+
// icon gap (the outlined button style defaults to 24dp); tighten
|
|
224
|
+
// further when there are many segments or long labels.
|
|
225
|
+
val density = resources.displayMetrics.density
|
|
226
|
+
val horizontalPadding = ((if (useCompactMode) 8 else 12) * density).toInt()
|
|
227
|
+
setPaddingRelative(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
|
228
|
+
iconPadding = ((if (useCompactMode) 4 else 8) * density).toInt()
|
|
229
|
+
|
|
230
|
+
if (iconOnly) {
|
|
231
|
+
// Centre the icon instead of leaving it at the start edge
|
|
232
|
+
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
|
233
|
+
iconPadding = 0
|
|
113
234
|
}
|
|
114
235
|
|
|
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
|
-
}
|
|
236
|
+
applyIcon(this, segment, generation)
|
|
237
|
+
styleButton(this, segment)
|
|
131
238
|
}
|
|
132
239
|
|
|
133
240
|
buttonIdToSegment[button.id] = segment
|
|
134
|
-
|
|
241
|
+
// Share the width equally (like UISegmentedControl) so trailing segments
|
|
242
|
+
// never overflow the control; long labels ellipsize instead.
|
|
243
|
+
group.addView(button, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
|
|
244
|
+
attachBadge(button, segment)
|
|
135
245
|
}
|
|
136
246
|
|
|
137
|
-
group.addOnButtonCheckedListener {
|
|
247
|
+
group.addOnButtonCheckedListener { toggleGroup, checkedId, isChecked ->
|
|
138
248
|
if (suppressCallbacks) return@addOnButtonCheckedListener
|
|
139
|
-
|
|
249
|
+
|
|
250
|
+
if (!isChecked) {
|
|
251
|
+
// In single-selection mode switching from A to B reports A as unchecked
|
|
252
|
+
// before B is reported as checked; by then the group already knows B is
|
|
253
|
+
// checked. Only a tap that leaves nothing checked is a real deselection.
|
|
254
|
+
if (toggleGroup.checkedButtonId == View.NO_ID) {
|
|
255
|
+
onSelect?.invoke(-1, "")
|
|
256
|
+
}
|
|
257
|
+
return@addOnButtonCheckedListener
|
|
258
|
+
}
|
|
140
259
|
|
|
141
260
|
val segment = buttonIdToSegment[checkedId] ?: return@addOnButtonCheckedListener
|
|
142
261
|
val index = segments.indexOf(segment)
|
|
@@ -153,6 +272,117 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
|
|
|
153
272
|
requestLayout()
|
|
154
273
|
}
|
|
155
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Resolves a segment's icon. Drawable names and release-bundled assets (which
|
|
277
|
+
* React Native ships as drawable resources) resolve synchronously; other
|
|
278
|
+
* image URIs load in the background and are applied when they arrive.
|
|
279
|
+
*/
|
|
280
|
+
private fun applyIcon(button: MaterialButton, segment: Segment, generation: Int) {
|
|
281
|
+
if (!segment.iconTinted) {
|
|
282
|
+
button.iconTint = null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
when (segment.iconType) {
|
|
286
|
+
"drawable" -> {
|
|
287
|
+
button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, segment.iconName)
|
|
288
|
+
}
|
|
289
|
+
"image" -> {
|
|
290
|
+
val uri = segment.iconUri
|
|
291
|
+
val isResourceName = !uri.contains(':')
|
|
292
|
+
if (isResourceName) {
|
|
293
|
+
button.icon = ResourceDrawableIdHelper.getResourceDrawable(context, uri)
|
|
294
|
+
} else {
|
|
295
|
+
PCImageLoader.load(context, uri, segment.iconScale) { bitmap ->
|
|
296
|
+
if (bitmap == null || generation != rebuildGeneration) return@load
|
|
297
|
+
button.icon = BitmapDrawable(resources, bitmap)
|
|
298
|
+
requestLayout()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Draws a Material badge over the button's top-end corner. The badge lives
|
|
307
|
+
* in the button's overlay, so it needs the button's final bounds: attach
|
|
308
|
+
* after the first layout and follow later size changes.
|
|
309
|
+
*/
|
|
310
|
+
private fun attachBadge(button: MaterialButton, segment: Segment) {
|
|
311
|
+
if (segment.badge.isEmpty()) return
|
|
312
|
+
|
|
313
|
+
val inset = (2 * resources.displayMetrics.density).toInt()
|
|
314
|
+
val badge = BadgeDrawable.create(context).apply {
|
|
315
|
+
text = segment.badge
|
|
316
|
+
badgeGravity = BadgeDrawable.TOP_END
|
|
317
|
+
badgeBackgroundColor?.let { backgroundColor = it }
|
|
318
|
+
badgeTextColor?.let { setBadgeTextColor(it) }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
var attached = false
|
|
322
|
+
button.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
|
323
|
+
if (!attached) {
|
|
324
|
+
attached = true
|
|
325
|
+
BadgeUtils.attachBadgeDrawable(badge, view)
|
|
326
|
+
// The badge's size is only known once it has an anchor. Offset it by
|
|
327
|
+
// half its size so it sits fully inside the button corner; a badge
|
|
328
|
+
// that overhangs the last segment would be clipped by the control.
|
|
329
|
+
badge.horizontalOffset = badge.intrinsicWidth / 2 + inset
|
|
330
|
+
badge.verticalOffset = badge.intrinsicHeight / 2 + inset
|
|
331
|
+
}
|
|
332
|
+
badge.updateBadgeCoordinates(view)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Applies the color and font props on top of the Material theme defaults.
|
|
338
|
+
* Buttons are rebuilt whenever a style prop changes, so the theme values
|
|
339
|
+
* read here are always the untouched defaults.
|
|
340
|
+
*/
|
|
341
|
+
private fun styleButton(button: MaterialButton, segment: Segment) {
|
|
342
|
+
val checkedState = intArrayOf(android.R.attr.state_checked)
|
|
343
|
+
val disabledState = intArrayOf(-android.R.attr.state_enabled)
|
|
344
|
+
|
|
345
|
+
if (activeTintColor != null || inactiveTintColor != null) {
|
|
346
|
+
val theme = button.textColors
|
|
347
|
+
val disabled = theme.getColorForState(disabledState, theme.defaultColor)
|
|
348
|
+
val checked = activeTintColor ?: theme.getColorForState(checkedState, theme.defaultColor)
|
|
349
|
+
val normal = inactiveTintColor ?: theme.defaultColor
|
|
350
|
+
val tint = ColorStateList(
|
|
351
|
+
arrayOf(disabledState, checkedState, intArrayOf()),
|
|
352
|
+
intArrayOf(disabled, checked, normal)
|
|
353
|
+
)
|
|
354
|
+
button.setTextColor(tint)
|
|
355
|
+
if (segment.iconTinted) {
|
|
356
|
+
button.iconTint = tint
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
selectedSegmentColor?.let { color ->
|
|
361
|
+
val theme = button.backgroundTintList
|
|
362
|
+
val normal = theme?.defaultColor ?: Color.TRANSPARENT
|
|
363
|
+
button.backgroundTintList = ColorStateList(
|
|
364
|
+
arrayOf(checkedState, intArrayOf()),
|
|
365
|
+
intArrayOf(color, normal)
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
rippleColor?.let { button.rippleColor = ColorStateList.valueOf(it) }
|
|
370
|
+
strokeColor?.let { button.strokeColor = ColorStateList.valueOf(it) }
|
|
371
|
+
|
|
372
|
+
if (labelFontSize > 0) {
|
|
373
|
+
button.setTextSize(TypedValue.COMPLEX_UNIT_SP, labelFontSize)
|
|
374
|
+
}
|
|
375
|
+
if (labelFontFamily.isNotEmpty() || labelFontWeight.isNotEmpty() || labelFontStyle.isNotEmpty()) {
|
|
376
|
+
button.typeface = ReactTypefaceUtils.applyStyles(
|
|
377
|
+
button.typeface,
|
|
378
|
+
ReactTypefaceUtils.parseFontStyle(labelFontStyle.ifEmpty { null }),
|
|
379
|
+
ReactTypefaceUtils.parseFontWeight(labelFontWeight.ifEmpty { null }),
|
|
380
|
+
labelFontFamily.ifEmpty { null },
|
|
381
|
+
context.assets
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
156
386
|
private fun updateSelection() {
|
|
157
387
|
suppressCallbacks = true
|
|
158
388
|
val group = toggleGroup ?: return
|
|
@@ -182,6 +412,35 @@ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScro
|
|
|
182
412
|
}
|
|
183
413
|
}
|
|
184
414
|
|
|
415
|
+
// ---- Layout ----
|
|
416
|
+
|
|
417
|
+
private var manualLayoutPending = false
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* React Native's root view ignores requestLayout after the initial pass, so
|
|
421
|
+
* a native view that changes its own subtree later (rebuilding segments for
|
|
422
|
+
* a prop change, an icon finishing loading) has to measure and lay itself
|
|
423
|
+
* out. Coalesced to one pass per frame.
|
|
424
|
+
*/
|
|
425
|
+
override fun requestLayout() {
|
|
426
|
+
super.requestLayout()
|
|
427
|
+
if (!manualLayoutPending) {
|
|
428
|
+
manualLayoutPending = true
|
|
429
|
+
post { runManualLayout() }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private fun runManualLayout() {
|
|
434
|
+
manualLayoutPending = false
|
|
435
|
+
// Nothing to do until Yoga has placed us; the regular pass covers that.
|
|
436
|
+
if (width == 0 || height == 0) return
|
|
437
|
+
measure(
|
|
438
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
439
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
|
440
|
+
)
|
|
441
|
+
layout(left, top, right, bottom)
|
|
442
|
+
}
|
|
443
|
+
|
|
185
444
|
// ---- Measurement ----
|
|
186
445
|
|
|
187
446
|
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
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Loads images referenced by React Native asset URIs for native controls that
|
|
4
|
+
/// take a `UIImage` (segmented control icons).
|
|
5
|
+
///
|
|
6
|
+
/// Release builds bundle local assets next to the JS bundle, so those arrive as
|
|
7
|
+
/// `file://` URLs and load synchronously. Debug builds and remote sources arrive
|
|
8
|
+
/// as `http(s)://` (Metro) or `data:` URLs and load asynchronously.
|
|
9
|
+
///
|
|
10
|
+
/// Must be used from the main thread.
|
|
11
|
+
final class PCImageLoader {
|
|
12
|
+
static let shared = PCImageLoader()
|
|
13
|
+
|
|
14
|
+
private var cache: [String: UIImage] = [:]
|
|
15
|
+
private var pending: [String: [(UIImage?) -> Void]] = [:]
|
|
16
|
+
|
|
17
|
+
/// Returns the image immediately when it is cached or readable from disk.
|
|
18
|
+
/// Otherwise starts a load and calls `completion` on the main thread with
|
|
19
|
+
/// the result; the return value is `nil` in that case.
|
|
20
|
+
@discardableResult
|
|
21
|
+
func image(
|
|
22
|
+
uri: String,
|
|
23
|
+
scale: CGFloat,
|
|
24
|
+
completion: @escaping (UIImage?) -> Void
|
|
25
|
+
) -> UIImage? {
|
|
26
|
+
let key = "\(uri)@\(scale)x"
|
|
27
|
+
if let cached = cache[key] {
|
|
28
|
+
return cached
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
guard let url = URL(string: uri) else { return nil }
|
|
32
|
+
|
|
33
|
+
if url.isFileURL {
|
|
34
|
+
guard let data = try? Data(contentsOf: url),
|
|
35
|
+
let image = UIImage(data: data, scale: scale) else { return nil }
|
|
36
|
+
cache[key] = image
|
|
37
|
+
return image
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if pending[key] != nil {
|
|
41
|
+
pending[key]?.append(completion)
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
pending[key] = [completion]
|
|
45
|
+
|
|
46
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
|
47
|
+
let image = data.flatMap { UIImage(data: $0, scale: scale) }
|
|
48
|
+
DispatchQueue.main.async {
|
|
49
|
+
guard let self else { return }
|
|
50
|
+
if let image { self.cache[key] = image }
|
|
51
|
+
let callbacks = self.pending.removeValue(forKey: key) ?? []
|
|
52
|
+
callbacks.forEach { $0(image) }
|
|
53
|
+
}
|
|
54
|
+
}.resume()
|
|
55
|
+
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
}
|