11// Third party dependencies.
2- import React , { useRef } from 'react' ;
3- import { Animated , ScrollView } from 'react-native' ;
2+ import React , {
3+ useEffect ,
4+ useRef ,
5+ useState ,
6+ useCallback ,
7+ useMemo ,
8+ } from 'react' ;
9+ import { Animated , ScrollView , LayoutChangeEvent } from 'react-native' ;
410
511// External dependencies.
612import { useTailwind } from '@metamask/design-system-twrnc-preset' ;
@@ -13,7 +19,6 @@ import {
1319// Internal dependencies.
1420import Tab from '../Tab' ;
1521import { TabsBarProps } from './TabsBar.types' ;
16- import { useTabsBarLayout } from '../hooks/useTabsBarLayout' ;
1722
1823const TabsBar : React . FC < TabsBarProps > = ( {
1924 tabs,
@@ -25,39 +30,247 @@ const TabsBar: React.FC<TabsBarProps> = ({
2530} ) => {
2631 const tw = useTailwind ( ) ;
2732
33+ // TabsBar with animated underline and automatic scroll detection
34+
2835 const scrollViewRef = useRef < ScrollView > ( null ) ;
36+
2937 const underlineAnimated = useRef ( new Animated . Value ( 0 ) ) . current ;
3038 const underlineWidthAnimated = useRef ( new Animated . Value ( 0 ) ) . current ;
39+ const tabLayouts = useRef < { x : number ; width : number } [ ] > ( [ ] ) ;
40+ const currentAnimation = useRef < Animated . CompositeAnimation | null > ( null ) ;
41+ const rafCallbackId = useRef < number | null > ( null ) ;
42+ const [ isInitialized , setIsInitialized ] = useState ( false ) ;
43+ const [ layoutsReady , setLayoutsReady ] = useState ( false ) ;
44+ const activeIndexRef = useRef ( activeIndex ) ;
45+
46+ // State for automatic overflow detection
47+ const [ scrollEnabled , setScrollEnabled ] = useState ( false ) ;
48+ const [ containerWidth , setContainerWidth ] = useState ( 0 ) ;
49+
50+ // Keep activeIndexRef in sync with activeIndex
51+ useEffect ( ( ) => {
52+ activeIndexRef . current = activeIndex ;
53+ } , [ activeIndex ] ) ;
54+
55+ // Reset layout data when tabs change structurally (count or content)
56+ const tabKeys = useMemo ( ( ) => tabs . map ( ( tab ) => tab . key ) . join ( ',' ) , [ tabs ] ) ;
57+ const prevTabKeys = useRef < string > ( '' ) ;
58+ const isInitialMount = useRef ( true ) ;
59+
60+ useEffect ( ( ) => {
61+ // Skip reset logic on initial mount to avoid interfering with initialization
62+ if ( isInitialMount . current ) {
63+ prevTabKeys . current = tabKeys ;
64+ isInitialMount . current = false ;
65+ return ;
66+ }
67+
68+ // Reset when tabs change (either count or content/keys)
69+ const shouldReset =
70+ tabLayouts . current . length !== tabs . length ||
71+ prevTabKeys . current !== tabKeys ;
72+
73+ if ( shouldReset ) {
74+ // Store current tab keys for next comparison
75+ prevTabKeys . current = tabKeys ;
76+ // Reset all layout state
77+ tabLayouts . current = new Array ( tabs . length ) ;
78+ setIsInitialized ( false ) ;
79+ setLayoutsReady ( false ) ;
80+ setScrollEnabled ( false ) ;
81+
82+ // Stop any ongoing animation
83+ if ( currentAnimation . current ) {
84+ currentAnimation . current . stop ( ) ;
85+ currentAnimation . current = null ;
86+ }
87+
88+ // Force re-measurement by resetting container width temporarily
89+ // This ensures fresh layout measurements for the new tab structure
90+ setContainerWidth ( 0 ) ;
91+ }
92+ } , [ tabKeys , tabs . length ] ) ;
93+
94+ // Animation function for smooth underline transitions
95+ const animateToTab = useCallback (
96+ ( targetIndex : number ) => {
97+ // Stop any ongoing animation
98+ if ( currentAnimation . current ) {
99+ currentAnimation . current . stop ( ) ;
100+ currentAnimation . current = null ;
101+ }
102+
103+ // Validate target index
104+ if ( targetIndex < 0 || targetIndex >= tabs . length ) {
105+ return ;
106+ }
107+
108+ const activeTabLayout = tabLayouts . current [ targetIndex ] ;
109+
110+ // If layout isn't ready yet, we'll animate when it becomes available
111+ if ( ! activeTabLayout || activeTabLayout . width <= 0 ) {
112+ return ;
113+ }
114+
115+ const isFirstTime = ! isInitialized ;
31116
32- const {
33- isInitialized,
34- scrollEnabled,
35- handleContainerLayout,
36- handleTabLayout,
37- } = useTabsBarLayout ( {
38- tabs,
39- activeIndex,
40- scrollViewRef,
41- onAnimateToTab : ( layout , isFirstTime ) => {
42117 if ( isFirstTime ) {
43- underlineAnimated . setValue ( layout . x ) ;
44- underlineWidthAnimated . setValue ( layout . width ) ;
45- return null ;
118+ // First time - set position immediately
119+ underlineAnimated . setValue ( activeTabLayout . x ) ;
120+ underlineWidthAnimated . setValue ( activeTabLayout . width ) ;
121+ setIsInitialized ( true ) ;
122+ } else {
123+ // Animate to new position
124+ const animation = Animated . parallel ( [
125+ Animated . timing ( underlineAnimated , {
126+ toValue : activeTabLayout . x ,
127+ duration : 200 ,
128+ useNativeDriver : false ,
129+ } ) ,
130+ Animated . timing ( underlineWidthAnimated , {
131+ toValue : activeTabLayout . width ,
132+ duration : 200 ,
133+ useNativeDriver : false ,
134+ } ) ,
135+ ] ) ;
136+
137+ currentAnimation . current = animation ;
138+ animation . start ( ( finished ) => {
139+ if ( finished && currentAnimation . current === animation ) {
140+ currentAnimation . current = null ;
141+ }
142+ } ) ;
143+ }
144+
145+ // Handle scrolling
146+ if ( scrollEnabled && scrollViewRef . current ) {
147+ scrollViewRef . current . scrollTo ( {
148+ x : Math . max ( 0 , activeTabLayout . x - 50 ) ,
149+ animated : ! isFirstTime ,
150+ } ) ;
46151 }
47- return Animated . parallel ( [
48- Animated . timing ( underlineAnimated , {
49- toValue : layout . x ,
50- duration : 200 ,
51- useNativeDriver : false ,
52- } ) ,
53- Animated . timing ( underlineWidthAnimated , {
54- toValue : layout . width ,
55- duration : 200 ,
56- useNativeDriver : false ,
57- } ) ,
58- ] ) ;
59152 } ,
60- } ) ;
153+ [
154+ scrollEnabled ,
155+ underlineAnimated ,
156+ underlineWidthAnimated ,
157+ tabs . length ,
158+ isInitialized ,
159+ ] ,
160+ ) ;
161+
162+ // Animate when activeIndex changes and layouts are ready
163+ useEffect ( ( ) => {
164+ if ( activeIndex >= 0 && layoutsReady ) {
165+ animateToTab ( activeIndex ) ;
166+ }
167+ } , [ activeIndex , layoutsReady , animateToTab ] ) ;
168+
169+ // Check if content overflows and update scroll state
170+ useEffect ( ( ) => {
171+ if ( containerWidth > 0 && tabLayouts . current . length === tabs . length ) {
172+ // Validate that all tab layouts are defined (prevent sparse array issues)
173+ const allLayoutsDefined = tabLayouts . current . every (
174+ ( layout ) => layout && typeof layout . width === 'number' ,
175+ ) ;
176+
177+ if ( allLayoutsDefined ) {
178+ // Calculate total content width by summing tab widths + gaps
179+ const totalTabsWidth = tabLayouts . current . reduce (
180+ ( sum , layout ) => sum + layout . width ,
181+ 0 ,
182+ ) ;
183+ const gapsWidth = ( tabs . length - 1 ) * 24 ; // Account for gaps between tabs
184+ const calculatedContentWidth = totalTabsWidth + gapsWidth ;
185+
186+ // Account for container's px-4 padding (16px * 2 = 32px)
187+ const shouldScroll = calculatedContentWidth > containerWidth - 32 ;
188+ setScrollEnabled ( shouldScroll ) ;
189+ }
190+ }
191+ } , [ containerWidth , tabs . length ] ) ;
192+
193+ // Handle container layout to measure available width
194+ const handleContainerLayout = ( layoutEvent : LayoutChangeEvent ) => {
195+ const { width } = layoutEvent . nativeEvent . layout ;
196+ setContainerWidth ( width ) ;
197+ } ;
198+
199+ const handleTabLayout = useCallback (
200+ ( index : number , layoutEvent : LayoutChangeEvent ) => {
201+ const { x, width } = layoutEvent . nativeEvent . layout ;
202+
203+ // Validate input
204+ if ( index < 0 || index >= tabs . length || width <= 0 ) {
205+ return ;
206+ }
207+
208+ // Check if this is a significant change (more than 1px difference)
209+ const previousLayout = tabLayouts . current [ index ] ;
210+ const hasSignificantChange =
211+ ! previousLayout ||
212+ Math . abs ( previousLayout . width - width ) > 1 ||
213+ Math . abs ( previousLayout . x - x ) > 1 ;
214+
215+ // Store layout data
216+ tabLayouts . current [ index ] = { x, width } ;
217+
218+ // Check if all layouts are now available
219+ const allLayoutsReady = tabLayouts . current . every (
220+ ( layout , i ) => i >= tabs . length || ( layout && layout . width > 0 ) ,
221+ ) ;
222+
223+ if ( allLayoutsReady ) {
224+ // Recalculate scroll detection on initial load OR when any layout changes significantly
225+ if ( ! layoutsReady || hasSignificantChange ) {
226+ if ( ! layoutsReady ) {
227+ setLayoutsReady ( true ) ;
228+ }
229+
230+ // If layouts were already ready and any tab changed, re-animate the active tab
231+ // This ensures re-animation triggers regardless of which tab's callback fires last
232+ if ( layoutsReady && hasSignificantChange ) {
233+ // Cancel any pending RAF to avoid multiple callbacks
234+ if ( rafCallbackId . current !== null ) {
235+ cancelAnimationFrame ( rafCallbackId . current ) ;
236+ }
237+ rafCallbackId . current = requestAnimationFrame ( ( ) => {
238+ rafCallbackId . current = null ;
239+ animateToTab ( activeIndexRef . current ) ;
240+ } ) ;
241+ }
242+
243+ // Update scroll detection
244+ if ( containerWidth > 0 ) {
245+ const totalWidth = tabLayouts . current . reduce (
246+ ( sum , layout ) => sum + ( layout ?. width || 0 ) ,
247+ 0 ,
248+ ) ;
249+ const gapsWidth = ( tabs . length - 1 ) * 24 ;
250+ // Account for container's px-4 padding (16px * 2 = 32px)
251+ const shouldScroll = totalWidth + gapsWidth > containerWidth - 32 ;
252+ setScrollEnabled ( shouldScroll ) ;
253+ }
254+ }
255+ }
256+ } ,
257+ [ tabs . length , layoutsReady , containerWidth , animateToTab ] ,
258+ ) ;
259+
260+ // Cleanup effect
261+ useEffect (
262+ ( ) => ( ) => {
263+ if ( currentAnimation . current ) {
264+ currentAnimation . current . stop ( ) ;
265+ currentAnimation . current = null ;
266+ }
267+ if ( rafCallbackId . current !== null ) {
268+ cancelAnimationFrame ( rafCallbackId . current ) ;
269+ rafCallbackId . current = null ;
270+ }
271+ } ,
272+ [ ] ,
273+ ) ;
61274
62275 const handleTabPress = ( index : number ) => {
63276 const tab = tabs [ index ] ;
@@ -99,6 +312,7 @@ const TabsBar: React.FC<TabsBarProps> = ({
99312 />
100313 ) ) }
101314
315+ { /* Animated underline for scrollable tabs */ }
102316 { activeIndex >= 0 && isInitialized && (
103317 < Animated . View
104318 style = { tw . style ( 'absolute bottom-0 h-0.5 bg-icon-default' , {
@@ -128,6 +342,7 @@ const TabsBar: React.FC<TabsBarProps> = ({
128342 />
129343 ) ) }
130344
345+ { /* Animated underline for non-scrollable tabs */ }
131346 { activeIndex >= 0 && isInitialized && (
132347 < Animated . View
133348 style = { tw . style ( 'absolute bottom-0 h-0.5 bg-icon-default' , {
0 commit comments