Skip to content

Commit

Permalink
chore(Cross): [IOAPPX-283] Add IOScrollView (next iteration of `Gra…
Browse files Browse the repository at this point in the history
…dientScroll`, now deprecated) + `IOScrollViewWithLargeHeader` (#5704)

> [!note]
> This PR is the result of the discussion we had in the PR below:
> * pagopa/io-app-design-system#243

## Short description
This PR add the new `IOScrollView`, next iteration of the
`GradientScrollView` (now officially deprecated):
- Simpler UI logic without unnecessary calculations
- Option to add a tertiary button
- Dark mode support

It also adds the new `IOScrollViewWithLargeHeader` screen component that
should replace `RNavScreenWithLargeHeader`.

## List of changes proposed in this pull request
- Add the new `IOScrollView` screen component. Compared to the previous
`GradientScrollView`:
  - Remove `GradientBottomActions` because it hasn't added any advantage
  - Add the new `snapOffset` prop to optionally configure the snap point
  - Add optional `headerConfig` prop
- Add optional `includeContentMargins` boolean value to remove content
margins if needed
  - Manage everything inside `GradientScrollView` (now `IOScrollView`)
- Refactor action related props by adding a single `actions` config
object
  - Change opacity transition from discrete to continuous
- Apply the opacity transition just to the gradient, excluding safe
background block
- Improve `debugMode` so you can see the gradient area even when the end
is reached

- Add the new `IOScrollViewWithLargeHeader` screen component. Compared
to the previous `RNavScreenWithLargeHeader`:
- Remove `fixedBottomSlot` because we already manage the actions at the
bottom with the `IOScrollView` component
  - Simpler inner component logic

### Preview
| Three buttons | Debug section |
|--------|--------|
| <video
src="https://github.com/pagopa/io-app-design-system/assets/1255491/9f9e0760-87d6-43ca-bc2c-2e3fd7f117b8">
| <video
src="https://github.com/pagopa/io-app/assets/1255491/930661d8-b205-4818-89c5-dd742e3f4e78">
|

| New opacity transition | Dark mode |
|--------|--------|
| <video
src="https://github.com/pagopa/io-app-design-system/assets/1255491/ba5c0759-cc33-42fc-ac2f-26ffdd3d4293">
| <video
src="https://github.com/pagopa/io-app-design-system/assets/1255491/f7d8cb29-d0a4-42b1-b7df-ed3efe4e8494">
|

As you can see from the video above, in the most extreme case you can
see all kinds of buttons.

## How to test
1. Launch the app in the local environment
2. Go to the **Design System → Screens → IOScrollView…** screens

---------

Co-authored-by: Cristiano Tofani <[email protected]>
Co-authored-by: Alice Di Rico <[email protected]>
Co-authored-by: Mario Perrotta <[email protected]>
  • Loading branch information
4 people authored May 17, 2024
1 parent cb333d9 commit b4f2762
Show file tree
Hide file tree
Showing 14 changed files with 651 additions and 19 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
"dependencies": {
"@babel/plugin-transform-regenerator": "^7.18.6",
"@gorhom/bottom-sheet": "^4.1.5",
"@pagopa/io-app-design-system": "1.36.11",
"@pagopa/io-app-design-system": "1.36.14",
"@pagopa/io-pagopa-commons": "^3.1.0",
"@pagopa/io-react-native-crypto": "^0.3.0",
"@pagopa/io-react-native-http-client": "^0.1.3",
Expand Down Expand Up @@ -149,6 +149,7 @@
"react-native-crypto": "^2.1.0",
"react-native-device-info": "^10.8.0",
"react-native-document-picker": "^9.1.1",
"react-native-easing-gradient": "^1.1.1",
"react-native-exception-handler": "^2.10.8",
"react-native-fingerprint-scanner": "^6.0.0",
"react-native-flag-secure-android": "^1.0.3",
Expand Down
370 changes: 370 additions & 0 deletions ts/components/ui/IOScrollView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,370 @@
import {
ButtonLink,
ButtonOutline,
ButtonSolid,
HeaderSecondLevel,
IOColors,
IOSpacer,
IOSpacingScale,
IOVisualCostants,
VSpacer,
hexToRgba,
useIOTheme
} from "@pagopa/io-app-design-system";
import * as React from "react";
import {
ComponentProps,
Fragment,
PropsWithChildren,
useLayoutEffect,
useMemo,
useState
} from "react";
import {
ColorValue,
LayoutChangeEvent,
LayoutRectangle,
StyleSheet,
View
} from "react-native";
import { easeGradient } from "react-native-easing-gradient";
import LinearGradient from "react-native-linear-gradient";
import Animated, {
Easing,
Extrapolate,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { WithTestID } from "../../types/WithTestID";

type IOScrollViewActions =
| {
type: "SingleButton";
primary: Omit<ComponentProps<typeof ButtonSolid>, "fullWidth">;
secondary?: never;
tertiary?: never;
}
| {
type: "TwoButtons";
primary: Omit<ComponentProps<typeof ButtonSolid>, "fullWidth">;
secondary: ComponentProps<typeof ButtonLink>;
tertiary?: never;
}
| {
type: "ThreeButtons";
primary: Omit<ComponentProps<typeof ButtonSolid>, "fullWidth">;
secondary: Omit<ComponentProps<typeof ButtonOutline>, "fullWidth">;
tertiary: ComponentProps<typeof ButtonLink>;
};

type IOSCrollViewHeaderScrollValues = ComponentProps<
typeof HeaderSecondLevel
>["scrollValues"];

type IOScrollView = WithTestID<
PropsWithChildren<{
headerConfig?: ComponentProps<typeof HeaderSecondLevel>;
actions?: IOScrollViewActions;
debugMode?: boolean;
snapOffset?: number;
/* Don't include safe area insets */
excludeSafeAreaMargins?: boolean;
/* Include page margins */
includeContentMargins?: boolean;
}>
>;

/* Percentage of scrolled content that triggers
the gradient opaciy transition */
const gradientOpacityScrollTrigger = 0.85;
/* Extended gradient area above the actions */
const gradientSafeAreaHeight: IOSpacingScale = 96;
/* End content margin before the actions */
const contentEndMargin: IOSpacingScale = 32;
/* Margin between ButtonSolid and ButtonOutline */
const spaceBetweenActions: IOSpacer = 16;
/* Margin between ButtonSolid and ButtonLink */
const spaceBetweenActionAndLink: IOSpacer = 16;
/* Extra bottom margin for iPhone bottom handle because
ButtonLink doesn't have a fixed height */
const extraSafeAreaMargin: IOSpacingScale = 8;

const styles = StyleSheet.create({
gradientBottomActions: {
width: "100%",
position: "absolute",
bottom: 0,
justifyContent: "flex-end"
},
gradientContainer: {
...StyleSheet.absoluteFillObject
},
buttonContainer: {
paddingHorizontal: IOVisualCostants.appMarginDefault,
width: "100%",
flexShrink: 0
}
});

export const IOScrollView = ({
headerConfig,
children,
actions,
snapOffset,
excludeSafeAreaMargins = false,
includeContentMargins = true,
debugMode = false,
testID
}: IOScrollView) => {
const theme = useIOTheme();

const type = actions?.type;
const primaryAction = actions?.primary;
const secondaryAction = actions?.secondary;
const tertiaryAction = actions?.tertiary;

/* Navigation */
const navigation = useNavigation();

/* Shared Values for `reanimated` */
const scrollPositionAbsolute =
useSharedValue(0); /* Scroll position (Absolute) */
const scrollPositionPercentage =
useSharedValue(0); /* Scroll position (Relative) */

/* Total height of actions */
const [actionBlockHeight, setActionBlockHeight] =
useState<LayoutRectangle["height"]>(0);

const getActionBlockHeight = (event: LayoutChangeEvent) => {
setActionBlockHeight(event.nativeEvent.layout.height);
};

const insets = useSafeAreaInsets();
const needSafeAreaMargin = useMemo(() => insets.bottom !== 0, [insets]);
const safeAreaMargin = useMemo(() => insets.bottom, [insets]);

/* Check if the iPhone bottom handle is present.
If not, or if you don't need safe area insets,
add a default margin to prevent the button
from sticking to the bottom. */
const bottomMargin: number = useMemo(
() =>
!needSafeAreaMargin || excludeSafeAreaMargins
? IOVisualCostants.appMarginDefault
: safeAreaMargin,
[needSafeAreaMargin, excludeSafeAreaMargins, safeAreaMargin]
);

/* GENERATE EASING GRADIENT
Background color should be app main background
(both light and dark themes) */
const HEADER_BG_COLOR: ColorValue = IOColors[theme["appBackground-primary"]];

const { colors, locations } = easeGradient({
colorStops: {
0: { color: hexToRgba(HEADER_BG_COLOR, 0) },
1: { color: HEADER_BG_COLOR }
},
easing: Easing.ease,
extraColorStopsPerTransition: 20
});

/* When the secondary action is visible, add extra margin
to avoid little space from iPhone bottom handle */
const extraBottomMargin: number = useMemo(
() => (secondaryAction && needSafeAreaMargin ? extraSafeAreaMargin : 0),
[needSafeAreaMargin, secondaryAction]
);

/* Safe background block. Cover at least 85% of the space
to avoid glitchy elements underneath */
const safeBackgroundBlockHeight: number = useMemo(
() => (bottomMargin + actionBlockHeight) * 0.85,
[actionBlockHeight, bottomMargin]
);

/* Total height of "Actions + Gradient" area */
const gradientAreaHeight: number = useMemo(
() => bottomMargin + actionBlockHeight + gradientSafeAreaHeight,
[actionBlockHeight, bottomMargin]
);

/* Height of the safe bottom area, applied to the ScrollView:
Actions + Content end margin */
const safeBottomAreaHeight: number = useMemo(
() => bottomMargin + actionBlockHeight + contentEndMargin,
[actionBlockHeight, bottomMargin]
);

const handleScroll = useAnimatedScrollHandler(
({ contentOffset, layoutMeasurement, contentSize }) => {
const scrollPosition = contentOffset.y;
const maxScrollHeight = contentSize.height - layoutMeasurement.height;
const scrollPercentage = scrollPosition / maxScrollHeight;

// eslint-disable-next-line functional/immutable-data
scrollPositionAbsolute.value = scrollPosition;
// eslint-disable-next-line functional/immutable-data
scrollPositionPercentage.value = scrollPercentage;
}
);

const opacityTransition = useAnimatedStyle(() => ({
opacity: interpolate(
scrollPositionPercentage.value,
[0, gradientOpacityScrollTrigger, 1],
[1, 1, 0],
Extrapolate.CLAMP
)
}));

/* Set custom header with `react-navigation` library using
`useLayoutEffect` hook */

const scrollValues: IOSCrollViewHeaderScrollValues = useMemo(
() => ({
contentOffsetY: scrollPositionAbsolute,
triggerOffset: snapOffset || 0
}),
[scrollPositionAbsolute, snapOffset]
);

useLayoutEffect(() => {
if (headerConfig) {
navigation.setOptions({
header: () => (
<HeaderSecondLevel {...headerConfig} scrollValues={scrollValues} />
),
headerTransparent: headerConfig.transparent
});
}
}, [headerConfig, navigation, scrollValues]);

return (
<Fragment>
<Animated.ScrollView
testID={testID}
onScroll={handleScroll}
scrollEventThrottle={8}
snapToOffsets={[0, snapOffset || 0]}
snapToEnd={false}
decelerationRate="normal"
contentContainerStyle={{
paddingBottom: actions
? safeBottomAreaHeight
: bottomMargin + contentEndMargin,
paddingHorizontal: includeContentMargins
? IOVisualCostants.appMarginDefault
: 0
}}
>
{children}
</Animated.ScrollView>
{actions && (
<View
style={[
styles.gradientBottomActions,
{
height: gradientAreaHeight,
paddingBottom: bottomMargin
}
]}
testID={testID}
pointerEvents="box-none"
>
<Animated.View
style={[
styles.gradientContainer,
debugMode && {
backgroundColor: hexToRgba(IOColors["error-500"], 0.15)
}
]}
pointerEvents="none"
>
<Animated.View
style={[
opacityTransition,
debugMode && {
borderTopColor: IOColors["error-500"],
borderTopWidth: 1,
backgroundColor: hexToRgba(IOColors["error-500"], 0.4)
}
]}
>
<LinearGradient
style={{
height: gradientAreaHeight - safeBackgroundBlockHeight
}}
locations={locations}
colors={colors}
/>
</Animated.View>

{/* Safe background block. It's added because when you swipe up
quickly, the content below is visible for about 100ms. Without this
block, the content appears glitchy. */}
<View
style={{
bottom: 0,
height: safeBackgroundBlockHeight,
backgroundColor: HEADER_BG_COLOR
}}
/>
</Animated.View>

<View
style={styles.buttonContainer}
onLayout={getActionBlockHeight}
pointerEvents="box-none"
>
{primaryAction && <ButtonSolid fullWidth {...primaryAction} />}

{type === "TwoButtons" && (
<View
style={{
alignSelf: "center",
marginBottom: extraBottomMargin
}}
>
<VSpacer size={spaceBetweenActionAndLink} />
{secondaryAction && (
<ButtonLink
{...(secondaryAction as ComponentProps<typeof ButtonLink>)}
/>
)}
</View>
)}

{type === "ThreeButtons" && (
<Fragment>
{secondaryAction && (
<Fragment>
<VSpacer size={spaceBetweenActions} />
<ButtonOutline fullWidth {...secondaryAction} />
</Fragment>
)}

{tertiaryAction && (
<View
style={{
alignSelf: "center",
marginBottom: extraBottomMargin
}}
>
<VSpacer size={spaceBetweenActionAndLink} />
<ButtonLink {...tertiaryAction} />
</View>
)}
</Fragment>
)}
</View>
</View>
)}
</Fragment>
);
};
Loading

0 comments on commit b4f2762

Please sign in to comment.