Skip to content

Commit 8d0288a

Browse files
committed
feat: add SegmentedButton component
Add Material3 SegmentedButton component with single-select and multi-select modes. Features: - Single-select mode (default): allows selecting one segment - Multi-select mode: allows toggling multiple segments - Optional icon support using Material Icons - Disabled state support - Type-safe discriminated union for props - Comprehensive test coverage (17 tests) Implementation: - TypeScript spec with codegen support - React wrapper with discriminated union types - Jetpack Compose View using SingleChoiceSegmentedButtonRow and MultiChoiceSegmentedButtonRow - Event handling for both single and multi-select modes - Values in multi-select mode passed as JSON string for codegen compatibility
1 parent 76f5b9c commit 8d0288a

8 files changed

Lines changed: 741 additions & 0 deletions

File tree

android/src/main/java/com/mgcrea/reactnative/jetpackcompose/RNJetpackComposePackage.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class RNJetpackComposePackage : ReactPackage {
1818
TimePickerViewManager(),
1919
TimeRangePickerViewManager(),
2020
PickerViewManager(),
21+
SegmentedButtonViewManager(),
2122
SheetPickerViewManager(),
2223
TextFieldViewManager(),
2324
)
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package com.mgcrea.reactnative.jetpackcompose
2+
3+
import androidx.compose.foundation.layout.fillMaxWidth
4+
import androidx.compose.material.icons.Icons
5+
import androidx.compose.material.icons.filled.Add
6+
import androidx.compose.material.icons.filled.Check
7+
import androidx.compose.material.icons.filled.Close
8+
import androidx.compose.material.icons.filled.Delete
9+
import androidx.compose.material.icons.filled.Done
10+
import androidx.compose.material.icons.filled.Edit
11+
import androidx.compose.material.icons.filled.Email
12+
import androidx.compose.material.icons.filled.Favorite
13+
import androidx.compose.material.icons.filled.Home
14+
import androidx.compose.material.icons.filled.Info
15+
import androidx.compose.material.icons.filled.List
16+
import androidx.compose.material.icons.filled.Notifications
17+
import androidx.compose.material.icons.filled.Person
18+
import androidx.compose.material.icons.filled.Phone
19+
import androidx.compose.material.icons.filled.Remove
20+
import androidx.compose.material.icons.filled.Search
21+
import androidx.compose.material.icons.filled.Settings
22+
import androidx.compose.material.icons.filled.Share
23+
import androidx.compose.material.icons.filled.Star
24+
import androidx.compose.material.icons.filled.Warning
25+
import androidx.compose.material3.ExperimentalMaterial3Api
26+
import androidx.compose.material3.Icon
27+
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
28+
import androidx.compose.material3.SegmentedButton
29+
import androidx.compose.material3.SegmentedButtonDefaults
30+
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
31+
import androidx.compose.material3.Text
32+
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.graphics.vector.ImageVector
36+
import com.facebook.react.bridge.ReadableArray
37+
import com.facebook.react.uimanager.ThemedReactContext
38+
import com.mgcrea.reactnative.jetpackcompose.core.InlineComposeView
39+
import com.mgcrea.reactnative.jetpackcompose.events.SegmentValueChangeEvent
40+
import com.mgcrea.reactnative.jetpackcompose.events.SegmentValuesChangeEvent
41+
42+
@OptIn(ExperimentalMaterial3Api::class)
43+
internal class SegmentedButtonView(reactContext: ThemedReactContext) :
44+
InlineComposeView(reactContext, TAG) {
45+
46+
companion object {
47+
private const val TAG = "SegmentedButtonView"
48+
}
49+
50+
// Data class for segment options
51+
data class SegmentOption(
52+
val value: String,
53+
val label: String,
54+
val icon: String? = null
55+
)
56+
57+
// State backing for Compose
58+
private val _segments = mutableStateOf<List<SegmentOption>>(emptyList())
59+
private val _selectedValue = mutableStateOf<String?>(null)
60+
private val _selectedValues = mutableStateOf<Set<String>>(emptySet())
61+
private val _multiSelect = mutableStateOf(false)
62+
private val _disabled = mutableStateOf(false)
63+
64+
// Property setters called by ViewManager
65+
fun setSegments(array: ReadableArray?) {
66+
_segments.value = array?.let { arr ->
67+
List(arr.size()) { i ->
68+
val map = arr.getMap(i)
69+
SegmentOption(
70+
value = map?.getString("value") ?: "",
71+
label = map?.getString("label") ?: "",
72+
icon = map?.getString("icon")
73+
)
74+
}
75+
} ?: emptyList()
76+
}
77+
78+
fun setSelectedValue(value: String?) {
79+
_selectedValue.value = value
80+
}
81+
82+
fun setSelectedValues(array: ReadableArray?) {
83+
_selectedValues.value = array?.let { arr ->
84+
(0 until arr.size()).mapNotNull { i ->
85+
arr.getString(i)
86+
}.toSet()
87+
} ?: emptySet()
88+
}
89+
90+
fun setMultiSelect(value: Boolean) {
91+
_multiSelect.value = value
92+
}
93+
94+
fun setDisabled(value: Boolean) {
95+
_disabled.value = value
96+
}
97+
98+
// Icon mapping (extended from TextFieldView pattern)
99+
private fun getIcon(name: String?): ImageVector? = when (name?.lowercase()) {
100+
"check" -> Icons.Default.Check
101+
"star" -> Icons.Default.Star
102+
"favorite" -> Icons.Default.Favorite
103+
"home" -> Icons.Default.Home
104+
"settings" -> Icons.Default.Settings
105+
"person" -> Icons.Default.Person
106+
"email" -> Icons.Default.Email
107+
"phone" -> Icons.Default.Phone
108+
"search" -> Icons.Default.Search
109+
"add" -> Icons.Default.Add
110+
"remove" -> Icons.Default.Remove
111+
"close" -> Icons.Default.Close
112+
"done" -> Icons.Default.Done
113+
"edit" -> Icons.Default.Edit
114+
"delete" -> Icons.Default.Delete
115+
"share" -> Icons.Default.Share
116+
"info" -> Icons.Default.Info
117+
"warning" -> Icons.Default.Warning
118+
"notifications" -> Icons.Default.Notifications
119+
"list" -> Icons.Default.List
120+
else -> null
121+
}
122+
123+
@Composable
124+
override fun ComposeContent() {
125+
val segments = _segments.value
126+
val selectedValue = _selectedValue.value
127+
val selectedValues = _selectedValues.value
128+
val multiSelect = _multiSelect.value
129+
val disabled = _disabled.value
130+
131+
if (segments.isEmpty()) return
132+
133+
if (multiSelect) {
134+
MultiChoiceSegmentedButtonRow(
135+
modifier = Modifier.fillMaxWidth()
136+
) {
137+
segments.forEachIndexed { index, segment ->
138+
val isChecked = selectedValues.contains(segment.value)
139+
140+
SegmentedButton(
141+
checked = isChecked,
142+
onCheckedChange = { checked ->
143+
if (!disabled) {
144+
val newValues = if (checked) {
145+
selectedValues + segment.value
146+
} else {
147+
selectedValues - segment.value
148+
}
149+
dispatchEvent(
150+
SegmentValuesChangeEvent(
151+
getSurfaceId(),
152+
id,
153+
newValues.toList()
154+
)
155+
)
156+
}
157+
},
158+
enabled = !disabled,
159+
shape = SegmentedButtonDefaults.itemShape(
160+
index = index,
161+
count = segments.size
162+
),
163+
icon = {
164+
SegmentedButtonDefaults.Icon(active = isChecked) {
165+
segment.icon?.let { iconName ->
166+
getIcon(iconName)?.let { icon ->
167+
Icon(
168+
imageVector = icon,
169+
contentDescription = null
170+
)
171+
}
172+
}
173+
}
174+
},
175+
label = { Text(segment.label) }
176+
)
177+
}
178+
}
179+
} else {
180+
SingleChoiceSegmentedButtonRow(
181+
modifier = Modifier.fillMaxWidth()
182+
) {
183+
segments.forEachIndexed { index, segment ->
184+
val isSelected = segment.value == selectedValue
185+
186+
SegmentedButton(
187+
selected = isSelected,
188+
onClick = {
189+
if (!disabled && segment.value != selectedValue) {
190+
dispatchEvent(
191+
SegmentValueChangeEvent(
192+
getSurfaceId(),
193+
id,
194+
segment.value,
195+
index
196+
)
197+
)
198+
}
199+
},
200+
enabled = !disabled,
201+
shape = SegmentedButtonDefaults.itemShape(
202+
index = index,
203+
count = segments.size
204+
),
205+
icon = {
206+
SegmentedButtonDefaults.Icon(active = isSelected) {
207+
segment.icon?.let { iconName ->
208+
getIcon(iconName)?.let { icon ->
209+
Icon(
210+
imageVector = icon,
211+
contentDescription = null
212+
)
213+
}
214+
}
215+
}
216+
},
217+
label = { Text(segment.label) }
218+
)
219+
}
220+
}
221+
}
222+
}
223+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.mgcrea.reactnative.jetpackcompose
2+
3+
import com.facebook.react.bridge.ReadableArray
4+
import com.facebook.react.module.annotations.ReactModule
5+
import com.facebook.react.uimanager.SimpleViewManager
6+
import com.facebook.react.uimanager.ThemedReactContext
7+
import com.facebook.react.uimanager.UIManagerHelper
8+
import com.facebook.react.uimanager.ViewManagerDelegate
9+
import com.facebook.react.viewmanagers.SegmentedButtonViewManagerDelegate
10+
import com.facebook.react.viewmanagers.SegmentedButtonViewManagerInterface
11+
12+
@ReactModule(name = SegmentedButtonViewManager.NAME)
13+
internal class SegmentedButtonViewManager :
14+
SimpleViewManager<SegmentedButtonView>(),
15+
SegmentedButtonViewManagerInterface<SegmentedButtonView> {
16+
17+
private val delegate: ViewManagerDelegate<SegmentedButtonView> =
18+
SegmentedButtonViewManagerDelegate(this)
19+
20+
override fun getName(): String = NAME
21+
22+
override fun createViewInstance(context: ThemedReactContext): SegmentedButtonView =
23+
SegmentedButtonView(context)
24+
25+
override fun getDelegate(): ViewManagerDelegate<SegmentedButtonView> = delegate
26+
27+
override fun onDropViewInstance(view: SegmentedButtonView) {
28+
super.onDropViewInstance(view)
29+
view.onDropInstance()
30+
}
31+
32+
override fun addEventEmitters(reactContext: ThemedReactContext, view: SegmentedButtonView) {
33+
view.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
34+
}
35+
36+
override fun setSegments(view: SegmentedButtonView, value: ReadableArray?) {
37+
view.setSegments(value)
38+
}
39+
40+
override fun setSelectedValue(view: SegmentedButtonView, value: String?) {
41+
view.setSelectedValue(value)
42+
}
43+
44+
override fun setSelectedValues(view: SegmentedButtonView, value: ReadableArray?) {
45+
view.setSelectedValues(value)
46+
}
47+
48+
override fun setMultiSelect(view: SegmentedButtonView, value: Boolean) {
49+
view.setMultiSelect(value)
50+
}
51+
52+
override fun setDisabled(view: SegmentedButtonView, value: Boolean) {
53+
view.setDisabled(value)
54+
}
55+
56+
companion object {
57+
const val NAME = "SegmentedButtonView"
58+
}
59+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.mgcrea.reactnative.jetpackcompose.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
7+
/**
8+
* Fired when selection changes in single-select mode.
9+
* Event name: "topValueChange" (maps to onValueChange prop)
10+
*/
11+
class SegmentValueChangeEvent(
12+
surfaceId: Int,
13+
viewId: Int,
14+
private val value: String,
15+
private val index: Int
16+
) : Event<SegmentValueChangeEvent>(surfaceId, viewId) {
17+
18+
override fun getEventName(): String = EVENT_NAME
19+
20+
override fun getEventData(): WritableMap =
21+
Arguments.createMap().apply {
22+
putString("value", value)
23+
putDouble("index", index.toDouble())
24+
}
25+
26+
companion object {
27+
const val EVENT_NAME = "topValueChange"
28+
}
29+
}
30+
31+
/**
32+
* Fired when selection changes in multi-select mode.
33+
* Event name: "topValuesChange" (maps to onValuesChange prop)
34+
* Values are passed as a JSON-encoded string array.
35+
*/
36+
class SegmentValuesChangeEvent(
37+
surfaceId: Int,
38+
viewId: Int,
39+
private val values: List<String>
40+
) : Event<SegmentValuesChangeEvent>(surfaceId, viewId) {
41+
42+
override fun getEventName(): String = EVENT_NAME
43+
44+
override fun getEventData(): WritableMap =
45+
Arguments.createMap().apply {
46+
// Encode values as JSON string array for codegen compatibility
47+
val jsonValues = values.joinToString(separator = ",", prefix = "[", postfix = "]") { "\"$it\"" }
48+
putString("values", jsonValues)
49+
}
50+
51+
companion object {
52+
const val EVENT_NAME = "topValuesChange"
53+
}
54+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ export { DatePicker, type DatePickerProps, type YearRange } from "./native/DateP
22
export { DateRangePicker, type DateRange, type DateRangePickerProps } from "./native/DateRangePicker";
33
export { ModalBottomSheet } from "./native/ModalBottomSheet";
44
export { Picker, type PickerOption, type PickerProps } from "./native/Picker";
5+
export {
6+
SegmentedButton,
7+
type SegmentedButtonOption,
8+
type SegmentedButtonProps,
9+
} from "./native/SegmentedButton";
510
export { SheetPicker, type SheetPickerOption, type SheetPickerProps } from "./native/SheetPicker";
611
export { TextField, type TextFieldProps } from "./native/TextField";
712
export { TimePicker, type TimePickerProps } from "./native/TimePicker";

0 commit comments

Comments
 (0)