Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Binary file added app/animations/next_best_action_module_v1.riv
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ const StepperCard = ({
{/* Image */}
<Box twClassName="p-4">
<Box testID={getTestId('step-image')} twClassName="w-full aspect-video">
<Image
source={step.image}
style={tw.style('w-full h-full')}
resizeMode="contain"
/>
{step.media ?? (
<Image
source={step.image}
style={tw.style('w-full h-full')}
resizeMode="contain"
/>
)}
</Box>
</Box>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { ImageSourcePropType } from 'react-native';

export interface StepperCardCta {
Expand All @@ -20,6 +21,11 @@ export interface StepperCardStep {
*/
descriptionTooltipAccessibilityLabel?: string;
image: ImageSourcePropType;
/**
* Optional custom media rendered in the image slot in place of `image`
* (e.g. an animated graphic). Falls back to `image` when not provided.
*/
media?: ReactNode;

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.

I don't see the media being rendered in the StepperCard. It's only being used as a condition. Is that intended?

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.

StepperCard.tsx:60

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.

not as a condition, but as a ??, so if media exists, it will use it, otherwise fallback to Image

primaryCta: StepperCardCta;
secondaryCta?: StepperCardCta;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StyleSheet } from 'react-native';

const styles = StyleSheet.create({
media: {
width: '100%',
height: '100%',
},
});

export default styles;
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React from 'react';
import { render, act } from '@testing-library/react-native';
import MoneyNextBestActionParallax from './MoneyNextBestActionParallax';
import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
import { useReduceMotion } from '../../hooks/useReduceMotion';
import { useDeviceOrientation } from '../../hooks/useDeviceOrientation';
import fallbackImage from '../../../../../images/money-onboarding-stepper-step-1.png';

const mockSetXValue = jest.fn();
const mockSetYValue = jest.fn();
const mockRefCallback = jest.fn();
const mockRiveInstance = {};
const mockOnErrorRef: { current?: (error: { message: string }) => void } = {};
const mockRiveProps: { current?: { artboardName?: string } } = {};

jest.mock('rive-react-native', () => {
const ReactActual = jest.requireActual('react');
const { View: RNView } = jest.requireActual('react-native');
return {
__esModule: true,
AutoBind: jest.fn(() => ({})),
Fit: { Contain: 'contain' },
useRive: () => [mockRefCallback, mockRiveInstance],
useRiveNumber: (_instance: unknown, path: string) => [
undefined,
path === 'xValue' ? mockSetXValue : mockSetYValue,
],
default: (props: {
testID?: string;
artboardName?: string;
onError?: (error: { message: string }) => void;
}) => {
mockOnErrorRef.current = props.onError;
mockRiveProps.current = { artboardName: props.artboardName };
return ReactActual.createElement(RNView, { testID: props.testID });
},
};
});

jest.mock('../../hooks/useReduceMotion', () => ({
useReduceMotion: jest.fn(),
}));

jest.mock('../../hooks/useDeviceOrientation', () => ({
useDeviceOrientation: jest.fn(),
}));

const mockUseReduceMotion = useReduceMotion as jest.Mock;
const mockUseDeviceOrientation = useDeviceOrientation as jest.Mock;

describe('MoneyNextBestActionParallax', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOnErrorRef.current = undefined;
mockRiveProps.current = undefined;
mockUseReduceMotion.mockReturnValue(false);
});

it('renders the Rive animation when enabled and reduce motion is off', () => {
const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.RIVE),
).toBeOnTheScreen();
expect(
queryByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
).toBeNull();
});

it('renders the gradient background behind the Rive when animating', () => {
const { getByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
).toBeOnTheScreen();
});

it('passes the given artboard name through to Rive', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 2"
fallbackImage={fallbackImage}
/>,
);

expect(mockRiveProps.current?.artboardName).toBe('Parallax Block 2');
});

it('does not render the gradient background behind the fallback image', () => {
mockUseReduceMotion.mockReturnValue(true);

const { queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
queryByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
).toBeNull();
});

it('renders the fallback image (with the provided source) when reduce motion is enabled', () => {
mockUseReduceMotion.mockReturnValue(true);

const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE).props.source,
).toBe(fallbackImage);
expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
});

it('enables the device tilt callback when animating', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
expect.any(Function),
{
enabled: true,
},
);
});

it('disables the device tilt callback when not animating', () => {
mockUseReduceMotion.mockReturnValue(true);

render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
expect.any(Function),
{
enabled: false,
},
);
});

it('drives the bound Rive number properties from mapped tilt values', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

const applyTilt = mockUseDeviceOrientation.mock.calls[0][0] as (
x: number,
y: number,
) => void;

act(() => applyTilt(0.5, -0.5));

expect(mockSetXValue).toHaveBeenCalledWith(75);
expect(mockSetYValue).toHaveBeenCalledWith(25);
});

it('falls back to the static image when Rive reports an error', () => {
const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

act(() => mockOnErrorRef.current?.({ message: 'boom' }));

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
).toBeOnTheScreen();
expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MoneyNextBestActionParallaxTestIds = {
CONTAINER: 'money-next-best-action-parallax-container',
RIVE: 'money-next-best-action-parallax-rive',
BACKGROUND: 'money-next-best-action-parallax-background',
STATIC_IMAGE: 'money-next-best-action-parallax-static-image',
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { useCallback, useState } from 'react';

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.

If you haven't already it would be worth creating a Testflight build via GH action to see how it responds on iOS. I noticed for the first time deposit animation that iOS and Android flipped the y-axis. Not a big problem tbh it's just something I didn't know until I tested on both.

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.

I don't have TF access, but will ask @shane-t to take a look if he has time

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.

We should get you access to TestFlight. When you get a sec can you ping the mobile-platform team in #metamask-mobile-dev and request to be added to the internal tester group? Seats are limited but we should still have room.

import {
Image,
ImageSourcePropType,
StyleProp,
StyleSheet,
ViewStyle,
} from 'react-native';
import { Box } from '@metamask/design-system-react-native';
import LinearGradient from 'react-native-linear-gradient';
import Rive, {
AutoBind,
Fit,
RNRiveError,
useRive,
useRiveNumber,
} from 'rive-react-native';
import { createProjectLogger } from '@metamask/utils';
import { useReduceMotion } from '../../hooks/useReduceMotion';
import { useDeviceOrientation } from '../../hooks/useDeviceOrientation';
import { tiltToParallaxValue } from './parallax';
import NextBestActionParallaxAnimation from '../../../../../animations/next_best_action_module_v1.riv';
import styles from './MoneyNextBestActionParallax.styles';
import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';

const log = createProjectLogger('money-parallax');

// Artboard names inside next_best_action_module_v1.riv, one per onboarding step.
export const PARALLAX_ARTBOARD_FUND = 'Parallax Block 1';
export const PARALLAX_ARTBOARD_CARD = 'Parallax Block 2';
Comment thread
Kureev marked this conversation as resolved.

const RIVE_PROPERTY_X = 'xValue';
const RIVE_PROPERTY_Y = 'yValue';

// The Rive artboard is transparent — the card's gradient background (sampled
// from the design) is rendered behind it.
const PARALLAX_BACKGROUND_COLORS = [
'rgb(24, 1, 101)',
'rgb(40, 43, 142)',
'rgb(57, 93, 191)',
];

interface MoneyNextBestActionParallaxProps {
/** Rive artboard to render (see PARALLAX_ARTBOARD_* constants). */
artboardName: string;
/** Static image shown when the animation is unavailable or disabled. */
fallbackImage: ImageSourcePropType;
style?: StyleProp<ViewStyle>;
testID?: string;
}

const MoneyNextBestActionParallax = ({
artboardName,
fallbackImage,
style,
testID,
}: MoneyNextBestActionParallaxProps) => {
const reduceMotion = useReduceMotion();
const [hasRiveError, setHasRiveError] = useState(false);
const [riveRef, riveInstance] = useRive();
const [, setXValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_X);
const [, setYValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_Y);

const animate = !reduceMotion && !hasRiveError;

const applyTilt = useCallback(
(x: number, y: number) => {
setXValue(tiltToParallaxValue(x));
setYValue(tiltToParallaxValue(y));
},
[setXValue, setYValue],
);

useDeviceOrientation(applyTilt, { enabled: animate });
Comment thread
cursor[bot] marked this conversation as resolved.

const handleError = useCallback((riveError: RNRiveError) => {
log(`Rive error: ${riveError.message}`);
setHasRiveError(true);
}, []);

let content: React.ReactNode;
if (animate) {
content = (
<>
<LinearGradient
colors={PARALLAX_BACKGROUND_COLORS}
style={StyleSheet.absoluteFill}
testID={MoneyNextBestActionParallaxTestIds.BACKGROUND}
/>
<Rive
ref={riveRef}
source={NextBestActionParallaxAnimation}
artboardName={artboardName}
dataBinding={AutoBind(true)}
fit={Fit.Contain}

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.

Non-blocking: I think Fit.Contain can sometimes aggressively shrink the Rive animation on different devices. I'm not a Rive expert but I think fit={Fit.Layout} is meant to be more responsive (as long as the animation file is leveraging responsive layout).

style={styles.media}
onError={handleError}
testID={MoneyNextBestActionParallaxTestIds.RIVE}
/>
</>
);
} else {
content = (
<Image
source={fallbackImage}
style={styles.media}
resizeMode="contain"
testID={MoneyNextBestActionParallaxTestIds.STATIC_IMAGE}
/>
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

return (
<Box
style={style}
twClassName={
animate ? 'w-full aspect-video overflow-hidden rounded-2xl' : undefined
}
testID={testID ?? MoneyNextBestActionParallaxTestIds.CONTAINER}
>
{content}
</Box>
);
};

export default MoneyNextBestActionParallax;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
default,
PARALLAX_ARTBOARD_FUND,
PARALLAX_ARTBOARD_CARD,
} from './MoneyNextBestActionParallax';
export { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
Loading
Loading