Skip to content

Commit 7da7450

Browse files
authored
feat: add TabsIcon component family with icon tabs, scroll overflow detection, and collapse animation (#29684)
## **Description** Adds new `TabsIcon` components to `components-temp` and new icons to the component library as part of the hub page discovery tabs A/B test. Extracted into its own PR so it can be reviewed and merged independently before the feature PR lands. --- ### `TabsIcon` Component Family **`TabsIconTab`** Tab item that renders an icon above a label. Icon and label have active/disabled color states. Supports a `fillWidth` prop for equal-width layouts and animated icon collapse via `TabsIconAnimationContext`. **`TabsIconBar`** Tab bar built for icon+label tabs. Features: - Automatic overflow detection — switches between a fixed layout and a horizontally scrollable `ScrollView` - Animated sliding underline indicator - `fillWidth` mode for equal-width tabs - Height collapse animation for hiding the bar on scroll **`TabsIconList`** Lazy-mounting tab list that manages active state, swipe gestures, and `InteractionManager`-based content loading. Per-tab `keepMounted` prop controls whether inactive tab content stays mounted or is unmounted when the tab loses focus. **`TabsIconAnimationContext`** React context that carries an `Animated.Value` (`0` = icons expanded, `1` = collapsed) from a parent provider down to `TabsIconTab` without prop drilling. --- ### Icons Adds three new SVG icons and registers them in `Icon.assets.ts` / `Icon.types.ts`: | Icon | `IconName` | |---|---| | Portfolio | `IconName.Portfolio` | | Predict | `IconName.Predict` | | Candlestick | `IconName.Candlestick` | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-591 ## **Manual testing steps** ```gherkin Feature: Tabs component library Scenario: user navigates between tabs Given the app is open on any screen using TabsList When user taps each tab Then the animated underline slides to the active tab And the correct tab content is displayed Scenario: tabs overflow to scrollable mode Given a TabsBar with more tabs than fit the container width When the screen renders Then the tab bar becomes horizontally scrollable And the active tab is scrolled into view automatically Scenario: new icons render correctly Given any view that uses the Icon component When IconName.Portfolio or IconName.Predict is passed Then the correct SVG icon is displayed ``` ## **Screenshots/Recordings** Take from the feature branch using these components [here](#29193) ### iOS Dark Mode https://github.com/user-attachments/assets/7df6a46d-b3b3-44cc-a697-b796581dd759 Light Mode (No Gradient) https://github.com/user-attachments/assets/97b1f901-6092-42f9-926c-e8e6785c6f4e ### Android Dark Mode https://github.com/user-attachments/assets/4232f3fc-a47c-4516-93c2-104e4a3fb5cb Light Mode (No Gradient) https://github.com/user-attachments/assets/3f11dd8f-696f-438e-9867-1b08dc69200d ### **Before** `~` ### **After** `~` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TMCU-591]: https://consensyssoftware.atlassian.net/browse/TMCU-591?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new tab UI components plus shared layout/list hooks involving animations, gesture handling, and InteractionManager-based lazy loading; regressions would primarily impact navigation/UX in screens adopting these new components. Also extends the global `IconName` enum/assets, which can affect consumers if mis-registered. > > **Overview** > Adds a new `TabsIcon` component family under `components-temp`: > > `TabsIconTab` renders icon+label tabs with active/disabled states and optional icon collapse via `TabIconAnimationContext`, `TabsIconBar` manages underline animation plus *auto scroll overflow detection* and optional height collapse, and `TabsIconList` composes the bar with lazy-mounted tab content, disabled-tab handling, swipe gestures, and a small imperative ref API. > > Introduces reusable hooks `useTabsBarLayout` (measures tab/container layouts, toggles scroll mode, drives underline animations, and resets/cleans up on layout/tab changes) and `useTabsList` (active index normalization, InteractionManager-backed lazy loading with timeout fallback, key-preserving tab updates, and swipe-to-switch). > > Extends the icon library by registering new SVGs and `IconName` entries (`Group`, `Portfolio`, `Predictions`) and updates the `candlestick.svg` asset. Comprehensive unit tests are added for the new components and hooks. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ffa57ba. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d8db24a commit 7da7450

20 files changed

Lines changed: 2896 additions & 1 deletion

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

Lines changed: 500 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Third party dependencies.
2+
import React, { useRef, useState } from 'react';
3+
import { Animated, ScrollView, LayoutChangeEvent } from 'react-native';
4+
5+
// External dependencies.
6+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
7+
import {
8+
Box,
9+
BoxFlexDirection,
10+
BoxAlignItems,
11+
} from '@metamask/design-system-react-native';
12+
13+
// Internal dependencies.
14+
import TabsIconTab from '../TabsIconTab/TabsIconTab';
15+
import { TabsIconBarProps } from './TabsIconBar.types';
16+
import { useTabsBarLayout } from '../hooks/useTabsBarLayout';
17+
18+
const TabsIconBar: React.FC<TabsIconBarProps> = ({
19+
tabs,
20+
activeIndex,
21+
onTabPress,
22+
testID,
23+
twClassName,
24+
fillWidth = false,
25+
collapseAnim,
26+
...boxProps
27+
}) => {
28+
const tw = useTailwind();
29+
30+
const scrollViewRef = useRef<ScrollView>(null);
31+
const underlineAnimated = useRef(new Animated.Value(0)).current;
32+
const [underlineWidth, setUnderlineWidth] = useState(0);
33+
34+
// Height collapse animation — icon tabs always have a border-b row
35+
const [tabRowHeight, setTabRowHeight] = useState(0);
36+
const animatedHeight =
37+
collapseAnim && tabRowHeight > 0
38+
? collapseAnim.interpolate({
39+
inputRange: [0, 1],
40+
outputRange: [tabRowHeight, 0],
41+
})
42+
: undefined;
43+
44+
const {
45+
isInitialized,
46+
scrollEnabled,
47+
handleContainerLayout,
48+
handleTabLayout,
49+
} = useTabsBarLayout({
50+
tabs,
51+
activeIndex,
52+
fillWidth,
53+
scrollAnimated: false,
54+
scrollViewRef,
55+
onAnimateToTab: (layout, isFirstTime) => {
56+
// Icon tabs: underline is 75% of the tab width, centered
57+
const targetWidth = layout.width * 0.75;
58+
const targetX = layout.x + layout.width * 0.125;
59+
60+
setUnderlineWidth(targetWidth);
61+
62+
if (isFirstTime) {
63+
underlineAnimated.setValue(targetX);
64+
return null;
65+
}
66+
67+
return Animated.timing(underlineAnimated, {
68+
toValue: targetX,
69+
duration: 200,
70+
useNativeDriver: true,
71+
});
72+
},
73+
});
74+
75+
const handleTabPress = (index: number) => {
76+
const tab = tabs[index];
77+
if (!tab?.isDisabled) {
78+
onTabPress(index);
79+
}
80+
};
81+
82+
return (
83+
<Box
84+
twClassName={`relative overflow-hidden border-b border-border-muted ${twClassName || ''}`}
85+
testID={testID}
86+
onLayout={handleContainerLayout as (layoutEvent: unknown) => void}
87+
{...boxProps}
88+
>
89+
{scrollEnabled ? (
90+
<ScrollView
91+
ref={scrollViewRef}
92+
horizontal
93+
showsHorizontalScrollIndicator={false}
94+
style={tw.style('flex-grow-0')}
95+
contentContainerStyle={tw.style('flex-row px-4')}
96+
scrollsToTop={false}
97+
>
98+
<Box
99+
flexDirection={BoxFlexDirection.Row}
100+
alignItems={BoxAlignItems.Center}
101+
twClassName="relative gap-6"
102+
>
103+
{tabs.map((tab, index) => (
104+
<TabsIconTab
105+
key={tab.key}
106+
label={tab.label}
107+
iconName={tab.iconName}
108+
isActive={index === activeIndex}
109+
isDisabled={tab.isDisabled}
110+
onPress={() => handleTabPress(index)}
111+
onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)}
112+
testID={tab.testID ?? `${testID}-tab-${index}`}
113+
style={tw.style('py-2')}
114+
/>
115+
))}
116+
117+
{activeIndex >= 0 && isInitialized && (
118+
<Animated.View
119+
style={tw.style(
120+
'absolute -bottom-2px h-1 bg-icon-default z-1',
121+
{
122+
width: underlineWidth,
123+
transform: [{ translateX: underlineAnimated }],
124+
},
125+
)}
126+
/>
127+
)}
128+
</Box>
129+
</ScrollView>
130+
) : (
131+
<Animated.View
132+
onLayout={({
133+
nativeEvent,
134+
}: {
135+
nativeEvent: LayoutChangeEvent['nativeEvent'];
136+
}) => {
137+
if (tabRowHeight === 0 && nativeEvent.layout.height > 0) {
138+
setTabRowHeight(nativeEvent.layout.height);
139+
}
140+
}}
141+
style={[
142+
tw.style(
143+
`relative ${fillWidth ? 'flex-row items-center' : 'px-4 gap-6 flex-row items-center relative'} ${animatedHeight !== undefined ? 'overflow-hidden' : ''}`,
144+
),
145+
animatedHeight !== undefined
146+
? { height: animatedHeight }
147+
: undefined,
148+
]}
149+
>
150+
{tabs.map((tab, index) => (
151+
<TabsIconTab
152+
key={tab.key}
153+
label={tab.label}
154+
iconName={tab.iconName}
155+
isActive={index === activeIndex}
156+
isDisabled={tab.isDisabled}
157+
onPress={() => handleTabPress(index)}
158+
onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)}
159+
testID={tab.testID ?? `${testID}-tab-${index}`}
160+
shouldFillWidth={fillWidth}
161+
/>
162+
))}
163+
164+
{activeIndex >= 0 && isInitialized && (
165+
<Animated.View
166+
style={tw.style('absolute -bottom-2px h-1 bg-icon-default z-1', {
167+
width: underlineWidth,
168+
transform: [{ translateX: underlineAnimated }],
169+
})}
170+
/>
171+
)}
172+
</Animated.View>
173+
)}
174+
</Box>
175+
);
176+
};
177+
178+
export default TabsIconBar;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Third party dependencies.
2+
import React from 'react';
3+
import { Animated } from 'react-native';
4+
5+
// External dependencies.
6+
import { Box } from '@metamask/design-system-react-native';
7+
8+
// TODO: @MetaMask/design-system-engineers
9+
// Use the concrete Box component props here instead of BoxProps.
10+
// https://github.com/MetaMask/metamask-design-system/issues/1115
11+
type BoxComponentProps = React.ComponentProps<typeof Box>;
12+
13+
// Internal dependencies.
14+
import { IconName } from '../../../components/Icons/Icon/Icon.types';
15+
16+
/**
17+
* Individual tab item data interface for the icon tab bar.
18+
* Icon is required — use the base TabsBar for text-only tabs.
19+
*/
20+
export interface TabsIconItem {
21+
key: string;
22+
label: string;
23+
content: React.ReactNode;
24+
iconName: IconName;
25+
isDisabled?: boolean;
26+
testID?: string;
27+
}
28+
29+
/**
30+
* TabsIconBar component props
31+
*/
32+
export interface TabsIconBarProps extends BoxComponentProps {
33+
/**
34+
* Array of tab items — each must include an iconName
35+
*/
36+
tabs: TabsIconItem[];
37+
/**
38+
* Current active tab index
39+
*/
40+
activeIndex: number;
41+
/**
42+
* Callback when a tab is selected
43+
*/
44+
onTabPress: (index: number) => void;
45+
/**
46+
* Test ID for the component
47+
*/
48+
testID?: string;
49+
/**
50+
* Tailwind CSS classes to apply to the main container
51+
*/
52+
twClassName?: string;
53+
/**
54+
* When true, each tab stretches equally to fill the full container width.
55+
* Disables horizontal scrolling and gap spacing. Defaults to false.
56+
*/
57+
fillWidth?: boolean;
58+
/**
59+
* Optional Animated.Value (0=visible, 1=hidden) that collapses the tab row height to zero.
60+
* Requires useNativeDriver: false on the driving animation.
61+
*/
62+
collapseAnim?: Animated.Value;
63+
}

0 commit comments

Comments
 (0)