'use client'; import React, { useEffect, useRef, useMemo } from 'react'; import { createTabs, TabsContext } from '@gluestack-ui/core/tabs/creator'; import { UIIcon } from '@gluestack-ui/core/icon/creator'; import { tva, withStyleContext, useStyleContext, type VariantProps, } from '@gluestack-ui/utils/nativewind-utils'; import { styled } from 'nativewind'; import { Pressable, Text, View, FlatList, Platform } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, useAnimatedScrollHandler, runOnJS, } from 'react-native-reanimated'; import { TabsAnimatedIndicator } from './TabsAnimatedIndicator'; const SCOPE = 'TABS'; const AnimatedView = Animated.createAnimatedComponent(View); const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); /** Styles */ const tabsStyle = tva({ base: 'w-full gap-1', }); const tabsListStyle = tva({ base: 'flex relative z-10 bg-muted p-1 rounded-lg', variants: { orientation: { horizontal: 'flex-row', vertical: 'flex-col', }, }, }); const tabsTriggerStyle = tva({ base: 'justify-center relative z-30 items-center web:outline-none data-[disabled=true]:opacity-40 data-[focus-visible=true]:web:ring-2 data-[focus-visible=true]:web:ring-primary/20 px-3 py-1.5 flex-row gap-1', parentVariants: { variant: { underlined: '', filled: 'rounded-lg', }, }, }); const tabsTriggerTextStyle = tva({ base: 'text-foreground/70 data-[selected=true]:text-foreground font-medium data-[hover=true]:text-foreground/90 ', }); const tabsTriggerIconStyle = tva({ base: 'h-4 w-4 fill-none pointer-events-none shrink-0 text-foreground/70 data-[selected=true]:text-foreground', }); const tabsContentStyle = tva({ base: 'p-2 h-auto', }); const tabsContentWrapperStyle = tva({ base: 'overflow-hidden rounded-lg', }); const tabsIndicatorStyle = tva({ base: 'pointer-events-none', parentVariants: { variant: { underlined: 'border-b border-primary', filled: 'bg-background z-20 rounded-lg', }, }, }); /** Creator */ const Root = withStyleContext(View, SCOPE); const StyledUIIcon = styled(UIIcon, { className: 'style', }); const UITabs = createTabs({ Root, List: View, Trigger: Pressable, Content: View, ContentWrapper: AnimatedView, TriggerText: Text, TriggerIcon: StyledUIIcon, Indicator: View, }); /** Type definitions */ type ITabsProps = React.ComponentPropsWithoutRef & VariantProps & { variant?: 'underlined' | 'filled'; }; type ITabsListProps = React.ComponentPropsWithoutRef; type ITabsTriggerProps = React.ComponentPropsWithoutRef; type ITabsContentProps = React.ComponentPropsWithoutRef; type ITabsContentWrapperProps = React.ComponentPropsWithoutRef< typeof UITabs.ContentWrapper >; type ITabsTriggerTextProps = React.ComponentPropsWithoutRef< typeof UITabs.TriggerText >; type ITabsTriggerIconProps = React.ComponentPropsWithoutRef< typeof UITabs.TriggerIcon > & { as?: React.ElementType; }; type ITabsIndicatorProps = React.ComponentPropsWithoutRef< typeof UITabs.Indicator >; /** Components */ const Tabs = React.forwardRef, ITabsProps>( ({ className, variant = 'filled', ...props }, ref) => { return ( ); } ); const TabsList = React.forwardRef< React.ComponentRef, ITabsListProps >(({ className, children, ...props }, ref) => { const context = React.useContext(TabsContext); const flatListRef = useRef(null); if (!context) return null; const { orientation, setScrollOffset, selectedKey, listRef } = context; // Shared value for indicator sync const animatedScrollOffset = useSharedValue(0); /** * Expose shared value to context */ useEffect(() => { if (context) { // @ts-ignore context.animatedScrollOffset = animatedScrollOffset; } }, [context, animatedScrollOffset]); /** * Auto scroll to selected tab */ useEffect(() => { if (orientation !== 'horizontal' || !selectedKey) return; const childArray = React.Children.toArray(children); const triggers = childArray.filter( (child: any) => child?.type?.displayName !== 'TabsIndicator' ); const selectedIndex = triggers.findIndex( (child: any) => child?.props?.value === selectedKey ); if (selectedIndex >= 0 && flatListRef.current) { const timer = setTimeout(() => { try { flatListRef.current.scrollToIndex({ index: selectedIndex, animated: true, viewPosition: 0.5, }); } catch {} }, 100); return () => clearTimeout(timer); } }, [selectedKey, orientation, children]); /** * Native animated scroll handler (ONLY for iOS / Android) */ const nativeScrollHandler = Platform.OS === 'web' ? undefined : useAnimatedScrollHandler({ onScroll: (event) => { 'worklet'; const x = event.contentOffset.x; animatedScrollOffset.value = x; runOnJS(setScrollOffset)(x); }, }); /** * Web scroll handler (JS thread) */ const handleWebScroll = (e: any) => { const x = e.nativeEvent.contentOffset.x; animatedScrollOffset.value = x; setScrollOffset(x); }; /** * Horizontal tabs → FlatList */ // Memoize the split so FlatList's `data` prop stays referentially stable // across scroll-driven re-renders (scrollOffset in context ticks on every // scroll event; without this, FlatList re-renders every cell every frame, // firing onLayout → measureTrigger on every scroll tick). const { triggers, indicator } = useMemo(() => { const childArray = React.Children.toArray(children); return { triggers: childArray.filter( (child: any) => child?.type?.displayName !== 'TabsIndicator' ), indicator: childArray.find( (child: any) => child?.type?.displayName === 'TabsIndicator' ), }; }, [children]); if (orientation === 'horizontal') { return ( {indicator} item as any} keyExtractor={(item: any, index) => item?.props?.value ?? `tab-${index}` } showsHorizontalScrollIndicator={false} scrollEventThrottle={16} style={{ zIndex: 100 }} onScroll={ Platform.OS === 'web' ? handleWebScroll : nativeScrollHandler } onScrollToIndexFailed={(info) => { setTimeout(() => { try { flatListRef.current?.scrollToIndex({ index: info.index, animated: false, viewPosition: 0.5, }); } catch {} }, 500); }} {...props} /> ); } /** * Vertical tabs → default List */ return ( {children} ); }); const TabsTrigger = React.forwardRef< React.ComponentRef, ITabsTriggerProps >(({ className, ...props }, ref) => { const { variant } = useStyleContext(SCOPE); return ( ); }); const TabsContent = React.forwardRef< React.ComponentRef, ITabsContentProps >(({ className, ...props }, ref) => { return ( ); }); const TabsContentWrapper = React.forwardRef< React.ComponentRef, ITabsContentWrapperProps >(({ className, targetHeight, ...props }: any, ref) => { const context = React.useContext(TabsContext); // Get the height of the selected content from the layouts Map const selectedLayout = context?.selectedKey ? context.contentLayouts.get(context.selectedKey) : null; const height = selectedLayout?.height || 0; // Use shared value for Reanimated with initial height const heightValue = useSharedValue(height); const isFirstRender = React.useRef(true); // Update shared value when height changes React.useEffect(() => { if (height > 0) { if (isFirstRender.current) { // Set initial height without animation heightValue.value = height; isFirstRender.current = false; } else { // Animate height changes heightValue.value = withSpring(height,{duration:100}); } } }, [height, heightValue]); // Animated style for height transitions const animatedStyle = useAnimatedStyle(() => { return { height: heightValue.value > 0 ? heightValue.value : 'auto', }; }, []); return ( ); }); const TabsTriggerText = React.forwardRef< React.ComponentRef, ITabsTriggerTextProps >(({ className, ...props }, ref) => { return ( ); }); const TabsTriggerIcon = React.forwardRef< React.ComponentRef, ITabsTriggerIconProps >(({ className, ...props }, ref) => { const { variant } = useStyleContext(SCOPE); // Remove `dataSet` ONLY on web to avoid React DOM warning const safeProps = Platform.OS === 'web' ? (() => { const { dataSet, ...rest } = props as any; return rest; })() : props; return ( ); }); const TabsIndicator = React.forwardRef< React.ComponentRef, ITabsIndicatorProps >(({ className, ...props }, ref) => { const context = React.useContext(TabsContext); const { variant } = useStyleContext(SCOPE); if (!context) { return null; } const { selectedKey, orientation, triggerLayouts, scrollOffset } = context; // @ts-ignore - Get animated scroll offset from context const animatedScrollOffset = context.animatedScrollOffset; return ( ); }); Tabs.displayName = 'Tabs'; TabsList.displayName = 'TabsList'; TabsTrigger.displayName = 'TabsTrigger'; TabsContent.displayName = 'TabsContent'; TabsContentWrapper.displayName = 'TabsContentWrapper'; TabsTriggerText.displayName = 'TabsTriggerText'; TabsTriggerIcon.displayName = 'TabsTriggerIcon'; TabsIndicator.displayName = 'TabsIndicator' export { Tabs, TabsList, TabsTrigger, TabsContent, TabsContentWrapper, TabsTriggerText, TabsTriggerIcon, TabsIndicator, };