Skip to content

Commit e34f313

Browse files
authored
chore: [IOPLT-989] Slightly improve ForceScrollDownView component (#423)
## Short description This PR slightly improves the `ForceScrollDownView` component. ## List of changes proposed in this pull request - Add the new `footerActions` prop: if set the component will include `FooterActions` - Make `threshold` mandatory to avoid a potential buggy behavior when not properly configured - Improve the `ScaleInOutAnimation` by adding an `opacity` transition. The previous feeling is still conveyed. - Prevent `useFooterActionsMeasurements` potential memory leaks ## How to test Launch the example app and go to the **Force scroll down** page with different devices
1 parent 2114fc1 commit e34f313

7 files changed

+115
-79
lines changed
+21-15
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1+
import { Body, ForceScrollDownView } from "@pagopa/io-app-design-system";
12
import * as React from "react";
2-
import { SafeAreaView } from "react-native";
3-
import {
4-
IOStyles,
5-
ForceScrollDownView,
6-
Body
7-
} from "@pagopa/io-app-design-system";
3+
import { Alert } from "react-native";
84
import { Screen } from "../components/Screen";
95

106
/**
117
* This Screen is used to test components in isolation while developing.
128
* @returns a screen with a flexed view where you can test components
139
*/
1410
export const ForceScrollDownViewPage = () => (
15-
<SafeAreaView style={IOStyles.flex}>
16-
<ForceScrollDownView>
17-
<Screen>
18-
{[...Array(50)].map((_el, i) => (
19-
<Body key={`body-${i}`}>Repeated text</Body>
20-
))}
21-
</Screen>
22-
</ForceScrollDownView>
23-
</SafeAreaView>
11+
<ForceScrollDownView
12+
footerActions={{
13+
actions: {
14+
type: "SingleButton",
15+
primary: {
16+
label: "Continua",
17+
onPress: () => {
18+
Alert.alert("Button pressed");
19+
}
20+
}
21+
}
22+
}}
23+
>
24+
<Screen>
25+
{[...Array(34)].map((_el, i) => (
26+
<Body key={`body-${i}`}>Repeated text</Body>
27+
))}
28+
</Screen>
29+
</ForceScrollDownView>
2430
);

src/components/common/ScaleInOutAnimation.tsx

+8-10
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,32 @@ import { ViewStyle } from "react-native";
44
import Animated, {
55
LayoutAnimation,
66
WithSpringConfig,
7-
withDelay,
8-
withSpring,
9-
withTiming
7+
withSpring
108
} from "react-native-reanimated";
119

1210
type Props = {
1311
visible?: boolean;
1412
springConfig?: WithSpringConfig;
15-
delayOut?: number;
16-
delayIn?: number;
1713
children: React.ReactNode;
1814
style?: ViewStyle;
1915
};
2016

2117
const ScaleInOutAnimation = ({
2218
visible = true,
2319
springConfig = { damping: 500, mass: 3, stiffness: 1000 },
24-
delayOut = 0,
25-
delayIn = 0,
2620
children,
2721
style
2822
}: Props) => {
2923
const enteringAnimation = (): LayoutAnimation => {
3024
"worklet";
3125
return {
3226
initialValues: {
33-
transform: [{ scale: 0 }]
27+
opacity: 0,
28+
transform: [{ scale: 0.5 }]
3429
},
3530
animations: {
36-
transform: [{ scale: withDelay(delayIn, withSpring(1, springConfig)) }]
31+
opacity: withSpring(1, springConfig),
32+
transform: [{ scale: withSpring(1, springConfig) }]
3733
}
3834
};
3935
};
@@ -42,10 +38,12 @@ const ScaleInOutAnimation = ({
4238
"worklet";
4339
return {
4440
initialValues: {
41+
opacity: 1,
4542
transform: [{ scale: 1 }]
4643
},
4744
animations: {
48-
transform: [{ scale: withDelay(delayOut, withTiming(0)) }]
45+
opacity: withSpring(0, springConfig),
46+
transform: [{ scale: withSpring(0.5, springConfig) }]
4947
}
5048
};
5149
};

src/components/layout/ForceScrollDownView.tsx

+67-32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import React, {
2+
ComponentProps,
3+
ReactNode,
24
useCallback,
35
useEffect,
46
useMemo,
@@ -13,29 +15,51 @@ import {
1315
ScrollViewProps,
1416
StyleSheet
1517
} from "react-native";
16-
import { ScaleInOutAnimation } from "../common/ScaleInOutAnimation";
1718
import { IOSpringValues, IOVisualCostants } from "../../core";
1819
import { IconButtonSolid } from "../buttons";
20+
import { ScaleInOutAnimation } from "../common/ScaleInOutAnimation";
21+
import { FooterActions } from "./FooterActions";
22+
import { useFooterActionsInlineMeasurements } from "./hooks";
1923

20-
type ForceScrollDownViewProps = {
24+
type ForceScrollDownViewActions = {
2125
/**
22-
* The content to display inside the scroll view.
26+
* The distance from the bottom is computed automatically based on the actions.
2327
*/
24-
children: React.ReactNode;
28+
threshold?: never;
29+
footerActions: Omit<
30+
ComponentProps<typeof FooterActions>,
31+
"fixed" | "onMeasure"
32+
>;
33+
};
34+
35+
type ForceScrollDownViewCustomSlot = {
2536
/**
2637
* The distance from the bottom of the scrollable content at which the "scroll to bottom" button
27-
* should become hidden. Defaults to 100.
38+
* should become hidden.
2839
*/
29-
threshold?: number;
40+
threshold: number;
41+
footerActions?: never;
42+
};
43+
44+
type ForceScrollDownViewSlot =
45+
| ForceScrollDownViewActions
46+
| ForceScrollDownViewCustomSlot;
47+
48+
export type ForceScrollDownView = {
49+
/**
50+
* The content to display inside the scroll view.
51+
*/
52+
children: ReactNode;
3053
/**
3154
* A callback that will be called whenever the scroll view crosses the threshold. The callback
3255
* is passed a boolean indicating whether the threshold has been crossed (`true`) or not (`false`).
3356
*/
3457
onThresholdCrossed?: (crossed: boolean) => void;
35-
} & Pick<
36-
ScrollViewProps,
37-
"style" | "contentContainerStyle" | "scrollEnabled" | "testID"
38-
>;
58+
} & ForceScrollDownViewSlot &
59+
Pick<
60+
ScrollViewProps,
61+
"style" | "contentContainerStyle" | "scrollEnabled" | "testID"
62+
>;
3963

4064
/**
4165
* A React Native component that displays a scroll view with a button that scrolls to the bottom of the content
@@ -44,26 +68,36 @@ type ForceScrollDownViewProps = {
4468
* `scrollEnabled` prop to `false`.
4569
*/
4670
const ForceScrollDownView = ({
71+
footerActions,
4772
children,
48-
threshold = 100,
73+
threshold: customThreshold,
4974
style,
5075
contentContainerStyle,
5176
scrollEnabled = true,
5277
onThresholdCrossed
53-
}: ForceScrollDownViewProps) => {
78+
}: ForceScrollDownView) => {
5479
const scrollViewRef = useRef<ScrollView>(null);
5580

81+
const {
82+
footerActionsInlineMeasurements,
83+
handleFooterActionsInlineMeasurements
84+
} = useFooterActionsInlineMeasurements();
85+
86+
const threshold = footerActions
87+
? footerActionsInlineMeasurements.safeBottomAreaHeight
88+
: customThreshold;
89+
5690
/**
5791
* The height of the scroll view, used to determine whether or not the scrollable content fits inside
5892
* the scroll view and whether the "scroll to bottom" button should be displayed.
5993
*/
60-
const [scrollViewHeight, setScrollViewHeight] = useState<number>();
94+
const [scrollViewHeight, setScrollViewHeight] = useState<number>(0);
6195

6296
/**
6397
* The height of the scrollable content, used to determine whether or not the "scroll to bottom" button
6498
* should be displayed.
6599
*/
66-
const [contentHeight, setContentHeight] = useState<number>();
100+
const [contentHeight, setContentHeight] = useState<number>(0);
67101

68102
/**
69103
* Whether or not the scroll view has crossed the threshold from the bottom.
@@ -79,7 +113,7 @@ const ForceScrollDownView = ({
79113
/**
80114
* A callback that is called whenever the scroll view is scrolled. It checks whether or not the
81115
* scroll view has crossed the threshold from the bottom and updates the state accordingly.
82-
* The callback is designed to updatr button visibility only when crossing the threshold.
116+
* The callback is designed to update button visibility only when crossing the threshold.
83117
*/
84118
const handleScroll = useCallback(
85119
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -88,19 +122,14 @@ const ForceScrollDownView = ({
88122

89123
const thresholdCrossed =
90124
layoutMeasurement.height + contentOffset.y >=
91-
contentSize.height - threshold;
92-
93-
setThresholdCrossed(previousState => {
94-
if (!previousState && thresholdCrossed) {
95-
setButtonVisible(false);
96-
}
97-
if (previousState && !thresholdCrossed) {
98-
setButtonVisible(true);
99-
}
100-
return thresholdCrossed;
101-
});
125+
contentSize.height - (threshold ?? 0);
126+
127+
if (isThresholdCrossed !== thresholdCrossed) {
128+
setThresholdCrossed(thresholdCrossed);
129+
setButtonVisible(!thresholdCrossed);
130+
}
102131
},
103-
[threshold]
132+
[threshold, isThresholdCrossed]
104133
);
105134

106135
/**
@@ -145,8 +174,8 @@ const ForceScrollDownView = ({
145174
*/
146175
const needsScroll = useMemo(
147176
() =>
148-
scrollViewHeight != null &&
149-
contentHeight != null &&
177+
scrollViewHeight > 0 &&
178+
contentHeight > 0 &&
150179
scrollViewHeight < contentHeight,
151180
[scrollViewHeight, contentHeight]
152181
);
@@ -182,16 +211,22 @@ const ForceScrollDownView = ({
182211
<ScrollView
183212
testID={"ScrollView"}
184213
ref={scrollViewRef}
185-
scrollIndicatorInsets={{ right: 1 }}
186214
scrollEnabled={scrollEnabled}
187-
onScroll={handleScroll}
188-
scrollEventThrottle={400}
189215
style={style}
216+
onScroll={handleScroll}
217+
scrollEventThrottle={8}
190218
onLayout={handleLayout}
191219
onContentSizeChange={handleContentSizeChange}
192220
contentContainerStyle={contentContainerStyle}
193221
>
194222
{children}
223+
{footerActions && (
224+
<FooterActions
225+
{...footerActions}
226+
onMeasure={handleFooterActionsInlineMeasurements}
227+
fixed={false}
228+
/>
229+
)}
195230
</ScrollView>
196231
{scrollDownButton}
197232
</>

src/components/layout/__test__/ForceScrollDownView.test.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe("ForceScrollDownView", () => {
1313
const tChildren = <Text>{tContent}</Text>;
1414

1515
const component = render(
16-
<ForceScrollDownView>{tChildren}</ForceScrollDownView>
16+
<ForceScrollDownView threshold={100}>{tChildren}</ForceScrollDownView>
1717
);
1818

1919
expect(component).toMatchSnapshot();
@@ -23,7 +23,7 @@ describe("ForceScrollDownView", () => {
2323
const tChildren = <Text>{tContent}</Text>;
2424

2525
const { getByText } = render(
26-
<ForceScrollDownView>{tChildren}</ForceScrollDownView>
26+
<ForceScrollDownView threshold={100}>{tChildren}</ForceScrollDownView>
2727
);
2828

2929
expect(getByText(tContent)).toBeDefined();
@@ -35,7 +35,7 @@ describe("ForceScrollDownView", () => {
3535
const tScreenHeight = 1000;
3636

3737
const { getByTestId, queryByTestId } = render(
38-
<ForceScrollDownView>{tChildren}</ForceScrollDownView>
38+
<ForceScrollDownView threshold={100}>{tChildren}</ForceScrollDownView>
3939
);
4040

4141
const scrollView = getByTestId("ScrollView");
@@ -72,7 +72,7 @@ describe("ForceScrollDownView", () => {
7272
const tScreenHeight = 1000;
7373

7474
const { getByTestId, queryByTestId } = render(
75-
<ForceScrollDownView>{tChildren}</ForceScrollDownView>
75+
<ForceScrollDownView threshold={100}>{tChildren}</ForceScrollDownView>
7676
);
7777

7878
const scrollView = getByTestId("ScrollView");

src/components/layout/__test__/__snapshots__/ForceScrollDownView.test.tsx.snap

+1-6
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ exports[`ForceScrollDownView should match snapshot 1`] = `
66
onLayout={[Function]}
77
onScroll={[Function]}
88
scrollEnabled={true}
9-
scrollEventThrottle={400}
10-
scrollIndicatorInsets={
11-
{
12-
"right": 1,
13-
}
14-
}
9+
scrollEventThrottle={8}
1510
testID="ScrollView"
1611
>
1712
<View>

src/components/layout/hooks/useFooterActionsInlineMeasurements.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useCallback, useState } from "react";
22
import { FooterActionsInlineMeasurements } from "../FooterActionsInline";
33

44
type UseFooterActionsInlineMeasurementsProps = {
@@ -25,11 +25,12 @@ export const useFooterActionsInlineMeasurements =
2525
safeBottomAreaHeight: 0
2626
});
2727

28-
const handleFooterActionsInlineMeasurements = (
29-
values: FooterActionsInlineMeasurements
30-
) => {
31-
setFooterActionsInlineMeasurements(values);
32-
};
28+
const handleFooterActionsInlineMeasurements = useCallback(
29+
(values: FooterActionsInlineMeasurements) => {
30+
setFooterActionsInlineMeasurements(values);
31+
},
32+
[]
33+
);
3334

3435
return {
3536
footerActionsInlineMeasurements,

src/components/layout/hooks/useFooterActionsMeasurements.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useCallback, useState } from "react";
22
import { FooterActionsMeasurements } from "../FooterActions";
33

44
type UseFooterActionsMeasurementsProps = {
@@ -22,11 +22,12 @@ export const useFooterActionsMeasurements =
2222
safeBottomAreaHeight: 0
2323
});
2424

25-
const handleFooterActionsMeasurements = (
26-
values: FooterActionsMeasurements
27-
) => {
28-
setFooterActionsMeasurements(values);
29-
};
25+
const handleFooterActionsMeasurements = useCallback(
26+
(values: FooterActionsMeasurements) => {
27+
setFooterActionsMeasurements(values);
28+
},
29+
[]
30+
);
3031

3132
return {
3233
footerActionsMeasurements,

0 commit comments

Comments
 (0)