Skip to content

Commit 516dc48

Browse files
authored
chore: cherry-pick fix(homepage): hub page discovery tabs UX & scroll improvements (#29931)
## **Description** - fix(homepage): hub page discovery tabs UX & scroll improvements cp-7.77.0 (#29889) ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** `~` ### **Before** `~` ### **After** `~` ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] 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 - [ ] 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** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes header/tab-bar hide/show behavior and animation plumbing across Homepage Discovery Tabs, Predict, and Perps, which can regress layout/scroll interactions on iOS/Android. > > **Overview** > Improves Hub Page Discovery Tabs UX by moving icon-collapse animation to Reanimated: `TabIconAnimationContext` now provides a `SharedValue` and `TabsIconTab` animates icon height/margin/opacity on the UI thread, while keeping a mirrored `Animated.Value` only for the dark-mode gradient fade. > > Updates the discovery tab content layout (Portfolio scroll container spacing, removing extra `mt-2` in `TabsIconList`, adding `topInset` support to `PerpsHomeView`) and forwards wallet header coordination props into `PredictFeed`. > > Extends `useFeedScrollManager` so, when embedded, it hides Predict’s header *and* its tab bar together and animates `walletHeaderTranslateY` in sync; adds clamped partial-collapse support to `TabsIconBar` via new `collapseBy` prop. Tests are added/updated to cover the new props and non-crashing behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6a1cc4f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 500eff8 commit 516dc48

14 files changed

Lines changed: 446 additions & 89 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,41 @@ describe('TabsIconBar', () => {
365365
});
366366
expect(getByTestId('icon-bar')).toBeOnTheScreen();
367367
});
368+
369+
it('renders without throwing when collapseBy is provided', () => {
370+
const collapseAnim = new Animated.Value(0);
371+
expect(() =>
372+
render(
373+
<TabsIconBar
374+
tabs={mockTabs}
375+
activeIndex={0}
376+
onTabPress={jest.fn()}
377+
collapseAnim={collapseAnim}
378+
collapseBy={28}
379+
testID="icon-bar"
380+
/>,
381+
),
382+
).not.toThrow();
383+
});
384+
385+
it('renders without throwing when collapseBy exceeds tabRowHeight', () => {
386+
const collapseAnim = new Animated.Value(0);
387+
const { getByTestId } = render(
388+
<TabsIconBar
389+
tabs={mockTabs}
390+
activeIndex={0}
391+
onTabPress={jest.fn()}
392+
collapseAnim={collapseAnim}
393+
collapseBy={9999}
394+
testID="icon-bar"
395+
/>,
396+
);
397+
// Tab row height is clamped to 0 (no negative height); should not crash.
398+
act(() => {
399+
fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
400+
});
401+
expect(getByTestId('icon-bar')).toBeOnTheScreen();
402+
});
368403
});
369404

370405
describe('Tab Array Changes', () => {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const TabsIconBar: React.FC<TabsIconBarProps> = ({
2323
twClassName,
2424
fillWidth = false,
2525
collapseAnim,
26+
collapseBy,
2627
...boxProps
2728
}) => {
2829
const tw = useTailwind();
@@ -33,11 +34,13 @@ const TabsIconBar: React.FC<TabsIconBarProps> = ({
3334

3435
// Height collapse animation — icon tabs always have a border-b row
3536
const [tabRowHeight, setTabRowHeight] = useState(0);
37+
const collapsedHeight =
38+
collapseBy !== undefined ? Math.max(0, tabRowHeight - collapseBy) : 0;
3639
const animatedHeight =
3740
collapseAnim && tabRowHeight > 0
3841
? collapseAnim.interpolate({
3942
inputRange: [0, 1],
40-
outputRange: [tabRowHeight, 0],
43+
outputRange: [tabRowHeight, collapsedHeight],
4144
})
4245
: undefined;
4346

app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@ export interface TabsIconBarProps extends BoxComponentProps {
6060
* Requires useNativeDriver: false on the driving animation.
6161
*/
6262
collapseAnim?: Animated.Value;
63+
/**
64+
* When provided alongside `collapseAnim`, the tab row collapses BY this many pixels at the
65+
* fully-hidden state (1.0) instead of collapsing all the way to 0. Use this to shrink the
66+
* row by just the icon area (for example) while keeping labels visible.
67+
*/
68+
collapseBy?: number;
6369
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const TabsIconList = forwardRef<TabsIconListRef, TabsIconListProps>(
8585

8686
<GestureDetector gesture={swipeGesture}>
8787
<Box
88-
twClassName={`flex-1 mt-2 px-4 ${tabsListContentTwClassName || ''}`}
88+
twClassName={`flex-1 ${tabsListContentTwClassName || ''}`}
8989
testID={testID ? `${testID}-content` : undefined}
9090
>
9191
{tabs.map((tab, index) => {

app/component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { createContext } from 'react';
2-
import { Animated } from 'react-native';
2+
import type { SharedValue } from 'react-native-reanimated';
33

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

1417
export const TabIconAnimationContext =

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { render, fireEvent } from '@testing-library/react-native';
44

55
// Internal dependencies.
66
import TabsIconTab from './TabsIconTab';
7+
import { TabIconAnimationContext } from './TabsIconAnimationContext';
78
import { IconName } from '../../../components/Icons/Icon/Icon.types';
89

910
describe('TabsIconTab', () => {
@@ -121,4 +122,39 @@ describe('TabsIconTab', () => {
121122
});
122123
});
123124
});
125+
126+
describe('Icon collapse animation context', () => {
127+
it('renders without throwing when iconCollapseProgress SharedValue is provided', () => {
128+
const iconCollapseProgress = { value: 0 } as never;
129+
expect(() =>
130+
render(
131+
<TabIconAnimationContext.Provider value={{ iconCollapseProgress }}>
132+
<TabsIconTab {...defaultProps} testID="tab" />
133+
</TabIconAnimationContext.Provider>,
134+
),
135+
).not.toThrow();
136+
});
137+
138+
it('renders without throwing when no SharedValue is provided (icons full size)', () => {
139+
expect(() =>
140+
render(
141+
<TabIconAnimationContext.Provider value={{}}>
142+
<TabsIconTab {...defaultProps} testID="tab" />
143+
</TabIconAnimationContext.Provider>,
144+
),
145+
).not.toThrow();
146+
});
147+
148+
it('renders the label text regardless of collapse progress value', () => {
149+
const fullyCollapsed = { value: 1 } as never;
150+
const { getByText } = render(
151+
<TabIconAnimationContext.Provider
152+
value={{ iconCollapseProgress: fullyCollapsed }}
153+
>
154+
<TabsIconTab {...defaultProps} />
155+
</TabIconAnimationContext.Provider>,
156+
);
157+
expect(getByText('Portfolio')).toBeOnTheScreen();
158+
});
159+
});
124160
});

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

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Third party dependencies.
22
import React, { useContext, useRef, useCallback } from 'react';
3-
import { Animated, Pressable, StyleProp, View, ViewStyle } from 'react-native';
3+
import { Pressable, StyleProp, View, ViewStyle } from 'react-native';
4+
import Reanimated, {
5+
useAnimatedStyle,
6+
interpolate,
7+
Extrapolation,
8+
} from 'react-native-reanimated';
49

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

42-
// translateY slides the icon upward out of the clipping boundary (overflow:hidden
43-
// on the outer View) without changing layout — keeps tab bar height fixed so
44-
// there is no layout cascade. Both transform and opacity run on the native thread.
45-
const iconAnimatedStyle = iconCollapseAnim
46-
? {
47-
opacity: iconCollapseAnim.interpolate({
48-
inputRange: [0, 1],
49-
outputRange: [1, 0],
50-
}),
51-
transform: [
52-
{
53-
translateY: iconCollapseAnim.interpolate({
54-
inputRange: [0, 1],
55-
outputRange: [0, -(ICON_SIZE_LG + ICON_MARGIN_BOTTOM)],
56-
}),
57-
},
58-
],
59-
}
60-
: undefined;
47+
// Drive icon's layout box (height + marginBottom) and opacity from a Reanimated
48+
// SharedValue so the entire transition runs on the UI thread — no per-frame JS work,
49+
// no layout reflow on the JS thread.
50+
const iconAnimatedStyle = useAnimatedStyle(() => {
51+
if (!iconCollapseProgress) {
52+
return {
53+
height: ICON_SIZE_LG,
54+
marginBottom: ICON_MARGIN_BOTTOM,
55+
opacity: 1,
56+
};
57+
}
58+
const p = iconCollapseProgress.value;
59+
return {
60+
height: interpolate(p, [0, 1], [ICON_SIZE_LG, 0], Extrapolation.CLAMP),
61+
marginBottom: interpolate(
62+
p,
63+
[0, 1],
64+
[ICON_MARGIN_BOTTOM, 0],
65+
Extrapolation.CLAMP,
66+
),
67+
opacity: interpolate(p, [0, 1], [1, 0], Extrapolation.CLAMP),
68+
};
69+
});
6170

6271
const handleOnLayout = useCallback(
6372
(layoutEvent: Parameters<NonNullable<typeof onLayout>>[0]) => {
@@ -92,12 +101,7 @@ const TabsIconTab: React.FC<TabsIconTabProps> = ({
92101
alignItems={BoxAlignItems.Center}
93102
justifyContent={BoxJustifyContent.Center}
94103
>
95-
<Animated.View
96-
style={[
97-
{ height: ICON_SIZE_LG, marginBottom: ICON_MARGIN_BOTTOM },
98-
iconAnimatedStyle,
99-
]}
100-
>
104+
<Reanimated.View style={iconAnimatedStyle}>
101105
<Icon
102106
name={iconName}
103107
size={IconSize.Lg}
@@ -110,7 +114,7 @@ const TabsIconTab: React.FC<TabsIconTabProps> = ({
110114
}
111115
{...iconProps}
112116
/>
113-
</Animated.View>
117+
</Reanimated.View>
114118
<Text
115119
variant={TextVariant.BodySm}
116120
fontWeight={

app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ interface PerpsHomeViewProps {
8282
tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>;
8383
/** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */
8484
onHeaderHiddenChange?: (hidden: boolean) => void;
85+
/**
86+
* Top padding applied inside the scroll content container — used by HomepageDiscoveryTabs
87+
* (Hub Page Discovery Tabs feature flag treatment) so the perps gradient extends up
88+
* directly under the discovery tab bar instead of leaving a transparent gap.
89+
*/
90+
topInset?: number;
8591
}
8692

8793
const PerpsHomeView = ({
@@ -90,6 +96,7 @@ const PerpsHomeView = ({
9096
walletHeaderHeight = 0,
9197
tabEnterCallbackRef,
9298
onHeaderHiddenChange,
99+
topInset = 0,
93100
}: PerpsHomeViewProps) => {
94101
const { styles } = useStyles(styleSheet, {});
95102
const insets = useSafeAreaInsets();
@@ -483,7 +490,10 @@ const PerpsHomeView = ({
483490
{/* Main Content - ScrollView with all carousels */}
484491
<Reanimated.ScrollView
485492
style={styles.scrollView}
486-
contentContainerStyle={styles.scrollViewContent}
493+
contentContainerStyle={[
494+
styles.scrollViewContent,
495+
topInset > 0 ? { paddingTop: topInset } : null,
496+
]}
487497
showsVerticalScrollIndicator={false}
488498
onScroll={perpsScrollHandler}
489499
scrollEventThrottle={16}

0 commit comments

Comments
 (0)