Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IOPLT-226] Migrates ForceScrollDownViewPage from main app codebase #154

Merged
merged 3 commits into from
Nov 28, 2023
Merged
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
10 changes: 10 additions & 0 deletions example/src/navigation/navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -209,6 +210,15 @@ const AppNavigator = () => (
}}
/>

<Stack.Screen
name={APP_ROUTES.COMPONENTS.FORCE_SCROLL_DOWN.route}
component={ForceScrollDownViewPage}
options={{
headerTitle: APP_ROUTES.COMPONENTS.FORCE_SCROLL_DOWN.title,
headerBackTitleVisible: false
}}
/>

<Stack.Screen
name={APP_ROUTES.COMPONENTS.HEADER_FIRST_LEVEL.route}
component={HeaderFirstLevelScreen}
Expand Down
1 change: 1 addition & 0 deletions example/src/navigation/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type AppParamsList = {
[DESIGN_SYSTEM_ROUTES.COMPONENTS.TAB_NAVIGATION.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.TEXT_INPUT.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.FOOTER_WITH_BUTTON.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.FORCE_SCROLL_DOWN.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.HEADER_FIRST_LEVEL.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.HEADER_SECOND_LEVEL.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.HEADER_SECOND_LEVEL_STATIC.route]: undefined;
Expand Down
4 changes: 4 additions & 0 deletions example/src/navigation/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const APP_ROUTES = {
route: "DESIGN_SYSTEM_HEADER_SECOND_LEVEL",
title: "Header Second Level"
},
FORCE_SCROLL_DOWN: {
route: "DESIGN_SYSTEM_FORCE_SCROLL_DOWN",
title: "Force Scroll Down"
},
HEADER_SECOND_LEVEL_STATIC: {
route: "DESIGN_SYSTEM_HEADER_SECOND_LEVEL_STATIC",
title: "Header Second Level Static"
Expand Down
24 changes: 24 additions & 0 deletions example/src/pages/ForceScrollDownViewPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from "react";
import { SafeAreaView } from "react-native";
import {
IOStyles,
ForceScrollDownView,
Body
} from "@pagopa/io-app-design-system";
import { Screen } from "../components/Screen";

/**
* This Screen is used to test components in isolation while developing.
* @returns a screen with a flexed view where you can test components
*/
export const ForceScrollDownViewPage = () => (
<SafeAreaView style={IOStyles.flex}>
<ForceScrollDownView>
<Screen>
{[...Array(50)].map((_el, i) => (
<Body key={`body-${i}`}>Repeated text</Body>
))}
</Screen>
</ForceScrollDownView>
</SafeAreaView>
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions src/components/common/ScaleInOutAnimation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Animated.View
style={style}
entering={enteringAnimation}
exiting={exitingAnimation}
>
{children}
</Animated.View>
);
};

export { ScaleInOutAnimation };
210 changes: 210 additions & 0 deletions src/components/layout/ForceScrollDownView.tsx
Original file line number Diff line number Diff line change
@@ -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, IOVisualCostants } 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<ScrollView>(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<number>();

/**
* The height of the scrollable content, used to determine whether or not the "scroll to bottom" button
* should be displayed.
*/
const [contentHeight, setContentHeight] = useState<number>();

/**
* 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<NativeScrollEvent>) => {
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 = (
<ScaleInOutAnimation
springConfig={IOSpringValues.button}
style={styles.scrollDownButton}
visible={shouldRenderScrollButton}
>
<IconButtonSolid
testID={"ScrollDownButton"}
accessibilityLabel="Scroll to bottom"
icon="arrowBottom"
onPress={handleScrollDownPress}
/>
</ScaleInOutAnimation>
);

return (
<>
<ScrollView
testID={"ScrollView"}
ref={scrollViewRef}
scrollIndicatorInsets={{ right: 1 }}
scrollEnabled={scrollEnabled}
onScroll={handleScroll}
scrollEventThrottle={400}
style={style}
onLayout={handleLayout}
onContentSizeChange={handleContentSizeChange}
contentContainerStyle={contentContainerStyle}
>
{children}
</ScrollView>
{scrollDownButton}
</>
);
};

const styles = StyleSheet.create({
scrollDownButton: {
position: "absolute",
zIndex: 10,
right: IOVisualCostants.scrollDownButtonRight,
bottom: IOVisualCostants.scrollDownButtonBottom
}
});

export { ForceScrollDownView };
Loading