Skip to content

Commit fabb222

Browse files
committed
Merge branch 'feat-tmcu-591-homepage-hub-tabs-components' into feat-tmcu-591-homepage-hub-tabs-ui
2 parents 1b88347 + ffa57ba commit fabb222

14 files changed

Lines changed: 440 additions & 57 deletions

File tree

app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx

Lines changed: 244 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
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.
612
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -13,7 +19,6 @@ import {
1319
// Internal dependencies.
1420
import Tab from '../Tab';
1521
import { TabsBarProps } from './TabsBar.types';
16-
import { useTabsBarLayout } from '../hooks/useTabsBarLayout';
1722

1823
const 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', {

app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('TabsIconBar', () => {
3333
{
3434
key: 'tab3',
3535
label: 'Predictions',
36-
iconName: IconName.Predict,
36+
iconName: IconName.Predictions,
3737
content: null,
3838
},
3939
];

app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ describe('TabsIconList', () => {
277277
</View>
278278
<View
279279
key="t3"
280-
{...(tabViewProps('Predictions', IconName.Predict) as object)}
280+
{...(tabViewProps('Predictions', IconName.Predictions) as object)}
281281
>
282282
<Text>Content 3</Text>
283283
</View>
@@ -505,7 +505,7 @@ describe('TabsIconList', () => {
505505
{
506506
key: 'pred-tab',
507507
label: 'Predictions',
508-
icon: IconName.Predict,
508+
icon: IconName.Predictions,
509509
content: 'Predictions Content',
510510
},
511511
];

app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('TabsIconTab', () => {
110110
const icons: IconName[] = [
111111
IconName.Portfolio,
112112
IconName.Candlestick,
113-
IconName.Predict,
113+
IconName.Predictions,
114114
];
115115
icons.forEach((icon) => {
116116
expect(() =>

0 commit comments

Comments
 (0)