gluestack-ui icon indicating copy to clipboard operation
gluestack-ui copied to clipboard

Segmented Control

Open surajs18 opened this issue 5 months ago โ€ข 0 comments

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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) /> `

surajs18 avatar Jun 20 '25 07:06 surajs18