diff --git a/package.json b/package.json index 862f6a034b2..83f8f144b9c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/ts/components/ui/IOScrollView.tsx b/ts/components/ui/IOScrollView.tsx new file mode 100644 index 00000000000..caa373846e9 --- /dev/null +++ b/ts/components/ui/IOScrollView.tsx @@ -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, "fullWidth">; + secondary?: never; + tertiary?: never; + } + | { + type: "TwoButtons"; + primary: Omit, "fullWidth">; + secondary: ComponentProps; + tertiary?: never; + } + | { + type: "ThreeButtons"; + primary: Omit, "fullWidth">; + secondary: Omit, "fullWidth">; + tertiary: ComponentProps; + }; + +type IOSCrollViewHeaderScrollValues = ComponentProps< + typeof HeaderSecondLevel +>["scrollValues"]; + +type IOScrollView = WithTestID< + PropsWithChildren<{ + headerConfig?: ComponentProps; + 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(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: () => ( + + ), + headerTransparent: headerConfig.transparent + }); + } + }, [headerConfig, navigation, scrollValues]); + + return ( + + + {children} + + {actions && ( + + + + + + + {/* 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. */} + + + + + {primaryAction && } + + {type === "TwoButtons" && ( + + + {secondaryAction && ( + )} + /> + )} + + )} + + {type === "ThreeButtons" && ( + + {secondaryAction && ( + + + + + )} + + {tertiaryAction && ( + + + + + )} + + )} + + + )} + + ); +}; diff --git a/ts/components/ui/IOScrollViewWithLargeHeader.tsx b/ts/components/ui/IOScrollViewWithLargeHeader.tsx new file mode 100644 index 00000000000..c6fb20909cb --- /dev/null +++ b/ts/components/ui/IOScrollViewWithLargeHeader.tsx @@ -0,0 +1,110 @@ +import { + Body, + ContentWrapper, + H2, + HeaderSecondLevel, + IOStyles, + VSpacer +} from "@pagopa/io-app-design-system"; +import { useNavigation } from "@react-navigation/native"; +import React, { ComponentProps, useState } from "react"; +import { LayoutChangeEvent, View } from "react-native"; +import { + BackProps, + HeaderActionProps, + useHeaderProps +} from "../../hooks/useHeaderProps"; +import { SupportRequestParams } from "../../hooks/useStartSupportRequest"; +import I18n from "../../i18n"; +import { IOScrollView } from "./IOScrollView"; + +export type LargeHeaderTitleProps = { + label: string; + accessibilityLabel?: string; + testID?: string; +}; + +type Props = { + children: React.ReactNode; + actions?: ComponentProps["actions"]; + title: LargeHeaderTitleProps; + description?: string; + goBack?: BackProps["goBack"]; + headerActionsProp?: HeaderActionProps; + canGoback?: boolean; +} & SupportRequestParams; + +/** + * Special `IOScrollView` screen with a large title that is hidden by a transition when + * the user scrolls. It also handles the contextual help and the FAQ. + * Use of LargeHeader naming is due to similar behavior offered by the native iOS API. + */ +export const IOScrollViewWithLargeHeader = ({ + children, + title, + description, + actions, + goBack, + canGoback = true, + contextualHelp, + contextualHelpMarkdown, + faqCategories, + headerActionsProp = {} +}: Props) => { + const [titleHeight, setTitleHeight] = useState(0); + + const navigation = useNavigation(); + + const getTitleHeight = (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setTitleHeight(height); + }; + + const headerPropsWithoutGoBack = { + title: title.label, + contextualHelp, + contextualHelpMarkdown, + faqCategories, + ...headerActionsProp + }; + + const headerProps: ComponentProps = useHeaderProps( + canGoback + ? { + ...headerPropsWithoutGoBack, + backAccessibilityLabel: I18n.t("global.buttons.back"), + goBack: goBack ?? navigation.goBack + } + : headerPropsWithoutGoBack + ); + + return ( + + +

+ {title.label} +

+
+ + {description && ( + + + {description} + + )} + + + + {children} +
+ ); +}; diff --git a/ts/components/ui/RNavScreenWithLargeHeader.tsx b/ts/components/ui/RNavScreenWithLargeHeader.tsx index 59320a8de5d..b1bc4ed0b00 100644 --- a/ts/components/ui/RNavScreenWithLargeHeader.tsx +++ b/ts/components/ui/RNavScreenWithLargeHeader.tsx @@ -51,6 +51,7 @@ type Props = { * @param faqCategories * @param headerProps * @param canGoback allows to show/not show the back button and consequently does not pass to the HeaderSecondLevel the props that would display the back button + * @deprecated This component is deprecated and will be removed in future versions. Please use `IOScrollViewWithLargeHeader` instead. */ export const RNavScreenWithLargeHeader = ({ children, diff --git a/ts/features/design-system/core/DSIOScrollView.tsx b/ts/features/design-system/core/DSIOScrollView.tsx new file mode 100644 index 00000000000..cef573dd79f --- /dev/null +++ b/ts/features/design-system/core/DSIOScrollView.tsx @@ -0,0 +1,60 @@ +import { + Body, + ButtonOutline, + H2, + IOColors, + VSpacer, + useIOTheme +} from "@pagopa/io-app-design-system"; +import * as React from "react"; +import { Alert, View } from "react-native"; +import { IOScrollView } from "../../../components/ui/IOScrollView"; + +export const DSIOScrollView = () => { + const theme = useIOTheme(); + + return ( + Alert.alert("Primary action pressed! (⁠⁠ꈍ⁠ᴗ⁠ꈍ⁠)") + }, + secondary: { + label: "Secondary action", + onPress: () => Alert.alert("Secondary action pressed! (⁠⁠ꈍ⁠ᴗ⁠ꈍ⁠)") + }, + tertiary: { + label: "Tertiary action", + onPress: () => Alert.alert("Tertiary action pressed! (⁠⁠ꈍ⁠ᴗ⁠ꈍ⁠)") + } + }} + > +

Start

+ {[...Array(50)].map((_el, i) => ( + Repeated text + ))} + + + + Alert.alert("Test button")} + /> + {[...Array(2)].map((_el, i) => ( + Repeated text + ))} +

End

+
+ ); +}; diff --git a/ts/features/design-system/core/DSIOScrollViewWithLargeHeader.tsx b/ts/features/design-system/core/DSIOScrollViewWithLargeHeader.tsx new file mode 100644 index 00000000000..0c360d8d555 --- /dev/null +++ b/ts/features/design-system/core/DSIOScrollViewWithLargeHeader.tsx @@ -0,0 +1,41 @@ +import { + Body, + ContentWrapper, + H3, + VSpacer, + useIOTheme +} from "@pagopa/io-app-design-system"; +import React from "react"; +import { Alert } from "react-native"; +import { IOScrollViewWithLargeHeader } from "../../../components/ui/IOScrollViewWithLargeHeader"; + +export const DSIOScrollViewScreenWithLargeHeader = () => { + const theme = useIOTheme(); + + return ( + Alert.alert("Primary action pressed! (⁠⁠ꈍ⁠ᴗ⁠ꈍ⁠)") + } + }} + title={{ + label: "Screen title" + }} + description={ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lobortis luctus odio vel ultricies. Sed non urna dui. Morbi at ipsum pulvinar, sagittis massa ut, viverra nisi. Donec dignissim mi vitae convallis ornare. Pellentesque vel volutpat ex, non tempor neque. Nulla fringilla mi non nisl luctus viverra. Proin efficitur odio id volutpat sodales. Aliquam purus lacus, ultrices at maximus ut, molestie a lorem. Morbi arcu ligula, gravida eu egestas suscipit, congue ut ligula. Aliquam rutrum ante eget dolor feugiat molestie. Phasellus porta tempus nibh sed suscipit." + } + > + + +

Start

+ {[...Array(50)].map((_el, i) => ( + Repeated text - {i + 1} + ))} +

End

+
+
+ ); +}; diff --git a/ts/features/design-system/core/DSIOScrollViewWithoutActions.tsx b/ts/features/design-system/core/DSIOScrollViewWithoutActions.tsx new file mode 100644 index 00000000000..baf03ee0917 --- /dev/null +++ b/ts/features/design-system/core/DSIOScrollViewWithoutActions.tsx @@ -0,0 +1,19 @@ +import { Body, H2, VSpacer, useIOTheme } from "@pagopa/io-app-design-system"; +import * as React from "react"; +import { IOScrollView } from "../../../components/ui/IOScrollView"; + +export const DSIOScrollViewWithoutActions = () => { + const theme = useIOTheme(); + + return ( + +

Start

+ + {[...Array(50)].map((_el, i) => ( + Repeated text {i + 1} + ))} + +

End

+
+ ); +}; diff --git a/ts/features/design-system/navigation/navigator.tsx b/ts/features/design-system/navigation/navigator.tsx index 1c706327b23..eb154cc6171 100644 --- a/ts/features/design-system/navigation/navigator.tsx +++ b/ts/features/design-system/navigation/navigator.tsx @@ -32,11 +32,13 @@ import { DSCards } from "../core/DSCards"; import { DSColors } from "../core/DSColors"; import { DSEdgeToEdgeArea } from "../core/DSEdgeToEdgeArea"; import { DSFullScreenModal } from "../core/DSFullScreenModal"; -import { DSGradientScroll } from "../core/DSGradientScroll"; import { DSHapticFeedback } from "../core/DSHapticFeedback"; import { DSHeaderFirstLevel } from "../core/DSHeaderFirstLevel"; import { DSHeaderSecondLevel } from "../core/DSHeaderSecondLevel"; import { DSHeaderSecondLevelWithSectionTitle } from "../core/DSHeaderSecondLevelWithSectionTitle"; +import { DSIOScrollView } from "../core/DSIOScrollView"; +import { DSIOScrollViewScreenWithLargeHeader } from "../core/DSIOScrollViewWithLargeHeader"; +import { DSIOScrollViewWithoutActions } from "../core/DSIOScrollViewWithoutActions"; import { DSIcons } from "../core/DSIcons"; import { DSLayout } from "../core/DSLayout"; import { DSLegacyIllustrations } from "../core/DSLegacyIllustrations"; @@ -385,10 +387,28 @@ export const DesignSystemNavigator = () => { {/* SCREENS */} + + + + diff --git a/ts/features/design-system/navigation/params.ts b/ts/features/design-system/navigation/params.ts index f8b1c4d9599..5fc653c0d54 100644 --- a/ts/features/design-system/navigation/params.ts +++ b/ts/features/design-system/navigation/params.ts @@ -28,7 +28,9 @@ export type DesignSystemParamsList = { [DESIGN_SYSTEM_ROUTES.HEADERS.FIRST_LEVEL.route]: undefined; [DESIGN_SYSTEM_ROUTES.HEADERS.SECOND_LEVEL.route]: undefined; [DESIGN_SYSTEM_ROUTES.HEADERS.SECOND_LEVEL_SECTION_TITLE.route]: undefined; - [DESIGN_SYSTEM_ROUTES.SCREENS.GRADIENT_SCROLL.route]: undefined; + [DESIGN_SYSTEM_ROUTES.SCREENS.IOSCROLLVIEW.route]: undefined; + [DESIGN_SYSTEM_ROUTES.SCREENS.IOSCROLLVIEW_WO_ACTIONS.route]: undefined; + [DESIGN_SYSTEM_ROUTES.SCREENS.IOSCROLLVIEW_LARGEHEADER.route]: undefined; [DESIGN_SYSTEM_ROUTES.SCREENS.OPERATION_RESULT.route]: undefined; [DESIGN_SYSTEM_ROUTES.SCREENS.WIZARD_SCREEN.route]: undefined; [DESIGN_SYSTEM_ROUTES.SCREENS.BONUS_CARD_SCREEN.route]: undefined; diff --git a/ts/features/design-system/navigation/routes.ts b/ts/features/design-system/navigation/routes.ts index eba00c54d74..dd6598a0bf5 100644 --- a/ts/features/design-system/navigation/routes.ts +++ b/ts/features/design-system/navigation/routes.ts @@ -46,9 +46,17 @@ const DESIGN_SYSTEM_ROUTES = { } }, SCREENS: { - GRADIENT_SCROLL: { - route: "GRADIENT_SCROLL", - title: "Gradient scroll + Actions" + IOSCROLLVIEW: { + route: "IOSCROLLVIEW", + title: "IOScrollView" + }, + IOSCROLLVIEW_WO_ACTIONS: { + route: "IOSCROLLVIEW_WO_ACTIONS", + title: "IOScrollView w/o Actions" + }, + IOSCROLLVIEW_LARGEHEADER: { + route: "IOSCROLLVIEW_LARGEHEADER", + title: "IOScrollView w/ Large header" }, OPERATION_RESULT: { route: "DS_SCREEN_OPERATION_RESULT", diff --git a/ts/features/euCovidCert/components/__test__/__snapshots__/EuCovidCertHeader.test.tsx.snap b/ts/features/euCovidCert/components/__test__/__snapshots__/EuCovidCertHeader.test.tsx.snap index 26750b2dd23..498019458bb 100644 --- a/ts/features/euCovidCert/components/__test__/__snapshots__/EuCovidCertHeader.test.tsx.snap +++ b/ts/features/euCovidCert/components/__test__/__snapshots__/EuCovidCertHeader.test.tsx.snap @@ -57,14 +57,14 @@ exports[`EuCovidCertHeader it should match the snapshot 1`] = ` fontStyle={ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, } } style={ Array [ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, }, Object { "color": "#17324D", diff --git a/ts/features/fci/components/__tests__/__snapshots__/LinkedText.test.tsx.snap b/ts/features/fci/components/__tests__/__snapshots__/LinkedText.test.tsx.snap index b2d98facc5c..155c54639bd 100644 --- a/ts/features/fci/components/__tests__/__snapshots__/LinkedText.test.tsx.snap +++ b/ts/features/fci/components/__tests__/__snapshots__/LinkedText.test.tsx.snap @@ -10,14 +10,14 @@ exports[`Test LinkedText component should render a LinkedText component with pro fontStyle={ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, } } style={ Array [ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, }, Object { "color": "#17324D", diff --git a/ts/features/fci/components/__tests__/__snapshots__/QtspClauseListItem.test.tsx.snap b/ts/features/fci/components/__tests__/__snapshots__/QtspClauseListItem.test.tsx.snap index 02b53b25893..e9c99ff6bb8 100644 --- a/ts/features/fci/components/__tests__/__snapshots__/QtspClauseListItem.test.tsx.snap +++ b/ts/features/fci/components/__tests__/__snapshots__/QtspClauseListItem.test.tsx.snap @@ -346,14 +346,14 @@ exports[`Test QtspClauseListItem component should render a QtspClauseListItem co fontStyle={ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, } } style={ Array [ Object { "fontSize": 22, - "lineHeight": 30, + "lineHeight": 24, }, Object { "color": "#17324D", diff --git a/yarn.lock b/yarn.lock index 24b5f24416f..cd137d1625a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,10 +3272,10 @@ dependencies: "@types/node" ">= 8" -"@pagopa/io-app-design-system@1.36.11": - version "1.36.11" - resolved "https://registry.yarnpkg.com/@pagopa/io-app-design-system/-/io-app-design-system-1.36.11.tgz#1f40c823bf45324bb1de29fd1541c254d009bb1c" - integrity sha512-fKVkiv7zq6RbBo9UfFj6/gH6h9Wbr+mVUSZhTxnG/wuEH2CaGK7SvUaiIhWFtv2zCYDqMy60lJj6qlZ1HJfW9g== +"@pagopa/io-app-design-system@1.36.14": + version "1.36.14" + resolved "https://registry.yarnpkg.com/@pagopa/io-app-design-system/-/io-app-design-system-1.36.14.tgz#bde36a531f1f9f302962d0bde8a440a1dbe3ca35" + integrity sha512-0Sgs31SfYKvHdaXeLoG+5wmI0ObfwYiFdQnkHF3r4LEHxWpkgvzknz2Ck/ZGA1Va/rnOf/rxCOUUc5uCmPE8nA== dependencies: "@pagopa/ts-commons" "^12.0.0" "@testing-library/jest-native" "^5.4.2"