-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(money): tilt-driven parallax on Money onboarding card (MUSD-1048) #32892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
fb8f8e6
a1b2acb
89aebb7
d40a592
441dd4f
7df7e3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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'; | ||
|
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 }); | ||
|
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} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: I think |
||
| style={styles.media} | ||
| onError={handleError} | ||
| testID={MoneyNextBestActionParallaxTestIds.RIVE} | ||
| /> | ||
| </> | ||
| ); | ||
| } else { | ||
| content = ( | ||
| <Image | ||
| source={fallbackImage} | ||
| style={styles.media} | ||
| resizeMode="contain" | ||
| testID={MoneyNextBestActionParallaxTestIds.STATIC_IMAGE} | ||
| /> | ||
| ); | ||
|
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'; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StepperCard.tsx:60
There was a problem hiding this comment.
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 ifmediaexists, it will use it, otherwise fallback to Image