From cc740af24da8a48906ee4c657e8ea9daed0ad198 Mon Sep 17 00:00:00 2001 From: Cristiano Tofani Date: Tue, 28 Nov 2023 16:12:05 +0100 Subject: [PATCH 1/2] Migrates `ForceScrollDownViewPage` from main app codebase --- example/src/navigation/navigator.tsx | 10 + example/src/navigation/params.ts | 1 + example/src/navigation/routes.ts | 4 + example/src/pages/ForceScrollDownViewPage.tsx | 24 ++ package.json | 1 + src/components/common/ScaleInOutAnimation.tsx | 68 ++++++ src/components/layout/ForceScrollDownView.tsx | 210 ++++++++++++++++++ .../__test__/ForceScrollDownView.test.tsx | 106 +++++++++ .../ForceScrollDownView.test.tsx.snap | 23 ++ src/components/layout/index.tsx | 1 + yarn.lock | 38 ++++ 11 files changed, 486 insertions(+) create mode 100644 example/src/pages/ForceScrollDownViewPage.tsx create mode 100644 src/components/common/ScaleInOutAnimation.tsx create mode 100644 src/components/layout/ForceScrollDownView.tsx create mode 100644 src/components/layout/__test__/ForceScrollDownView.test.tsx create mode 100644 src/components/layout/__test__/__snapshots__/ForceScrollDownView.test.tsx.snap diff --git a/example/src/navigation/navigator.tsx b/example/src/navigation/navigator.tsx index 8b1853d5..75d3466f 100644 --- a/example/src/navigation/navigator.tsx +++ b/example/src/navigation/navigator.tsx @@ -27,6 +27,7 @@ import { HeaderFirstLevelScreen } from "../pages/HeaderFirstLevel"; import { NumberPadScreen } from "../pages/NumberPad"; import { StepperPage } from "../pages/Stepper"; import { HeaderSecondLevelWithStepper } from "../pages/HeaderSecondLevelWithStepper"; +import { ForceScrollDownViewPage } from "../pages/ForceScrollDownViewPage"; import { AppParamsList } from "./params"; import APP_ROUTES from "./routes"; @@ -209,6 +210,15 @@ const AppNavigator = () => ( }} /> + + ( + + + + {[...Array(50)].map((_el, i) => ( + Repeated text + ))} + + + +); diff --git a/package.json b/package.json index 2ecb3b59..c88f90b6 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", + "@testing-library/react-native": "^12.4.0", "@types/jest": "^28.1.2", "@types/lodash": "^4.14.157", "@types/react": "~17.0.38", diff --git a/src/components/common/ScaleInOutAnimation.tsx b/src/components/common/ScaleInOutAnimation.tsx new file mode 100644 index 00000000..97add186 --- /dev/null +++ b/src/components/common/ScaleInOutAnimation.tsx @@ -0,0 +1,68 @@ +/* eslint-disable functional/immutable-data */ +import React from "react"; +import { ViewStyle } from "react-native"; +import Animated, { + LayoutAnimation, + WithSpringConfig, + withDelay, + withSpring, + withTiming +} from "react-native-reanimated"; + +type Props = { + visible?: boolean; + springConfig?: WithSpringConfig; + delayOut?: number; + delayIn?: number; + children: React.ReactNode; + style?: ViewStyle; +}; + +const ScaleInOutAnimation = ({ + visible = true, + springConfig = { damping: 500, mass: 3, stiffness: 1000 }, + delayOut = 0, + delayIn = 0, + children, + style +}: Props) => { + const enteringAnimation = (): LayoutAnimation => { + "worklet"; + return { + initialValues: { + transform: [{ scale: 0 }] + }, + animations: { + transform: [{ scale: withDelay(delayIn, withSpring(1, springConfig)) }] + } + }; + }; + + const exitingAnimation = (): LayoutAnimation => { + "worklet"; + return { + initialValues: { + transform: [{ scale: 1 }] + }, + animations: { + transform: [{ scale: withDelay(delayOut, withTiming(0)) }] + } + }; + }; + + if (!visible) { + return null; + } + + return ( + + {children} + + ); +}; + +export { ScaleInOutAnimation }; diff --git a/src/components/layout/ForceScrollDownView.tsx b/src/components/layout/ForceScrollDownView.tsx new file mode 100644 index 00000000..bff6a282 --- /dev/null +++ b/src/components/layout/ForceScrollDownView.tsx @@ -0,0 +1,210 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + ScrollViewProps, + StyleSheet +} from "react-native"; +import { ScaleInOutAnimation } from "../common/ScaleInOutAnimation"; +import { IOSpringValues } from "../../core"; +import { IconButtonSolid } from "../buttons"; + +type ForceScrollDownViewProps = { + /** + * The content to display inside the scroll view. + */ + children: React.ReactNode; + /** + * The distance from the bottom of the scrollable content at which the "scroll to bottom" button + * should become hidden. Defaults to 100. + */ + threshold?: number; + /** + * A callback that will be called whenever the scroll view crosses the threshold. The callback + * is passed a boolean indicating whether the threshold has been crossed (`true`) or not (`false`). + */ + onThresholdCrossed?: (crossed: boolean) => void; +} & Pick< + ScrollViewProps, + "style" | "contentContainerStyle" | "scrollEnabled" | "testID" +>; + +/** + * A React Native component that displays a scroll view with a button that scrolls to the bottom of the content + * when pressed. The button is hidden when the scroll view reaches a certain threshold from the bottom, which is + * configurable by the `threshold` prop. The button, and the scrolling, can also be disabled by setting the + * `scrollEnabled` prop to `false`. + */ +const ForceScrollDownView = ({ + children, + threshold = 100, + style, + contentContainerStyle, + scrollEnabled = true, + onThresholdCrossed +}: ForceScrollDownViewProps) => { + const scrollViewRef = useRef(null); + + /** + * The height of the scroll view, used to determine whether or not the scrollable content fits inside + * the scroll view and whether the "scroll to bottom" button should be displayed. + */ + const [scrollViewHeight, setScrollViewHeight] = useState(); + + /** + * The height of the scrollable content, used to determine whether or not the "scroll to bottom" button + * should be displayed. + */ + const [contentHeight, setContentHeight] = useState(); + + /** + * Whether or not the scroll view has crossed the threshold from the bottom. + */ + const [isThresholdCrossed, setThresholdCrossed] = useState(false); + + /** + * Whether or not the "scroll to bottom" button should be visible. This is controlled by the threshold + * and the current scroll position. + */ + const [isButtonVisible, setButtonVisible] = useState(true); + + /** + * A callback that is called whenever the scroll view is scrolled. It checks whether or not the + * scroll view has crossed the threshold from the bottom and updates the state accordingly. + * The callback is designed to updatr button visibility only when crossing the threshold. + */ + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { layoutMeasurement, contentOffset, contentSize } = + event.nativeEvent; + + const thresholdCrossed = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - threshold; + + setThresholdCrossed(previousState => { + if (!previousState && thresholdCrossed) { + setButtonVisible(false); + } + if (previousState && !thresholdCrossed) { + setButtonVisible(true); + } + return thresholdCrossed; + }); + }, + [threshold] + ); + + /** + * A side effect that calls the `onThresholdCrossed` callback whenever the value of `isThresholdCrossed` changes. + */ + useEffect(() => { + onThresholdCrossed?.(isThresholdCrossed); + }, [onThresholdCrossed, isThresholdCrossed]); + + /** + * A callback that is called whenever the size of the scrollable content changes. It updates the + * state with the new content height. + */ + const handleContentSizeChange = useCallback( + (_contentWidth: number, contentHeight: number) => { + setContentHeight(contentHeight); + }, + [] + ); + + /** + * A callback that is called whenever the size of the scroll view changes. It updates the state + * with the new scroll view height. + */ + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setScrollViewHeight(event.nativeEvent.layout.height); + }, []); + + /** + * A callback that is called when the "scroll to bottom" button is pressed. It scrolls the + * scroll view to the bottom and hides the button. + */ + const handleScrollDownPress = useCallback(() => { + setButtonVisible(false); + scrollViewRef.current?.scrollToEnd(); + }, [scrollViewRef]); + + /** + * Whether or not the "scroll to bottom" button needs to be displayed. It is only displayed + * when the scrollable content cannot fit inside the scroll view and the button is enabled + * (`scrollEnabled` is `true`). + */ + const needsScroll = useMemo( + () => + scrollViewHeight != null && + contentHeight != null && + scrollViewHeight < contentHeight, + [scrollViewHeight, contentHeight] + ); + + /** + * Whether or not to render the "scroll to bottom" button. It is only rendered when the scroll view + * is enabled, needs to be scrolled, and the button is visible (`isButtonVisible` is `true`). + */ + const shouldRenderScrollButton = + scrollEnabled && needsScroll && isButtonVisible; + + /** + * The "scroll to bottom" button component. It is wrapped in a reanimated view and has enter and exit + * animations applied to it. + */ + const scrollDownButton = ( + + + + ); + + return ( + <> + + {children} + + {scrollDownButton} + + ); +}; + +const styles = StyleSheet.create({ + scrollDownButton: { + position: "absolute", + zIndex: 10, + right: 20, + bottom: 50 + } +}); + +export { ForceScrollDownView }; diff --git a/src/components/layout/__test__/ForceScrollDownView.test.tsx b/src/components/layout/__test__/ForceScrollDownView.test.tsx new file mode 100644 index 00000000..667f6cc6 --- /dev/null +++ b/src/components/layout/__test__/ForceScrollDownView.test.tsx @@ -0,0 +1,106 @@ +/* eslint-disable functional/immutable-data */ +import { fireEvent, render } from "@testing-library/react-native"; +import React from "react"; +import { Text } from "react-native"; +import { ForceScrollDownView } from "../ForceScrollDownView"; + +const tContent = "Some content"; + +describe("ForceScrollDownView", () => { + jest.useFakeTimers(); + + it("should match snapshot", () => { + const tChildren = {tContent}; + + const component = render( + {tChildren} + ); + + expect(component).toMatchSnapshot(); + }); + + it("renders the content correctly", () => { + const tChildren = {tContent}; + + const { getByText } = render( + {tChildren} + ); + + expect(getByText(tContent)).toBeDefined(); + }); + + it("displays the scroll down button when necessary", async () => { + const tChildren = {tContent}; + + const tScreenHeight = 1000; + + const { getByTestId, queryByTestId } = render( + {tChildren} + ); + + const scrollView = getByTestId("ScrollView"); + + // Update scroll view height + fireEvent(scrollView, "layout", { + nativeEvent: { + layout: { + height: tScreenHeight + } + } + }); + + // Update scroll view content height + fireEvent(scrollView, "contentSizeChange", null, tScreenHeight - 500); + + // Button should not be visible because content does not need scrolling + const buttonBefore = queryByTestId("ScrollDownButton"); + expect(buttonBefore).toBeNull(); + + // Increase content height to force button to be shown + fireEvent(scrollView, "contentSizeChange", null, tScreenHeight + 500); + + jest.advanceTimersByTime(500); + + // Button should be visible now beacuse content needs scrolling + const buttonAfter = queryByTestId("ScrollDownButton"); + expect(buttonAfter).not.toBeNull(); + }); + + it("scrolls to the bottom when the button is pressed", () => { + const tChildren = {tContent}; + + const tScreenHeight = 1000; + + const { getByTestId, queryByTestId } = render( + {tChildren} + ); + + const scrollView = getByTestId("ScrollView"); + + // Update scroll view height + fireEvent(scrollView, "layout", { + nativeEvent: { + layout: { + height: tScreenHeight + } + } + }); + + // Update scroll view content height + fireEvent(scrollView, "contentSizeChange", null, tScreenHeight + 500); + + // Button should be visible + const buttonBefore = getByTestId("ScrollDownButton"); + expect(buttonBefore).not.toBeNull(); + + // Fire button press event + fireEvent.press(buttonBefore); + + // Wait for the scroll animation + jest.advanceTimersByTime(500); + + // Button should not be visible after scrolling + const buttonAfter = queryByTestId("ScrollDownButton"); + expect(buttonAfter).toBeNull(); + }); +}); diff --git a/src/components/layout/__test__/__snapshots__/ForceScrollDownView.test.tsx.snap b/src/components/layout/__test__/__snapshots__/ForceScrollDownView.test.tsx.snap new file mode 100644 index 00000000..63466b67 --- /dev/null +++ b/src/components/layout/__test__/__snapshots__/ForceScrollDownView.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ForceScrollDownView should match snapshot 1`] = ` + + + + Some content + + + +`; diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index fe5136fc..3647175b 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -3,5 +3,6 @@ export * from "./GradientScrollView"; export * from "./GradientBottomActions"; export * from "./HeaderFirstLevel"; export * from "./HeaderSecondLevel"; +export * from "./ForceScrollDownView"; export * from "./FooterWithButtons"; export * from "./BlockButtons"; diff --git a/yarn.lock b/yarn.lock index 24bbc6a5..e6079200 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3981,6 +3981,15 @@ pretty-format "^29.0.3" redent "^3.0.0" +"@testing-library/react-native@^12.4.0": + version "12.4.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-12.4.0.tgz#ebc71b15256753006c123dd574064cb10dc88ed7" + integrity sha512-FPc/0LPL+xoYxt10IUyYv19I9BRL6FtZ8EGzOvlyT7wXXARzryASQmz4pj2ZBtp5Xn43bYzpbNZO8kIWNv0rLA== + dependencies: + jest-matcher-utils "^29.7.0" + pretty-format "^29.7.0" + redent "^3.0.0" + "@testing-library/user-event@^14.0.0": version "14.4.3" resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" @@ -9929,6 +9938,16 @@ jest-diff@^29.0.1, jest-diff@^29.6.4: jest-get-type "^29.6.3" pretty-format "^29.6.3" +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-docblock@^29.6.3: version "29.6.3" resolved "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.6.3.tgz#293dca5188846c9f7c0c2b1bb33e5b11f21645f2" @@ -10021,6 +10040,16 @@ jest-matcher-utils@^29.0.1, jest-matcher-utils@^29.6.4: jest-get-type "^29.6.3" pretty-format "^29.6.3" +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-message-util@^28.1.3: version "28.1.3" resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz#232def7f2e333f1eecc90649b5b94b0055e7c43d" @@ -12586,6 +12615,15 @@ pretty-format@^29.0.3, pretty-format@^29.6.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" From 0946240b39d40fb78af563dfa13efaa308dbc981 Mon Sep 17 00:00:00 2001 From: Cristiano Tofani Date: Tue, 28 Nov 2023 17:36:22 +0100 Subject: [PATCH 2/2] Adds ScrollDownButton visual costant --- src/components/layout/ForceScrollDownView.tsx | 6 +++--- src/core/IOStyles.ts | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/layout/ForceScrollDownView.tsx b/src/components/layout/ForceScrollDownView.tsx index bff6a282..63d27be9 100644 --- a/src/components/layout/ForceScrollDownView.tsx +++ b/src/components/layout/ForceScrollDownView.tsx @@ -14,7 +14,7 @@ import { StyleSheet } from "react-native"; import { ScaleInOutAnimation } from "../common/ScaleInOutAnimation"; -import { IOSpringValues } from "../../core"; +import { IOSpringValues, IOVisualCostants } from "../../core"; import { IconButtonSolid } from "../buttons"; type ForceScrollDownViewProps = { @@ -202,8 +202,8 @@ const styles = StyleSheet.create({ scrollDownButton: { position: "absolute", zIndex: 10, - right: 20, - bottom: 50 + right: IOVisualCostants.scrollDownButtonRight, + bottom: IOVisualCostants.scrollDownButtonBottom } }); diff --git a/src/core/IOStyles.ts b/src/core/IOStyles.ts index 19a0b499..88933f8d 100644 --- a/src/core/IOStyles.ts +++ b/src/core/IOStyles.ts @@ -22,6 +22,8 @@ interface IOVisualCostants { avatarSizeSmall: number; avatarSizeMedium: number; iconContainedSizeDefault: number; + scrollDownButtonRight: number; + scrollDownButtonBottom: number; } export const IOVisualCostants: IOVisualCostants = { @@ -29,7 +31,9 @@ export const IOVisualCostants: IOVisualCostants = { headerHeight: 56, avatarSizeSmall: 44, avatarSizeMedium: 66, - iconContainedSizeDefault: 44 + iconContainedSizeDefault: 44, + scrollDownButtonRight: 24, + scrollDownButtonBottom: 24 }; export const IOStyles = StyleSheet.create({