diff --git a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md index 0735b0be5..53255ca17 100644 --- a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md +++ b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the IntroScreen did not show the slide set by the active slide attribute, and where swiping between slides did not work reliably on slower Android devices. + ## [4.3.0] - 2026-4-10 ### Changed diff --git a/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml b/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml index 9df303212..6cef5970c 100644 --- a/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml +++ b/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml @@ -11,15 +11,21 @@ appId: "${APP_ID}" timeout: 5000 - assertVisible: text: "Changes: 0" +- waitForAnimationToEnd: + timeout: 2000 - swipe: - direction: LEFT + start: 90%, 10% + end: 15%, 10% - extendedWaitUntil: visible: "Active slide: 3" timeout: 5000 - assertVisible: text: "Changes: 1" +- waitForAnimationToEnd: + timeout: 2000 - swipe: - direction: RIGHT + start: 15%, 10% + end: 90%, 10% - extendedWaitUntil: visible: "Active slide: 2" timeout: 5000 @@ -47,6 +53,9 @@ appId: "${APP_ID}" timeout: 5000 - tapOn: text: "NEXT" +- extendedWaitUntil: + visible: "Active slide: 3" + timeout: 5000 - tapOn: text: "FINISH" - extendedWaitUntil: diff --git a/packages/pluggableWidgets/intro-screen-native/package.json b/packages/pluggableWidgets/intro-screen-native/package.json index 3053350ac..c159784af 100644 --- a/packages/pluggableWidgets/intro-screen-native/package.json +++ b/packages/pluggableWidgets/intro-screen-native/package.json @@ -1,7 +1,7 @@ { "name": "intro-screen-native", "widgetName": "IntroScreen", - "version": "4.3.0", + "version": "4.4.0", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx b/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx index c0be8b0a3..fcf35f9d0 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx @@ -2,7 +2,6 @@ import { Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef, useS import { I18nManager, LayoutChangeEvent, - NativeSyntheticEvent, Platform, StyleSheet, Text, @@ -53,8 +52,14 @@ const isAndroidRTL = I18nManager.isRTL && Platform.OS === "android"; const Touchable: React.ComponentType = Platform.OS === "android" ? TouchableNativeFeedback : TouchableOpacity; +// Changing this config after mount is not supported by flash-list, so it is a constant. +const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 60, + minimumViewTime: 250 +} as const; + const refreshActiveSlideAttribute = (slides: SlidesType[], activeSlide?: EditableValue): number => { - if (activeSlide && activeSlide.status === ValueStatus.Available && slides && slides.length > 0) { + if (activeSlide && activeSlide.value !== undefined && slides && slides.length > 0) { const slide = Number(activeSlide.value) - 1; if (slide < 0) { return 0; @@ -69,9 +74,21 @@ const refreshActiveSlideAttribute = (slides: SlidesType[], activeSlide?: Editabl export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement => { const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(() => refreshActiveSlideAttribute(props.slides, props.activeSlide)); const flashList = useRef>(null); - const isInitializing = useRef(true); + const pendingWrite = useRef<{ replaced: number } | null>(null); + const initialIndex = useRef(activeIndex); + const isUserScrolling = useRef(false); + const activeSlidePending = + props.activeSlide?.status === ValueStatus.Loading && props.activeSlide.value === undefined; + const listMounted = useRef(false); + + if (!listMounted.current && !activeSlidePending) { + initialIndex.current = refreshActiveSlideAttribute(props.slides, props.activeSlide); + if (initialIndex.current !== activeIndex) { + setActiveIndex(initialIndex.current); + } + } const rtlSafeIndex = useCallback( (i: number): number => (isAndroidRTL ? props.slides.length - 1 - i : i), @@ -81,7 +98,7 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement const goToSlide = useCallback( (pageNum: number) => { setActiveIndex(pageNum); - if (flashList && flashList.current) { + if (width > 0 && flashList && flashList.current) { flashList.current.scrollToOffset({ offset: rtlSafeIndex(pageNum) * width }); @@ -91,19 +108,19 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement ); useEffect(() => { + if (!width || props.activeSlide?.status !== ValueStatus.Available) { + return; + } const slide = refreshActiveSlideAttribute(props.slides, props.activeSlide); - if (width && props.activeSlide?.status === ValueStatus.Available && slide !== activeIndex) { - goToSlide(slide); - if (isInitializing.current) { - if (isInitializing.current) { - // Use requestAnimationFrame twice to wait for the next frame after scroll. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - isInitializing.current = false; - }); - }); - } + const pending = pendingWrite.current; + if (pending) { + if (slide === pending.replaced) { + return; } + pendingWrite.current = null; + } + if (slide !== activeIndex) { + goToSlide(slide); } }, [props.activeSlide, activeIndex, width, props.slides, goToSlide]); @@ -181,6 +198,7 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement const onSlideChange = useCallback( (newIndex: number, lastIndex: number): void => { if (props.activeSlide && !props.activeSlide.readOnly) { + pendingWrite.current = { replaced: lastIndex }; props.activeSlide.setValue(new Big(newIndex + 1)); } if (props.onSlideChange) { @@ -303,24 +321,28 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement ); }; - const onMomentumScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - const offset = event.nativeEvent.contentOffset.x; - const newIndex = rtlSafeIndex(Math.round(offset / width)); - if (newIndex === activeIndex) { + const onScrollBeginDrag = useCallback(() => { + isUserScrolling.current = true; + }, []); + + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: Array<{ index: number | null }> }) => { + if (!isUserScrolling.current) { return; } - - if (isInitializing.current) { - setActiveIndex(newIndex); + const visible = viewableItems.find(token => token.index !== null); + if (!visible || visible.index === null) { + return; + } + const newIndex = rtlSafeIndex(visible.index); + if (newIndex === activeIndex) { return; } - const lastIndex = activeIndex; setActiveIndex(newIndex); onSlideChange(newIndex, lastIndex); }, - [activeIndex, width, rtlSafeIndex, onSlideChange] + [activeIndex, rtlSafeIndex, onSlideChange] ); /** @@ -341,25 +363,41 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement [width, height] ); + const showList = width > 0 && (listMounted.current || !activeSlidePending); + + useEffect(() => { + if (showList) { + listMounted.current = true; + } + }, [showList]); + return ( - "screen_key_" + index} - /> + {showList ? ( + "screen_key_" + index} + importantForAccessibility="no" + /> + ) : ( + + )} {renderPagination()} ); diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.notch.spec.tsx b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.notch.spec.tsx index e41403530..b84a944fe 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.notch.spec.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.notch.spec.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react-native"; +import { render, act, fireEvent, RenderAPI } from "@testing-library/react-native"; import { IntroScreen } from "../IntroScreen"; import { IntroScreenProps } from "../../typings/IntroScreenProps"; import { IntroScreenStyle } from "../ui/Styles"; @@ -16,6 +16,12 @@ jest.mock("@react-native-async-storage/async-storage", () => ({ setValue: jest.fn().mockResolvedValue(null) })); +const layout = (component: RenderAPI, name: string): void => { + fireEvent(component.getByTestId(name), "layout", { + nativeEvent: { layout: { width: 400, height: 800 } } + }); +}; + describe("Intro Screen", () => { let defaultProps: IntroScreenProps; @@ -41,6 +47,7 @@ describe("Intro Screen", () => { it("renders", () => { const component = render(); + layout(component, "intro-screen-notch-test"); expect(component.toJSON()).toMatchSnapshot(); }); @@ -48,11 +55,13 @@ describe("Intro Screen", () => { const component = render( ); + layout(component, "intro-screen-notch-test"); expect(component.toJSON()).toMatchSnapshot(); }); it("renders with 2 bottom button", () => { const component = render(); + layout(component, "intro-screen-notch-test"); expect(component.toJSON()).toMatchSnapshot(); }); @@ -63,11 +72,15 @@ describe("Intro Screen", () => { activeSlideAttribute={new EditableValueBuilder().withValue(new Big(1)).build()} /> ); + layout(component, "intro-screen-notch-test"); expect(component.toJSON()).toMatchSnapshot(); }); - it("renders with async storage identifier", () => { + it("renders with async storage identifier", async () => { const component = render(); + // Wait for async storage to resolve + await act(async () => {}); + layout(component, "intro-screen-notch-test"); expect(component.toJSON()).toMatchSnapshot(); }); }); diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx index 03a12e580..14c1bd1d4 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react-native"; +import { render, act, fireEvent, RenderAPI } from "@testing-library/react-native"; import { IntroScreen } from "../IntroScreen"; import { IntroScreenProps } from "../../typings/IntroScreenProps"; import { IntroScreenStyle } from "../ui/Styles"; @@ -16,6 +16,12 @@ jest.mock("@react-native-async-storage/async-storage", () => ({ setValue: jest.fn().mockResolvedValue(null) })); +const layout = (component: RenderAPI, name: string): void => { + fireEvent(component.getByTestId(name), "layout", { + nativeEvent: { layout: { width: 400, height: 800 } } + }); +}; + describe("Intro Screen", () => { let defaultProps: IntroScreenProps; @@ -46,6 +52,7 @@ describe("Intro Screen", () => { it("renders", () => { const component = render(); + layout(component, "intro-screen-test"); expect(component.toJSON()).toMatchSnapshot(); }); @@ -53,11 +60,13 @@ describe("Intro Screen", () => { const component = render( ); + layout(component, "intro-screen-test"); expect(component.toJSON()).toMatchSnapshot(); }); it("renders with 2 bottom button", () => { const component = render(); + layout(component, "intro-screen-test"); expect(component.toJSON()).toMatchSnapshot(); }); @@ -68,11 +77,241 @@ describe("Intro Screen", () => { activeSlideAttribute={new EditableValueBuilder().withValue(new Big(1)).build()} /> ); + layout(component, "intro-screen-test"); expect(component.toJSON()).toMatchSnapshot(); }); - it("renders with async storage identifier", () => { + it("renders with async storage identifier", async () => { const component = render(); + // Wait for async storage to resolve + await act(async () => {}); + layout(component, "intro-screen-test"); expect(component.toJSON()).toMatchSnapshot(); }); + + describe("active slide attribute", () => { + const threeSlides = [ + { name: "Page 1", content: }, + { name: "Page 2", content: }, + { name: "Page 3", content: } + ]; + + const reportViewable = (component: RenderAPI, index: number): void => { + const list = component.getByTestId("intro-screen-test"); + fireEvent(list, "scrollBeginDrag"); + fireEvent(list, "viewableItemsChanged", { + viewableItems: [{ index, isViewable: true }], + changed: [{ index, isViewable: true }] + }); + }; + + it("reports the slide the list says is showing", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(1)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + reportViewable(component, 1); + + expect(activeSlideAttribute.setValue).toHaveBeenCalledWith(new Big(2)); + }); + + it("reports a swipe made from the slide the attribute already named", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(1)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + reportViewable(component, 1); + + expect(activeSlideAttribute.setValue).toHaveBeenCalledTimes(1); + expect(activeSlideAttribute.setValue).toHaveBeenCalledWith(new Big(2)); + expect(component.queryByTestId("intro-screen-test$buttonPrevious")).not.toBeNull(); + }); + + it("does not report the slide that is already active", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + reportViewable(component, 1); + reportViewable(component, 1); + + expect(activeSlideAttribute.setValue).not.toHaveBeenCalled(); + }); + + it("ignores the list's own opening scroll", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + fireEvent(component.getByTestId("intro-screen-test"), "viewableItemsChanged", { + viewableItems: [{ index: 0, isViewable: true }], + changed: [{ index: 0, isViewable: true }] + }); + + expect(activeSlideAttribute.setValue).not.toHaveBeenCalled(); + expect(component.queryByTestId("intro-screen-test$buttonPrevious")).not.toBeNull(); + }); + + it("reports a swipe that begins before the list has finished opening", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.viewabilityConfig.waitForInteraction).toBe( + undefined + ); + + reportViewable(component, 2); + + expect(activeSlideAttribute.setValue).toHaveBeenCalledWith(new Big(3)); + }); + + it("ignores a viewability report that names no item", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(1)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + fireEvent(component.getByTestId("intro-screen-test"), "viewableItemsChanged", { + viewableItems: [], + changed: [] + }); + + expect(activeSlideAttribute.setValue).not.toHaveBeenCalled(); + }); + + it("stays on the new slide while the attribute write is still in flight", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + (activeSlideAttribute.setValue as jest.Mock).mockImplementation(() => undefined); + const component = render( + + ); + layout(component, "intro-screen-test"); + + fireEvent.press(component.getByTestId("intro-screen-test$buttonNext")); + expect(activeSlideAttribute.setValue).toHaveBeenCalledWith(new Big(3)); + + component.update( + + ); + + expect(component.queryByTestId("intro-screen-test$buttonDone")).not.toBeNull(); + expect(component.queryByTestId("intro-screen-test$buttonNext")).toBeNull(); + expect(activeSlideAttribute.setValue).toHaveBeenCalledTimes(1); + }); + + it("mounts the slides only once a width has been measured", () => { + const component = render( + ().withValue(new Big(3)).build()} + /> + ); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBeUndefined(); + + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(2); + }); + + it("does not re-point the mounted list at the slide navigated to", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(1); + + fireEvent.press(component.getByTestId("intro-screen-test$buttonNext")); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(1); + }); + + it("does not let the list hold the previous slide in place", () => { + const component = render(); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.maintainVisibleContentPosition).toBeUndefined(); + }); + + it("holds the slides back until the attribute has a value to open on", () => { + const activeSlideAttribute = new EditableValueBuilder().isLoading().build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBeUndefined(); + + component.update( + ().withValue(new Big(3)).build()} + /> + ); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(2); + }); + + it("keeps the slides up while a value it already has is refreshing", () => { + const refreshing = new EditableValueBuilder().withValue(new Big(2)).isLoading().build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(1); + }); + + it("keeps the slides up while the attribute reloads with no value after a swipe", () => { + const activeSlideAttribute = new EditableValueBuilder().withValue(new Big(2)).build(); + const component = render( + + ); + layout(component, "intro-screen-test"); + + reportViewable(component, 2); + expect(activeSlideAttribute.setValue).toHaveBeenCalledWith(new Big(3)); + + component.update( + ().isLoading().build()} + /> + ); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(1); + expect(component.queryByTestId("intro-screen-test$buttonDone")).not.toBeNull(); + expect(activeSlideAttribute.setValue).toHaveBeenCalledTimes(1); + }); + + it("opens on the first slide when the attribute has no value to give", () => { + const component = render( + ().isUnavailable().build()} + /> + ); + layout(component, "intro-screen-test"); + + expect(component.getByTestId("intro-screen-test").props.initialScrollIndex).toBe(0); + }); + }); }); diff --git a/packages/pluggableWidgets/intro-screen-native/src/package.xml b/packages/pluggableWidgets/intro-screen-native/src/package.xml index d039d8c27..3c89884b6 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/package.xml +++ b/packages/pluggableWidgets/intro-screen-native/src/package.xml @@ -1,6 +1,6 @@ - +