Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,41 @@ describe('TabsIconBar', () => {
});
expect(getByTestId('icon-bar')).toBeOnTheScreen();
});

it('renders without throwing when collapseBy is provided', () => {
const collapseAnim = new Animated.Value(0);
expect(() =>
render(
<TabsIconBar
tabs={mockTabs}
activeIndex={0}
onTabPress={jest.fn()}
collapseAnim={collapseAnim}
collapseBy={28}
testID="icon-bar"
/>,
),
).not.toThrow();
});

it('renders without throwing when collapseBy exceeds tabRowHeight', () => {
const collapseAnim = new Animated.Value(0);
const { getByTestId } = render(
<TabsIconBar
tabs={mockTabs}
activeIndex={0}
onTabPress={jest.fn()}
collapseAnim={collapseAnim}
collapseBy={9999}
testID="icon-bar"
/>,
);
// Tab row height is clamped to 0 (no negative height); should not crash.
act(() => {
fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
});
expect(getByTestId('icon-bar')).toBeOnTheScreen();
});
});

describe('Tab Array Changes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const TabsIconBar: React.FC<TabsIconBarProps> = ({
twClassName,
fillWidth = false,
collapseAnim,
collapseBy,
...boxProps
}) => {
const tw = useTailwind();
Expand All @@ -33,11 +34,13 @@ const TabsIconBar: React.FC<TabsIconBarProps> = ({

// Height collapse animation — icon tabs always have a border-b row
const [tabRowHeight, setTabRowHeight] = useState(0);
const collapsedHeight =
collapseBy !== undefined ? Math.max(0, tabRowHeight - collapseBy) : 0;
const animatedHeight =
collapseAnim && tabRowHeight > 0
? collapseAnim.interpolate({
inputRange: [0, 1],
outputRange: [tabRowHeight, 0],
outputRange: [tabRowHeight, collapsedHeight],
})
: undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ export interface TabsIconBarProps extends BoxComponentProps {
* Requires useNativeDriver: false on the driving animation.
*/
collapseAnim?: Animated.Value;
/**
* When provided alongside `collapseAnim`, the tab row collapses BY this many pixels at the
* fully-hidden state (1.0) instead of collapsing all the way to 0. Use this to shrink the
* row by just the icon area (for example) while keeping labels visible.
*/
collapseBy?: number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there anyway to update this prop naming to something more intuitive? collapseBy seems incredibly vague

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I can, could I do a follow up PR for this? I want to get this CP out of the way @brianacnguyen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup

}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const TabsIconList = forwardRef<TabsIconListRef, TabsIconListProps>(

<GestureDetector gesture={swipeGesture}>
<Box
twClassName={`flex-1 mt-2 px-4 ${tabsListContentTwClassName || ''}`}
twClassName={`flex-1 ${tabsListContentTwClassName || ''}`}
testID={testID ? `${testID}-content` : undefined}
>
{tabs.map((tab, index) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { createContext } from 'react';
import { Animated } from 'react-native';
import type { SharedValue } from 'react-native-reanimated';

/**
* Provides an optional RN Animated.Value (0 = icons expanded, 1 = icons collapsed)
* to Tab components without threading props through TabsList / TabsBar.
* Consumers that don't provide this context get the default (undefined), which
* means icons render at full size — preserving existing behaviour.
* Provides an optional Reanimated SharedValue (0 = icons expanded, 1 = icons collapsed)
* to Tab components without threading props through TabsList / TabsBar. The value drives
* the icon's height / marginBottom / opacity via useAnimatedStyle on the UI thread —
* no per-frame JS work, no layout reflow on the JS thread.
*
* Consumers that don't provide this context get the default (undefined), which means
* icons render at full size — preserving existing behaviour.
*/
export interface TabIconAnimationContextValue {
iconCollapseAnim?: Animated.Value;
iconCollapseProgress?: SharedValue<number>;
}

export const TabIconAnimationContext =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render, fireEvent } from '@testing-library/react-native';

// Internal dependencies.
import TabsIconTab from './TabsIconTab';
import { TabIconAnimationContext } from './TabsIconAnimationContext';
import { IconName } from '../../../components/Icons/Icon/Icon.types';

describe('TabsIconTab', () => {
Expand Down Expand Up @@ -121,4 +122,39 @@ describe('TabsIconTab', () => {
});
});
});

describe('Icon collapse animation context', () => {
it('renders without throwing when iconCollapseProgress SharedValue is provided', () => {
const iconCollapseProgress = { value: 0 } as never;
expect(() =>
render(
<TabIconAnimationContext.Provider value={{ iconCollapseProgress }}>
<TabsIconTab {...defaultProps} testID="tab" />
</TabIconAnimationContext.Provider>,
),
).not.toThrow();
});

it('renders without throwing when no SharedValue is provided (icons full size)', () => {
expect(() =>
render(
<TabIconAnimationContext.Provider value={{}}>
<TabsIconTab {...defaultProps} testID="tab" />
</TabIconAnimationContext.Provider>,
),
).not.toThrow();
});

it('renders the label text regardless of collapse progress value', () => {
const fullyCollapsed = { value: 1 } as never;
const { getByText } = render(
<TabIconAnimationContext.Provider
value={{ iconCollapseProgress: fullyCollapsed }}
>
<TabsIconTab {...defaultProps} />
</TabIconAnimationContext.Provider>,
);
expect(getByText('Portfolio')).toBeOnTheScreen();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Third party dependencies.
import React, { useContext, useRef, useCallback } from 'react';
import { Animated, Pressable, StyleProp, View, ViewStyle } from 'react-native';
import { Pressable, StyleProp, View, ViewStyle } from 'react-native';
import Reanimated, {
useAnimatedStyle,
interpolate,
Extrapolation,
} from 'react-native-reanimated';

// External dependencies.
import { useTailwind } from '@metamask/design-system-twrnc-preset';
Expand Down Expand Up @@ -37,27 +42,31 @@ const TabsIconTab: React.FC<TabsIconTabProps> = ({
}) => {
const tw = useTailwind();
const viewRef = useRef<View>(null);
const { iconCollapseAnim } = useContext(TabIconAnimationContext);
const { iconCollapseProgress } = useContext(TabIconAnimationContext);

// translateY slides the icon upward out of the clipping boundary (overflow:hidden
// on the outer View) without changing layout — keeps tab bar height fixed so
// there is no layout cascade. Both transform and opacity run on the native thread.
const iconAnimatedStyle = iconCollapseAnim
? {
opacity: iconCollapseAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
transform: [
{
translateY: iconCollapseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -(ICON_SIZE_LG + ICON_MARGIN_BOTTOM)],
}),
},
],
}
: undefined;
// Drive icon's layout box (height + marginBottom) and opacity from a Reanimated
// SharedValue so the entire transition runs on the UI thread — no per-frame JS work,
// no layout reflow on the JS thread.
const iconAnimatedStyle = useAnimatedStyle(() => {
if (!iconCollapseProgress) {
return {
height: ICON_SIZE_LG,
marginBottom: ICON_MARGIN_BOTTOM,
opacity: 1,
};
}
const p = iconCollapseProgress.value;
return {
height: interpolate(p, [0, 1], [ICON_SIZE_LG, 0], Extrapolation.CLAMP),
marginBottom: interpolate(
p,
[0, 1],
[ICON_MARGIN_BOTTOM, 0],
Extrapolation.CLAMP,
),
opacity: interpolate(p, [0, 1], [1, 0], Extrapolation.CLAMP),
};
});

const handleOnLayout = useCallback(
(layoutEvent: Parameters<NonNullable<typeof onLayout>>[0]) => {
Expand Down Expand Up @@ -92,12 +101,7 @@ const TabsIconTab: React.FC<TabsIconTabProps> = ({
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Center}
>
<Animated.View
style={[
{ height: ICON_SIZE_LG, marginBottom: ICON_MARGIN_BOTTOM },
iconAnimatedStyle,
]}
>
<Reanimated.View style={iconAnimatedStyle}>
<Icon
name={iconName}
size={IconSize.Lg}
Expand All @@ -110,7 +114,7 @@ const TabsIconTab: React.FC<TabsIconTabProps> = ({
}
{...iconProps}
/>
</Animated.View>
</Reanimated.View>
<Text
variant={TextVariant.BodySm}
fontWeight={
Expand Down
12 changes: 11 additions & 1 deletion app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ interface PerpsHomeViewProps {
tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>;
/** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */
onHeaderHiddenChange?: (hidden: boolean) => void;
/**
* Top padding applied inside the scroll content container — used by HomepageDiscoveryTabs
* (Hub Page Discovery Tabs feature flag treatment) so the perps gradient extends up
* directly under the discovery tab bar instead of leaving a transparent gap.
*/
topInset?: number;
}

const PerpsHomeView = ({
Expand All @@ -90,6 +96,7 @@ const PerpsHomeView = ({
walletHeaderHeight = 0,
tabEnterCallbackRef,
onHeaderHiddenChange,
topInset = 0,
}: PerpsHomeViewProps) => {
const { styles } = useStyles(styleSheet, {});
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -483,7 +490,10 @@ const PerpsHomeView = ({
{/* Main Content - ScrollView with all carousels */}
<Reanimated.ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
contentContainerStyle={[
styles.scrollViewContent,
topInset > 0 ? { paddingTop: topInset } : null,
]}
showsVerticalScrollIndicator={false}
onScroll={perpsScrollHandler}
scrollEventThrottle={16}
Expand Down
Loading
Loading