diff --git a/.github/workflows/NativePipeline.yml b/.github/workflows/NativePipeline.yml index cef6dc827..b82717e14 100644 --- a/.github/workflows/NativePipeline.yml +++ b/.github/workflows/NativePipeline.yml @@ -105,7 +105,7 @@ env: # Pin the test project (mendix/Native-Mobile-Resources) to a SHA for reproducibility instead # of tracking a moving `main`. Single source of truth โ€” bump deliberately. The repo publishes # no tags, so a commit SHA is the only stable ref. Override per run via the dispatch input. - NATIVE_MOBILE_RESOURCES_REF: ${{ github.event.inputs.test_project_ref || 'a915484ef2cfed403cfbdfa531638df3c46c9d00' }} + NATIVE_MOBILE_RESOURCES_REF: ${{ github.event.inputs.test_project_ref || '05255f5ad19de7b109f6a68088e4fb198447ed00' }} permissions: packages: write jobs: @@ -751,7 +751,7 @@ jobs: # truncated โ€” at 20 min several shards (accordion, background-image, bottom-sheet, # color-picker, safe-area-view) were cancelled mid-flow. If a shard still hits 30 the cause # is real app instability, not the cap. - timeout-minutes: 30 + timeout-minutes: 35 strategy: max-parallel: 4 matrix: diff --git a/maestro/helpers/helpers.sh b/maestro/helpers/helpers.sh index d035668e1..77235e86d 100644 --- a/maestro/helpers/helpers.sh +++ b/maestro/helpers/helpers.sh @@ -78,9 +78,24 @@ stop_recording() { REC_FILE="" } +# Function to clean up stale XCTest processes (iOS only) +cleanup_xctest_processes() { + if [ "$PLATFORM" != "ios" ]; then + return 0 + fi + echo "๐Ÿงน Cleaning up stale XCTest processes..." + # Kill any leftover XCTest runner processes that might be blocking driver initialization + pkill -9 -f "XCTRunner" 2>/dev/null || true + pkill -9 -f "xctest" 2>/dev/null || true + pkill -9 -f "maestro.*driver" 2>/dev/null || true + sleep 2 +} + # Function to restart the iOS simulator restart_simulator() { echo "๐Ÿ”„ Restarting iOS Simulator..." + # Clean up XCTest processes first + cleanup_xctest_processes # Shut down whatever is booted; the device is auto-selected in prepare_ios.sh, # so we don't depend on a hardcoded device name here. xcrun simctl shutdown all || true diff --git a/maestro/images/expected/android/bg_image_dynamic_svg.png b/maestro/images/expected/android/bg_image_dynamic_svg.png index ebd38ac4c..77122942e 100644 Binary files a/maestro/images/expected/android/bg_image_dynamic_svg.png and b/maestro/images/expected/android/bg_image_dynamic_svg.png differ diff --git a/maestro/images/expected/ios/bg_image_dynamic_svg.png b/maestro/images/expected/ios/bg_image_dynamic_svg.png new file mode 100644 index 000000000..91393cdfb Binary files /dev/null and b/maestro/images/expected/ios/bg_image_dynamic_svg.png differ diff --git a/maestro/run_maestro_jsactions_tests.sh b/maestro/run_maestro_jsactions_tests.sh index 56bc5b595..f4f7c5a7b 100644 --- a/maestro/run_maestro_jsactions_tests.sh +++ b/maestro/run_maestro_jsactions_tests.sh @@ -97,6 +97,16 @@ run_jsactions_tests() { fi } +# Clean up any stale XCTest processes before starting (iOS only) +if [ "$PLATFORM" == "ios" ]; then + cleanup_xctest_processes +fi + +# Fast-fail smoke check before running jsActions tests (same as widget tests) +if ! smoke_check; then + exit 1 +fi + # Run jsActions tests run_jsactions_tests if [ $? -ne 0 ]; then diff --git a/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Botton_sheet_expanding_fullscreen.yaml b/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Botton_sheet_expanding_fullscreen.yaml index c7c3a9a5c..84f75e399 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Botton_sheet_expanding_fullscreen.yaml +++ b/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Botton_sheet_expanding_fullscreen.yaml @@ -8,11 +8,11 @@ appId: "${APP_ID}" text: "Bottom Sheet" - tapOn: text: "Expanding fullscreen" -# Because the image loading can take some time due to resources on emulator, we need to wait for the image to be visible -- extendedWaitUntil: +# Because the image loading and animation can take some time due to resources on emulator, we need to wait +- extendedWaitUntil: visible: randText # Any random text that does not exist in the UI optional: true # This should be true so that the test won't fail - timeout: 10000 # 10 seconds + timeout: 15000 # 15 seconds to allow for animation to complete - takeScreenshot: path: "maestro/images/actual/${PLATFORM}/bottom_sheet_expanding_fullscreen" - assertScreenshot: diff --git a/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Modal_basic_non_native.yaml b/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Modal_basic_non_native.yaml index 199199179..95d39bdcd 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Modal_basic_non_native.yaml +++ b/packages/pluggableWidgets/bottom-sheet-native/e2e/specs/maestro/Modal_basic_non_native.yaml @@ -8,6 +8,10 @@ appId: "${APP_ID}" text: "Bottom Sheet" - tapOn: text: "Modal basic non native" +# Wait for the page to fully load before tapping Open +- extendedWaitUntil: + visible: "Open" + timeout: 10000 - tapOn: text: "Open" diff --git a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md index b44994119..5f4666072 100644 --- a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md +++ b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where reopening a page containing the intro screen would show the first slide instead of the slide referenced by the active slide attribute, and would report a slide change that the user did not make. +- We fixed an issue where the initial slide was scrolled to with an animation, which briefly left the slide content unavailable to screen readers. +- We fixed an issue where moving to a slide with the next, previous or pagination controls could jump back to the first slide and report an extra slide change. + ## [4.4.1] - 2026-6-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..09c4462a8 100644 --- a/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml +++ b/packages/pluggableWidgets/intro-screen-native/e2e/specs/maestro/IntroScreen.yaml @@ -47,6 +47,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/src/SwipeableContainer.tsx b/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx index c64934610..ca0f90d32 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx @@ -69,9 +69,17 @@ 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); + // Start on the slide the attribute points at, so activeIndex never lags the slide that + // initialScrollIndex renders. Otherwise the first Next/Previous press moves relative to + // slide 0 instead of the visible slide. + const [activeIndex, setActiveIndex] = useState(() => refreshActiveSlideAttribute(props.slides, props.activeSlide)); const flashList = useRef>(null); - const isInitializing = useRef(true); + const hasAppliedInitialScroll = useRef(false); + const isUserDragging = useRef(false); + // Slide a programmatic scroll is currently heading for, or null when the list is + // wherever the user left it. Used to recognise momentum events that contradict the + // scroll we just asked for; see onMomentumScrollEnd. + const pendingScrollTarget = useRef(null); const rtlSafeIndex = useCallback( (i: number): number => (isAndroidRTL ? props.slides.length - 1 - i : i), @@ -79,11 +87,13 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement ); const goToSlide = useCallback( - (pageNum: number) => { + (pageNum: number, animated = true) => { setActiveIndex(pageNum); if (flashList && flashList.current) { + pendingScrollTarget.current = pageNum; flashList.current.scrollToOffset({ - offset: rtlSafeIndex(pageNum) * width + offset: rtlSafeIndex(pageNum) * width, + animated }); } }, @@ -91,19 +101,21 @@ 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) { + // Once width is known, force the list to the attribute's slide even when activeIndex + // already matches: initialScrollIndex is applied before layout, so on remount the list + // can sit at offset 0 while activeIndex says otherwise. + if (!hasAppliedInitialScroll.current) { + hasAppliedInitialScroll.current = true; + // Jump without animation: activeIndex is applied immediately, and it drives which + // slide is exposed to accessibility. An animated scroll would leave the exposed + // slide off-screen until the animation lands. + goToSlide(slide, false); + } else if (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; - }); - }); - } - } } }, [props.activeSlide, activeIndex, width, props.slides, goToSlide]); @@ -315,15 +327,46 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement ); }; + const onScrollBeginDrag = useCallback(() => { + isUserDragging.current = true; + // The user takes over from here, so any programmatic scroll still in flight no + // longer describes where the list is heading. Clearing the target also keeps it + // from latching when a scroll never reports a momentum end of its own. + pendingScrollTarget.current = null; + }, []); + const onMomentumScrollEnd = useCallback( (event: NativeSyntheticEvent) => { + const wasUserDragging = isUserDragging.current; + isUserDragging.current = false; + + if (!width) { + return; + } const offset = event.nativeEvent.contentOffset.x; const newIndex = rtlSafeIndex(Math.round(offset / width)); + + // While a programmatic scroll is in flight the list can report a momentum end + // for the position it is leaving rather than the one it is heading to. Trusting + // that offset rewrites activeIndex to a slide that is not on screen, and the + // drag flag cannot tell the two apart because a fling reports momentum without + // a fresh drag. Anything that disagrees with the requested slide is stale. + const pendingTarget = pendingScrollTarget.current; + if (pendingTarget !== null) { + if (newIndex === pendingTarget) { + pendingScrollTarget.current = null; + } + return; + } + if (newIndex === activeIndex) { return; } - if (isInitializing.current) { + // Only a user swipe reports a change here. Programmatic scrolls (initial positioning, + // Next/Previous/pagination) already fired onSlideChange, so re-firing would double + // count the change and, on remount, emit a spurious change back to slide 1. + if (!wasUserDragging) { setActiveIndex(newIndex); return; } @@ -366,6 +409,7 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement bounces={false} style={styles.flatList} renderItem={renderItem} + onScrollBeginDrag={onScrollBeginDrag} onMomentumScrollEnd={onMomentumScrollEnd} scrollEventThrottle={50} extraData={[width, activeIndex]} diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/SwipeableContainer.remount.spec.tsx b/packages/pluggableWidgets/intro-screen-native/src/__tests__/SwipeableContainer.remount.spec.tsx new file mode 100644 index 000000000..e796a66b1 --- /dev/null +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/SwipeableContainer.remount.spec.tsx @@ -0,0 +1,221 @@ +import { render, fireEvent, act } from "@testing-library/react-native"; +import { View } from "react-native"; +import { EditableValueBuilder } from "@mendix/piw-utils-internal"; +import { EditableValue } from "mendix"; +import { Big } from "big.js"; +import { SwipeableContainer } from "../SwipeableContainer"; +import { defaultWelcomeScreenStyle } from "../ui/Styles"; + +jest.mock("react-native-device-info", () => ({ + hasNotch: jest.fn(), + getDeviceId: jest.fn().mockReturnValue("") +})); + +const WIDTH = 400; + +// Capture the scroll calls the widget issues, while leaving the real FlashList in place so +// layout and momentum-scroll events still behave normally. +const scrollCalls: Array<{ offset: number; animated?: boolean }> = []; + +jest.mock("@shopify/flash-list", () => { + const actual = jest.requireActual("@shopify/flash-list"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const react = require("react"); + const Wrapped = react.forwardRef((props: any, ref: any) => { + const inner = react.useRef(null); + react.useImperativeHandle(ref, () => ({ + scrollToOffset: (params: any) => { + (globalThis as any).__introScreenScrollCalls.push(params); + (inner.current as any)?.scrollToOffset?.(params); + } + })); + return react.createElement(actual.FlashList, { ...props, ref: inner }); + }); + return { ...actual, FlashList: Wrapped }; +}); + +(globalThis as any).__introScreenScrollCalls = scrollCalls; + +type Setup = ReturnType & { + activeSlide: EditableValue; + onSlideChange: jest.Mock; + scrollCalls: Array<{ offset: number; animated?: boolean }>; +}; + +function setup(activeSlideValue: number): Setup { + const activeSlide = new EditableValueBuilder().withValue(new Big(activeSlideValue)).build(); + const onSlideChange = jest.fn(); + const slides = [1, 2, 3].map(i => ({ name: `Page ${i}`, content: })); + + scrollCalls.length = 0; + + const utils = render( + + ); + + // Simulate layout so `width` becomes known, exactly as on a real device. + act(() => { + fireEvent(utils.getByTestId("intro"), "layout", { + nativeEvent: { layout: { width: WIDTH, height: 800 } } + }); + }); + + return { ...utils, activeSlide, onSlideChange, scrollCalls }; +} + +describe("SwipeableContainer remount behaviour", () => { + it("positions the initial slide without animation", () => { + const { scrollCalls } = setup(2); + + expect(scrollCalls.length).toBeGreaterThan(0); + expect(scrollCalls[0]).toEqual({ offset: WIDTH, animated: false }); + }); + + it("writes the next slide when Next is pressed after remounting on slide 2", () => { + const { getByTestId, activeSlide } = setup(2); + + fireEvent.press(getByTestId("intro$buttonNext")); + + expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3)); + }); + + it("does not report a slide change for the programmatic initial scroll", () => { + const { getByTestId, onSlideChange, activeSlide } = setup(2); + + // The forced initial scroll settles and reports its resting offset. + act(() => { + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: WIDTH } } + }); + }); + + expect(onSlideChange).not.toHaveBeenCalled(); + expect(activeSlide.setValue).not.toHaveBeenCalled(); + }); + + it("still writes the next slide when a stale scroll settles before Next is pressed", () => { + const { getByTestId, activeSlide } = setup(2); + + act(() => { + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: WIDTH } } + }); + }); + + fireEvent.press(getByTestId("intro$buttonNext")); + + expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3)); + }); + + it("keeps the slide Next moved to when a stale momentum event reports the old offset", () => { + // Reproduces the Android e2e failure: after remounting on slide 2 the widget scrolls + // to slide 3, then the list reports a momentum end for the offset it was leaving. + const { getByTestId, activeSlide, onSlideChange } = setup(2); + + // A drag that settles without a momentum event of its own โ€” the initial positioning + // scroll produces one on device โ€” leaves the widget believing a drag is in progress. + act(() => { + fireEvent(getByTestId("intro"), "scrollBeginDrag", {}); + }); + + fireEvent.press(getByTestId("intro$buttonNext")); + expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(3)); + + act(() => { + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: 0 } } + }); + }); + + // The stale offset must not drag the attribute back to slide 1 or report a change. + expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(3)); + expect(onSlideChange).toHaveBeenCalledTimes(1); + expect(onSlideChange).toHaveBeenCalledWith(2, 1); + + // Slide 3 is the last slide, so the pagination still offers Done rather than + // falling back to the first-slide buttons. + expect(getByTestId("intro$buttonDone")).toBeTruthy(); + + // Previous moves relative to the slide that is actually on screen. + fireEvent.press(getByTestId("intro$buttonPrevious")); + expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(2)); + }); + + it("still reports a user swipe after a programmatic scroll has settled", () => { + const { getByTestId, activeSlide, onSlideChange } = setup(2); + + fireEvent.press(getByTestId("intro$buttonNext")); + onSlideChange.mockClear(); + + // The programmatic scroll to slide 3 lands. + act(() => { + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: 2 * WIDTH } } + }); + }); + + // The user then swipes back to slide 2, which must be reported. + act(() => { + fireEvent(getByTestId("intro"), "scrollBeginDrag", {}); + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: WIDTH } } + }); + }); + + expect(onSlideChange).toHaveBeenCalledWith(1, 2); + expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(2)); + }); + + it("reports a user swipe that interrupts an in-flight programmatic scroll", () => { + const { getByTestId, activeSlide, onSlideChange } = setup(2); + + fireEvent.press(getByTestId("intro$buttonNext")); + onSlideChange.mockClear(); + + // The user grabs the list before the scroll to slide 3 settles and swipes back to + // slide 1. Taking over invalidates the pending target, so this must be reported. + act(() => { + fireEvent(getByTestId("intro"), "scrollBeginDrag", {}); + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: 0 } } + }); + }); + + expect(onSlideChange).toHaveBeenCalledWith(0, 2); + expect(activeSlide.setValue).toHaveBeenLastCalledWith(new Big(1)); + }); + + it("ignores a stale programmatic scroll that reports the pre-scroll offset", () => { + const { getByTestId, activeSlide, onSlideChange } = setup(2); + + // The list reports offset 0 โ€” the position it held before the programmatic scroll to + // slide 2 settled. Trusting it would desync activeIndex from what is on screen. + act(() => { + fireEvent(getByTestId("intro"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: 0 } } + }); + }); + + expect(onSlideChange).not.toHaveBeenCalled(); + + fireEvent.press(getByTestId("intro$buttonNext")); + + expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3)); + }); +}); diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap index 1e0342d71..b7c015e5e 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap @@ -55,6 +55,7 @@ exports[`Intro Screen renders 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -281,6 +282,7 @@ exports[`Intro Screen renders with 1 bottom button 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -521,6 +523,7 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -761,6 +764,7 @@ exports[`Intro Screen renders with active slide attribute 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -987,6 +991,7 @@ exports[`Intro Screen renders with async storage identifier 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap index e923f4fff..58f04048a 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap @@ -55,6 +55,7 @@ exports[`Intro Screen renders 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -281,6 +282,7 @@ exports[`Intro Screen renders with 1 bottom button 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -521,6 +523,7 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -761,6 +764,7 @@ exports[`Intro Screen renders with active slide attribute 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false} @@ -987,6 +991,7 @@ exports[`Intro Screen renders with async storage identifier 1`] = ` onLayout={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} pagingEnabled={true} scrollEventThrottle={50} showsHorizontalScrollIndicator={false}