gluestack-ui
gluestack-ui copied to clipboard
Segmented Control
Description
`
The SegmentedControl is a customizable, animated tab switcher component in React Native. It's designed to allow users to toggle between multiple options (tabs) with smooth animated transitions, used often in UIs for filtering views (e.g., Active / Completed / Canceled orders).
Key Features:
Animated sliding selector (translateX)
Fading animated text colors
Dynamic segment width calculation
Two style presets: rounded (styles1) and rectangular (styles2)
Accepts customizable options and notifies parent on selection change
Props:
| Prop | Type | Description |
|---|---|---|
| options | array | Array of tab options { id, label } |
| defaultOption | string | Default selected tab id |
| onOptionChange | func | Callback triggered when selection changes |
| applyStyle | number | Style type selector (0 โ styles1, 1 โ styles2) |
`
Problem Statement
`While the component works well in many cases, there are some issues/limitations that can affect usability and maintainability:
- Hardcoded Width (width: "90%" and 98%) In styles1 and styles2, container width is hardcoded to "90%" or "98%". This makes the layout inflexible for different screen sizes or nested layouts. ๐ ๏ธ Solution: Make width customizable via props or use Dimensions.get("window").width.
- Height Too Small for Larger Text Text size was increased to 22px (as per your earlier request), but container height remains at 35, which causes clipping or cramped spacing. ๐ ๏ธ Solution: Dynamically calculate or increase height to match font size + padding.
- No Fallback When segmentWidths Not Ready The animated selector width uses segmentWidths[selectedId] || 0, which can be 0 initially, leading to a flicker. ๐ ๏ธ Solution: Add a minimum/default width fallback or delay render until all layouts are measured.
- Selector Animation Jumps on Layout Update Layout measurement depends on onLayout event of each tab. If they update at different times, the selector might jitter or animate unnecessarily. ๐ ๏ธ Solution: Use a useLayoutEffect or debounce state update until all widths are measured.
- Opacity Animation Can Be Simplified Animating opacity for each tab label is overkill for small tab counts. Interpolation also introduces complexity with edge cases (e.g., undefined values on fast tab switch). ๐ ๏ธ Alternative: Use conditional styles (e.g., changing text color directly) if animation isnโt critical.
- Code Duplication in Styles styles1 and styles2 are 90% similar. The main difference is border radius and font size. ๐ ๏ธ Solution: Abstract common styles and override only necessary parts. โ Improvements To Consider
Accept containerStyle and optionTextStyle as props Make it responsive by default Use Animated.View only for selector; keep text styling simpler Add accessibility props (e.g., accessibilityRole="button")`
Proposed Solution or API
`import React, { useState, useRef, useEffect } from "react"; import { View, Text, StyleSheet, Pressable, Animated, Dimensions, } from "react-native";
export default function SegmentedControl({ options, defaultOption, onOptionChange, applyStyle, }) { const [selectedId, setSelectedId] = useState( defaultOption || (options[0] && options[0].id) ); const [segmentWidths, setSegmentWidths] = useState({});
const translateX = useRef(new Animated.Value(0)).current;
const opacityValuesRef = useRef(null); if (!opacityValuesRef.current && options?.length > 0) { opacityValuesRef.current = options.reduce((acc, option) => { acc[option.id] = new Animated.Value(option.id === selectedId ? 1 : 0); return acc; }, {}); } const opacityValues = opacityValuesRef.current;
useEffect(() => { if (!opacityValues) return;
let position = 0;
for (const option of options) {
if (option.id === selectedId) break;
position += segmentWidths[option.id] || 0;
}
Animated.timing(translateX, {
toValue: position,
duration: 300,
useNativeDriver: true,
}).start();
options.forEach((option) => {
const animatedValue = opacityValues[option.id];
if (animatedValue) {
Animated.timing(animatedValue, {
toValue: option.id === selectedId ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start();
}
});
if (onOptionChange) {
const selectedOption = options.find((option) => option.id === selectedId);
if (selectedOption) onOptionChange(selectedOption);
}
}, [selectedId, segmentWidths]);
const handleSegmentLayout = (id, event) => { const { width } = event.nativeEvent.layout; setSegmentWidths((prev) => { if (prev[id] === width) return prev; return { ...prev, [id]: width, }; }); };
const handlePress = (id) => { if (id !== selectedId) setSelectedId(id); };
const insertStyle = [styles1, styles2];
return ( <View style={insertStyle[applyStyle].container}> {/* Animated Selector */} <Animated.View style={[ insertStyle[applyStyle].selector,
{
width: segmentWidths[selectedId] || 0,
transform: [{ translateX }],
},
]}
/>
{/* Option Buttons */}
{options.map((option) => {
const animatedValue = opacityValues?.[option.id];
const textColor = animatedValue
? animatedValue.interpolate({
inputRange: [0, 1],
outputRange: ["#000", "#fff"],
})
: "#000";
return (
<Pressable
key={option.id}
style={insertStyle[applyStyle].option}
onPress={() => handlePress(option.id)}
onLayout={(e) => handleSegmentLayout(option.id, e)}
>
<Animated.Text
style={[insertStyle[applyStyle].optionText, { color: textColor }]}
>
{option.label}
</Animated.Text>
</Pressable>
);
})}
</View>
); }
const styles1 = StyleSheet.create({ container: { flexDirection: "row", borderRadius: 50, borderWidth: 1, borderColor: "#1F4D5B", height: 35, overflow: "hidden", width: "90%", alignSelf: "center", backgroundColor: "#FFFFFF", }, selector: { position: "absolute", top: 0, left: 0, bottom: 0, backgroundColor: "#1F4D5B", borderRadius: 50, }, option: { flex: 1, justifyContent: "center", alignItems: "center", zIndex: 1, }, optionText: { fontSize: 14, fontWeight: "600", }, });
const styles2 = StyleSheet.create({ container: { flexDirection: "row", borderRadius: 10, // More rectangular with rounded corners borderWidth: 1, borderColor: "#E5E5E5", // Lighter border color height: 35, // Taller height to match the image overflow: "hidden", width: "98%", alignSelf: "center", backgroundColor: "#FFFFFF", shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 8, elevation: 2, }, selector: { position: "absolute", top: 0, left: 0, bottom: 0, backgroundColor: "#1F4D5B", borderRadius: 10, // Slightly smaller radius for the selector }, option: { flex: 1, justifyContent: "center", alignItems: "center", zIndex: 1, }, optionText: { fontSize: 15, // Larger font size to match the image fontWeight: "500", }, }); `
Alternatives
No response
Additional Information
How to use:
` const [selectedTab, setSelectedTab] = useState({ id: "active", label: "Active", });
// Segmented control options const tabOptions = [ { id: "active", label: "Active" }, { id: "completed", label: "Completed" }, { id: "canceled", label: "Canceled" }, ];
const handleTabChange = (option) => { setSelectedTab(option); setExpandedItem(null); // Reset expanded item when changing tabs };
<SegmentedControl options={tabOptions} defaultOption={selectedTab?.id} onOptionChange={handleTabChange} applyStyle={1} // Use the first style (rounded) /> `