Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/intro-screen-native/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.4.1] - 2026-6-10

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,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
Expand Down Expand Up @@ -53,6 +59,9 @@ appId: "${APP_ID}"
timeout: 5000
- tapOn:
text: "NEXT"
- extendedWaitUntil:
visible: "Active slide: 3"
timeout: 5000
- tapOn:
text: "FINISH"
- extendedWaitUntil:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "intro-screen-native",
"widgetName": "IntroScreen",
"version": "4.4.1",
"version": "4.4.2",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef, useS
import {
I18nManager,
LayoutChangeEvent,
NativeSyntheticEvent,
Platform,
StyleSheet,
Text,
Expand Down Expand Up @@ -53,8 +52,14 @@ const isAndroidRTL = I18nManager.isRTL && Platform.OS === "android";
const Touchable: React.ComponentType<TouchableProps> =
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<Big>): 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;
Expand All @@ -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<FlashListRef<any>>(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),
Expand All @@ -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
});
Expand All @@ -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]);

Expand Down Expand Up @@ -190,6 +207,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) {
Expand Down Expand Up @@ -315,24 +333,28 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
);
};

const onMomentumScrollEnd = useCallback(
(event: NativeSyntheticEvent<any>) => {
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]
);

/**
Expand All @@ -353,26 +375,41 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
[width, height]
);

const showList = width > 0 && (listMounted.current || !activeSlidePending);

useEffect(() => {
if (showList) {
listMounted.current = true;
}
}, [showList]);

return (
<View style={styles.flexOne}>
<FlashList
testID={props.testID}
initialScrollIndex={refreshActiveSlideAttribute(props.slides, props.activeSlide)}
ref={flashList}
data={props.slides}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
bounces={false}
style={styles.flatList}
renderItem={renderItem}
onMomentumScrollEnd={onMomentumScrollEnd}
scrollEventThrottle={50}
extraData={[width, activeIndex]}
onLayout={onLayout}
keyExtractor={(_: any, index: number) => "screen_key_" + index}
importantForAccessibility="no"
/>
{showList ? (
<FlashList
testID={props.testID}
initialScrollIndex={initialIndex.current}
ref={flashList}
data={props.slides}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
bounces={false}
style={styles.flatList}
renderItem={renderItem}
onScrollBeginDrag={onScrollBeginDrag}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={VIEWABILITY_CONFIG}
scrollEventThrottle={50}
maintainVisibleContentPosition={{ disabled: true }}
extraData={[width, activeIndex]}
onLayout={onLayout}
keyExtractor={(_: any, index: number) => "screen_key_" + index}
importantForAccessibility="no"
/>
) : (
<View testID={props.testID} style={styles.flatList} onLayout={onLayout} />
)}
{renderPagination()}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, act } 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";
Expand All @@ -16,6 +16,12 @@ jest.mock("@react-native-async-storage/async-storage", () => ({
setItem: 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<IntroScreenStyle>;

Expand All @@ -39,18 +45,21 @@ describe("Intro Screen", () => {

it("renders", () => {
const component = render(<IntroScreen {...defaultProps} />);
layout(component, "intro-screen-notch-test");
expect(component.toJSON()).toMatchSnapshot();
});

it("renders with 1 bottom button", () => {
const component = render(
<IntroScreen {...defaultProps} slideIndicators={"above"} buttonPattern={"nextDone"} />
);
layout(component, "intro-screen-notch-test");
expect(component.toJSON()).toMatchSnapshot();
});

it("renders with 2 bottom button", () => {
const component = render(<IntroScreen {...defaultProps} slideIndicators={"above"} buttonPattern={"all"} />);
layout(component, "intro-screen-notch-test");
expect(component.toJSON()).toMatchSnapshot();
});

Expand All @@ -61,13 +70,15 @@ describe("Intro Screen", () => {
activeSlideAttribute={new EditableValueBuilder<Big>().withValue(new Big(1)).build()}
/>
);
layout(component, "intro-screen-notch-test");
expect(component.toJSON()).toMatchSnapshot();
});

it("renders with async storage identifier", async () => {
const component = render(<IntroScreen {...defaultProps} identifier="test1" />);
// Wait for async storage to resolve
await act(async () => {});
layout(component, "intro-screen-notch-test");
expect(component.toJSON()).toMatchSnapshot();
});
});
Loading
Loading