From eb564edb6939e9d50a52f5f9cbf2f06724ca87d0 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Mon, 9 Feb 2026 15:59:06 -0800 Subject: [PATCH 1/2] fix edge to edge display issues for Tour component --- packages/mobile/src/tour/DefaultTourMask.tsx | 20 +++++++++++++++---- packages/mobile/src/tour/Tour.tsx | 17 +++++++++++++--- .../mobile/src/tour/__tests__/Tour.test.tsx | 14 +++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/mobile/src/tour/DefaultTourMask.tsx b/packages/mobile/src/tour/DefaultTourMask.tsx index 4e2145243..e343c0775 100644 --- a/packages/mobile/src/tour/DefaultTourMask.tsx +++ b/packages/mobile/src/tour/DefaultTourMask.tsx @@ -1,7 +1,9 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; import { Defs, Mask, Rect as NativeRect, Svg } from 'react-native-svg'; import { defaultRect, type Rect } from '@coinbase/cds-common/types/Rect'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout'; @@ -11,6 +13,7 @@ export const DefaultTourMask = memo( ({ activeTourStepTarget, padding, borderRadius = 12 }: TourMaskComponentProps) => { const [rect, setRect] = useState(defaultRect); const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const overlayFillRgba = theme.color.bgOverlay; const defaultPadding = theme.space[2]; @@ -27,10 +30,19 @@ export const DefaultTourMask = memo( ); useEffect(() => { - activeTourStepTarget?.measureInWindow((x, y, width, height) => - setRect({ x, y, width, height }), - ); - }, [activeTourStepTarget]); + activeTourStepTarget?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + + setRect({ x, y: adjustedY, width, height }); + }); + }, [activeTourStepTarget, statusBarHeight]); return ( diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index ca2190a7e..a2ceca947 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { Modal, View } from 'react-native'; +import { Modal, Platform, View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { OverlayContentContext, @@ -27,6 +27,7 @@ import { } from '@floating-ui/react-native'; import { animated, config as springConfig, useSpring } from '@react-spring/native'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { DefaultTourMask } from './DefaultTourMask'; @@ -116,6 +117,7 @@ const TourComponent = ({ testID, }: TourProps) => { const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const defaultTourStepOffset = theme.space[3]; const defaultTourStepShiftPadding = theme.space[4]; @@ -175,9 +177,18 @@ const TourComponent = ({ const handleActiveTourStepTargetChange = useCallback( (target: View | null) => { target?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + refs.setReference({ measure: (callback: (x: number, y: number, width: number, height: number) => void) => { - callback(x, y, width, height); + callback(x, adjustedY, width, height); void animationApi.start({ to: { opacity: 1 }, config: springConfig.slow }); }, }); @@ -185,7 +196,7 @@ const TourComponent = ({ setActiveTourStepTarget(target); }, - [animationApi, refs, setActiveTourStepTarget], + [animationApi, refs, setActiveTourStepTarget, statusBarHeight], ); return ( diff --git a/packages/mobile/src/tour/__tests__/Tour.test.tsx b/packages/mobile/src/tour/__tests__/Tour.test.tsx index 1d1ad49a0..24421cc9a 100644 --- a/packages/mobile/src/tour/__tests__/Tour.test.tsx +++ b/packages/mobile/src/tour/__tests__/Tour.test.tsx @@ -3,9 +3,15 @@ import { Button, Text } from 'react-native'; import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { useDimensions } from '../../hooks/useDimensions'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Tour, type TourProps } from '../Tour'; +jest.mock('../../hooks/useDimensions'); +const mockUseDimensions = (mocks: ReturnType) => { + (useDimensions as jest.Mock).mockReturnValue(mocks); +}; + const StepOne = () => { const { goNextTourStep } = useTourContext(); @@ -51,6 +57,14 @@ const exampleProps: TourProps = { }; describe('Tour', () => { + beforeEach(() => { + mockUseDimensions({ + screenHeight: 844, + screenWidth: 390, + statusBarHeight: 47, + }); + }); + it('passes accessibility', async () => { render( From 3589cfd827f584e19ea1e36a77d126986c46d4f3 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Mon, 9 Feb 2026 16:47:39 -0800 Subject: [PATCH 2/2] Reimplement mobile stepper animations with reanimated and change to timing based animation to match motion spec from designs --- .../DefaultStepperHeaderHorizontal.tsx | 67 +++-- .../DefaultStepperProgressHorizontal.tsx | 30 ++- .../DefaultStepperProgressVertical.tsx | 52 ++-- .../stepper/DefaultStepperStepHorizontal.tsx | 6 +- .../stepper/DefaultStepperStepVertical.tsx | 6 +- packages/mobile/src/stepper/Stepper.tsx | 235 ++++++++---------- .../__stories__/StepperHorizontal.stories.tsx | 28 +++ .../__stories__/StepperVertical.stories.tsx | 35 +++ 8 files changed, 268 insertions(+), 191 deletions(-) diff --git a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx index 6afa2475f..b40840d8e 100644 --- a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx @@ -1,17 +1,20 @@ -import { memo, useEffect, useMemo } from 'react'; -import { animated, useSpring } from '@react-spring/native'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { HStack } from '../layout/HStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { Text } from '../typography/Text'; import type { StepperHeaderComponent } from './Stepper'; -const AnimatedHStack = animated(HStack); +const AnimatedHStack = Animated.createAnimatedComponent(HStack); export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( function DefaultStepperHeaderHorizontal({ activeStep, complete, + disableAnimateOnMount, flatStepIds, style, paddingBottom = 1.5, @@ -20,27 +23,41 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( fontFamily = font, ...props }) { - const [spring, springApi] = useSpring( - { - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }, - [], - ); + const opacity = useSharedValue(disableAnimateOnMount ? 1 : 0); + const disableAnimateOnMountRef = useRef(disableAnimateOnMount); + const isInitialRender = useRef(true); + + const [displayedStep, setDisplayedStep] = useState(activeStep); + const [displayedComplete, setDisplayedComplete] = useState(complete); - // TO DO: resetting the spring doesn't work like it does in react-spring on web - // need to look into this deeper and understand why there is a difference in behavior useEffect(() => { - springApi.start({ - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }); - }, [springApi, activeStep]); + if (isInitialRender.current) { + isInitialRender.current = false; + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + if (disableAnimateOnMountRef.current) return; + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + return; + } + + // Fade out with old text, then swap text and fade in + opacity.value = withTiming(0, { duration: durations.fast1, easing: mobileCurves.linear }); + + const timeout = setTimeout(() => { + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + }, durations.fast1 + durations.fast1); + + return () => clearTimeout(timeout); + }, [activeStep, complete, opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); - const styles = useMemo(() => [style, spring] as any, [style, spring]); - const flatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; + const styles = useMemo(() => [style, animatedStyle], [style, animatedStyle]); + const flatStepIndex = displayedStep ? flatStepIds.indexOf(displayedStep.id) : -1; const emptyText = ' '; // Simple space for React Native return ( @@ -52,7 +69,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( {...props} > - {!activeStep || complete ? ( + {!displayedStep || displayedComplete ? ( emptyText ) : ( @@ -65,12 +82,12 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( > {flatStepIndex + 1}/{flatStepIds.length} - {activeStep.label && typeof activeStep.label === 'string' ? ( + {displayedStep.label && typeof displayedStep.label === 'string' ? ( - {activeStep.label} + {displayedStep.label} ) : ( - activeStep.label + displayedStep.label )} )} diff --git a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx index 7110050e1..7d07a6721 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx @@ -1,11 +1,12 @@ -import { memo } from 'react'; -import { animated, to } from '@react-spring/native'; +import { memo, useCallback, useEffect } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { Box } from '../layout/Box'; import type { StepperProgressComponent } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( function DefaultStepperProgressHorizontal({ @@ -19,7 +20,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( progress, complete, isDescendentActive, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, style, @@ -33,6 +34,24 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( height = 4, ...props }) { + const containerWidth = useSharedValue(0); + const animatedProgress = useSharedValue(progress); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerWidth.value = event.nativeEvent.layout.width; + }, + [containerWidth], + ); + + useEffect(() => { + animatedProgress.value = withTiming(progress, progressTimingConfig); + }, [progress, progressTimingConfig, animatedProgress]); + + const animatedStyle = useAnimatedStyle(() => ({ + width: animatedProgress.value * containerWidth.value, + })); + return ( @@ -57,7 +77,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( } borderRadius={borderRadius} height="100%" - width={to([progress], (width) => `${width * 100}%`)} + style={animatedStyle} /> ); diff --git a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx index d582c8170..1530a9687 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx @@ -1,13 +1,13 @@ -import { memo, useCallback, useMemo } from 'react'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; +import { memo, useCallback, useEffect, useMemo } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { flattenSteps } from '@coinbase/cds-common/stepper/utils'; -import { animated, to, useSpring } from '@react-spring/native'; import { Box } from '../layout/Box'; import type { StepperProgressComponent, StepperValue } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressVertical: StepperProgressComponent = memo( function DefaultStepperProgressVertical({ @@ -23,9 +23,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( isDescendentActive, style, activeStepLabelElement, - progressSpringConfig, - animate = true, - disableAnimateOnMount, + progressTimingConfig, background = 'bgLine', defaultFill = 'bgLinePrimarySubtle', activeFill = 'bgLinePrimarySubtle', @@ -36,7 +34,6 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( width = 2, ...props }) { - const hasMounted = useHasMounted(); const isLastStep = flatStepIds[flatStepIds.length - 1] === step.id; // Count the total number of sub-steps in the current step's tree @@ -56,35 +53,45 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( [], ); + // Fractional fill for steps with sub-steps. For all other cases, return 1 + // and let the cascade's `progress` prop control whether the bar is filled. const progressHeight = useMemo(() => { const totalSubSteps = countAllSubSteps(step.subSteps ?? []); - if (complete) return 1; - if (active && totalSubSteps === 0) return 1; - if (active && !isDescendentActive) return 0; - if (isDescendentActive) { + if (active && totalSubSteps > 0 && !isDescendentActive) return 0; + if (isDescendentActive && totalSubSteps > 0) { const activePosition = findSubStepPosition(step.subSteps ?? [], activeStepId); return activePosition / totalSubSteps; } - if (visited) return 1; - return 0; + return 1; }, [ countAllSubSteps, step.subSteps, - complete, active, isDescendentActive, - visited, findSubStepPosition, activeStepId, ]); - const fillHeightSpring = useSpring({ - height: progressHeight, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - config: progressSpringConfig, - }); + const containerHeight = useSharedValue(0); + const targetHeight = progress * progressHeight; + const animatedHeight = useSharedValue(targetHeight); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerHeight.value = event.nativeEvent.layout.height; + }, + [containerHeight], + ); + + useEffect(() => { + animatedHeight.value = withTiming(targetHeight, progressTimingConfig); + }, [targetHeight, progressTimingConfig, animatedHeight]); + + const animatedStyle = useAnimatedStyle(() => ({ + height: animatedHeight.value * containerHeight.value, + })); if (depth > 0 || isLastStep) return null; @@ -93,6 +100,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( background={background} flexGrow={1} minHeight={minHeight} + onLayout={handleLayout} position="relative" style={style} width={width} @@ -110,8 +118,8 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( ? visitedFill : defaultFill } - height={to([progress, fillHeightSpring.height], (p, h) => `${p * h * 100}%`)} position="absolute" + style={animatedStyle} width="100%" /> diff --git a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx index 8e94ff179..fdf3221c8 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx @@ -24,7 +24,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepHorizontal, @@ -75,7 +75,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -140,7 +140,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx index ae544fc18..77aad211e 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx @@ -27,7 +27,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepVertical, @@ -80,7 +80,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -146,7 +146,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx index e780f1fdf..701769139 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -1,19 +1,15 @@ -import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { WithTimingConfig } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; -import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { containsStep, flattenSteps, isStepVisited } from '@coinbase/cds-common/stepper/utils'; import type { IconName } from '@coinbase/cds-common/types'; -import { - type SpringConfig, - type SpringValue as SpringValueType, - useSprings, -} from '@react-spring/native'; import type { IconProps } from '../icons/Icon'; import { Box, type BoxBaseProps, type BoxProps } from '../layout/Box'; import { VStack } from '../layout/VStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { DefaultStepperHeaderHorizontal } from './DefaultStepperHeaderHorizontal'; import { DefaultStepperIconVertical } from './DefaultStepperIconVertical'; @@ -73,13 +69,13 @@ export type StepperStepProps = Record & BoxProps & { /** - * An animated SpringValue between 0 and 1. - * You can use this to animate your own custom Progress subcomponent. + * A value between 0 and 1 representing the step's progress. + * Progress bar subcomponents animate to this value internally. */ - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; setActiveStepLabelElement: (element: View) => void; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; completedStepAccessibilityLabel?: string; @@ -110,6 +106,7 @@ export type StepperHeaderProps = Record activeStep: StepperValue | null; flatStepIds: string[]; complete?: boolean; + disableAnimateOnMount?: boolean; style?: StyleProp; }; @@ -129,9 +126,9 @@ export type StepperProgressProps< Metadata extends Record = Record, > = StepperSubcomponentProps & BoxProps & { - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; defaultFill?: ThemeVars.Color; @@ -215,9 +212,9 @@ export type StepperBaseProps = Record | null; /** An optional component to render in place of the default Header subcomponent. Set to null to render nothing in this slot. */ StepperHeaderComponent?: StepperHeaderComponent | null; - /** The spring config to use for the progress spring. */ - progressSpringConfig?: SpringConfig; - /** Whether to animate the progress spring. + /** The timing config to use for the progress animation. */ + progressTimingConfig?: WithTimingConfig; + /** Whether to animate the progress bar. * @default true */ animate?: boolean; @@ -248,8 +245,12 @@ export type StepperProps = Record = Record>( props: StepperProps & { ref?: React.Ref }, @@ -287,14 +288,13 @@ const StepperBase = memo( StepperHeaderComponent = direction === 'vertical' ? null : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressSpringConfig = defaultProgressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, ...props }: StepperProps, ref: React.Ref, ) => { - const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); // Derive activeStep from activeStepId @@ -339,90 +339,57 @@ const StepperBase = memo( : -1; }, [activeStepId, steps]); - const previousComplete = usePreviousValue(complete) ?? false; - const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1; + // The effective cascade target: when complete, fill all steps up to the last one. + // Otherwise, fill up to activeStepIndex. + const cascadeTarget = complete ? steps.length - 1 : activeStepIndex; - const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({ - progress: complete ? 1 : 0, - config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - })); + // Cascade animation state: advances one step at a time toward cascadeTarget. + // When disableAnimateOnMount is false (default), start unfilled (-1) so the + // cascade animates bars one-at-a-time up to the target on mount. + const [filledStepIndex, setFilledStepIndex] = useState(() => + disableAnimateOnMount ? cascadeTarget : -1, + ); + const targetStepIndexRef = useRef(cascadeTarget); useEffect(() => { - // update the previous values for next render - let stepsToAnimate: number[] = []; - let isAnimatingForward = false; - - // Case when going from not-complete to complete - if (Boolean(complete) !== previousComplete) { - if (complete) { - // Going to complete: animate from activeStepIndex+1 to end - stepsToAnimate = Array.from( - { length: steps.length - activeStepIndex - 1 }, - (_, i) => activeStepIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Going from complete: animate from end down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: steps.length - activeStepIndex - 1 }, - (_, i) => steps.length - 1 - i, - ); - isAnimatingForward = false; - } - } + targetStepIndexRef.current = cascadeTarget; - // Case for normal step navigation (e.g. step 1 => step 2) - else if (activeStepIndex !== previousActiveStepIndex) { - if (activeStepIndex > previousActiveStepIndex) { - // Forward: animate from previousActiveStepIndex+1 to activeStepIndex - stepsToAnimate = Array.from( - { length: activeStepIndex - previousActiveStepIndex }, - (_, i) => previousActiveStepIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Backward: animate from previousActiveStepIndex down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: previousActiveStepIndex - activeStepIndex }, - (_, i) => previousActiveStepIndex - i, - ); - isAnimatingForward = false; - } + if (!animate) { + setFilledStepIndex(cascadeTarget); + return; } - const animateNextStep = () => { - if (stepsToAnimate.length === 0) return; - const stepIndex = stepsToAnimate.shift(); - if (stepIndex === undefined) return; - - progressSpringsApi.start((index) => - index === stepIndex - ? { - progress: isAnimatingForward ? 1 : 0, - config: progressSpringConfig, - onRest: animateNextStep, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - } - : {}, - ); - }; - - // start the animation loop for relevant springs (stepsToAnimate) - animateNextStep(); - }, [ - progressSpringsApi, - complete, - steps.length, - steps, - activeStepIndex, - previousActiveStepIndex, - previousComplete, - progressSpringConfig, - animate, - disableAnimateOnMount, - hasMounted, - ]); + // Advance one step immediately to kick off the cascade + setFilledStepIndex((prev) => { + if (prev === cascadeTarget) return prev; + return prev < cascadeTarget ? prev + 1 : prev - 1; + }); + + // Continue advancing on a fixed interval for fluid, overlapping springs + const interval = setInterval(() => { + setFilledStepIndex((prev) => { + const target = targetStepIndexRef.current; + if (prev === target) return prev; + return prev < target ? prev + 1 : prev - 1; + }); + }, cascadeStaggerMs); + + return () => clearInterval(interval); + }, [cascadeTarget, animate]); + + // Compute progress for each step: 1 if filled, 0 if not + const getStepProgress = useCallback( + (index: number) => { + if (!animate) { + if (complete) return 1; + if (activeStepIndex < 0) return 0; + return index <= activeStepIndex ? 1 : 0; + } + if (filledStepIndex < 0) return 0; + return index <= filledStepIndex ? 1 : 0; + }, + [complete, animate, activeStepIndex, filledStepIndex], + ); return ( @@ -446,42 +414,43 @@ const StepperBase = memo( ? containsStep({ step, targetStepId: activeStepId }) : false; const RenderedStepComponent = step.Component ?? StepperStepComponent; + + if (!RenderedStepComponent) return null; + return ( - RenderedStepComponent && ( - - ) + ); })} diff --git a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx index 835f437ac..1637559b4 100644 --- a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx @@ -3,6 +3,7 @@ import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; import { useStepper } from '@coinbase/cds-common/stepper/useStepper'; import { Button } from '../../buttons'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { HStack, VStack } from '../../layout'; @@ -216,6 +217,29 @@ const NoActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Custom Progress Component // ------------------------------------------------------------ @@ -264,6 +288,10 @@ const StepperHorizontalScreen = () => { + + + + diff --git a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx index caf2bb517..aa067c3d6 100644 --- a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx @@ -8,6 +8,7 @@ import { import { Button } from '../../buttons'; import { ListCell } from '../../cells'; import { Collapsible } from '../../collapsible'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { Box, HStack, VStack } from '../../layout'; @@ -247,6 +248,36 @@ const InitialActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const disableAnimateOnMountSteps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { id: 'second-step', label: 'Second step' }, + { id: 'third-step', label: 'Third step' }, + { id: 'final-step', label: 'Final step' }, +]; + +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Nested Steps // ------------------------------------------------------------ @@ -733,6 +764,10 @@ const StepperVerticalScreen = () => { + + + +