Skip to content

Commit

Permalink
[IOPID-1899] NumberPad optimization (#284)
Browse files Browse the repository at this point in the history
## Short description
Improved `NumberPad` component performance, avoiding unnecessary renders

## List of changes proposed in this pull request
- Performance improvements
- Add unitary tests for `NumberPad` component
- Add documentation in `NumberPad` and `NumberButton` components
- BREAKING CHANGE: update `NumberPad` APIs and logic**

>[!Important]
>** The pin concatenation is not handled by `NumberPad` anymore. This
kind of logic will need to be handled directly where the component will
be used.
## How to test
Run the `ExampleApp`, navigate in the `Components > NumberPad` section
and start typing.

---------

Co-authored-by: Fabio Bombardi <[email protected]>
  • Loading branch information
ChrisMattew and shadowsheep1 authored Jun 13, 2024
1 parent ec4c7f7 commit 037f501
Show file tree
Hide file tree
Showing 8 changed files with 1,524 additions and 124 deletions.
30 changes: 17 additions & 13 deletions example/src/pages/NumberPad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
useIOTheme
} from "@pagopa/io-app-design-system";
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Alert, View } from "react-native";

const PIN_LENGTH = 6;
Expand All @@ -25,16 +25,20 @@ export const NumberPadScreen = () => {
const theme = useIOTheme();
const navigation = useNavigation();

const [value, setValue] = React.useState("");
const [blueBackground, setBlueBackground] = React.useState(false);
const [value, setValue] = useState("");
const [blueBackground, setBlueBackground] = useState(false);

const onValueChange = (v: string) => {
if (v.length <= PIN_LENGTH) {
setValue(v);
}
};
const onNumberPress = useCallback((v: number) => {
setValue((prev) => prev.length < PIN_LENGTH ? `${prev}${v}` : prev);
}, []);

React.useEffect(() => {
const onDeletePress = useCallback(() => {
setValue((prev) => prev.slice(0, -1));
}, []);

const onBiometricPress = useCallback(() => Alert.alert("biometric"),[]);

useEffect(() => {
navigation.setOptions({
headerStyle: {
backgroundColor: blueBackground
Expand Down Expand Up @@ -74,18 +78,18 @@ export const NumberPadScreen = () => {
value={value}
length={PIN_LENGTH}
variant={blueBackground ? "light" : "dark"}
onValueChange={onValueChange}
onValueChange={setValue}
onValidate={v => v === "123456"}
/>
<VSpacer size={48} />
<NumberPad
value={value}
deleteAccessibilityLabel="Delete"
onValueChange={onValueChange}
onNumberPress={onNumberPress}
onDeletePress={onDeletePress}
variant={blueBackground ? "dark" : "light"}
biometricType="FACE_ID"
biometricAccessibilityLabel="Face ID"
onBiometricPress={() => Alert.alert("biometric")}
onBiometricPress={onBiometricPress}
/>
</ContentWrapper>
</View>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"prettify": "prettier --write \"src/**/*.(ts|tsx)\"",
"prepack": "bob build",
"prepare": "bob build",
"release": "release-it",
"example": "yarn --cwd example",
"bootstrap": "yarn example && yarn install",
Expand Down
33 changes: 26 additions & 7 deletions src/components/numberpad/NumberButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react";
import React, { memo, useCallback, useMemo } from "react";
import { Pressable } from "react-native";
import Animated, {
Extrapolate,
Expand All @@ -22,8 +22,19 @@ import { H3 } from "../typography";
type NumberButtonVariantType = "light" | "dark";

type NumberButtonProps = {
/**
* Used to choose the component color variant between `dark` and `light`.
*/
variant: NumberButtonVariantType;
/**
* The button value.
*/
number: number;
/**
* The action to be executed when the button is pressed.
* @param number
* @returns void
*/
onPress: (number: number) => void;
};

Expand Down Expand Up @@ -58,8 +69,12 @@ const legacyColorMap: Record<NumberButtonVariantType, ColorMapVariant> = {
foreground: "white"
}
};

export const NumberButton = ({
/**
* Based on a `Pressable` element, it displays a number button with animations on press In and Out.
*
* @returns {JSX.Element} The rendered `NumberButton`
*/
export const NumberButton = memo(({
number,
variant,
onPress
Expand Down Expand Up @@ -110,13 +125,17 @@ export const NumberButton = ({
isPressed.value = 0;
}, [isPressed]);

const handleOnPress = useCallback(() => {
onPress(number);
}, [number, onPress]);

return (
<Pressable
accessible={true}
accessibilityRole={"button"}
accessible
accessibilityRole="button"
onPressIn={onPressIn}
onPressOut={onPressOut}
onPress={() => onPress(number)}
onPress={handleOnPress}
>
<Animated.View
style={[
Expand All @@ -130,4 +149,4 @@ export const NumberButton = ({
</Animated.View>
</Pressable>
);
};
});
219 changes: 126 additions & 93 deletions src/components/numberpad/NumberPad.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { ComponentProps } from "react";
import { View } from "react-native";
import { BiometricsValidType } from "../../utils/types";
import React, { ComponentProps, Fragment, useCallback, useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { BiometricsValidType, Optional } from "../../utils/types";
import { IONumberPadButtonStyles, IOStyles } from "../../core";
import { VSpacer } from "../spacer";
import { IconButton } from "../buttons";
Expand All @@ -9,21 +9,49 @@ import { NumberButton } from "./NumberButton";

type BiometricAuthProps =
| {
biometricType: BiometricsValidType;
onBiometricPress: () => void;
biometricAccessibilityLabel: string;
}
/**
* Type of device biometric.
*/
biometricType: BiometricsValidType;
/**
* Function to be executed when the biometric button is pressed.
* @returns void
*/
onBiometricPress: () => void;
/**
* This label will be read from ScreenReaders and give informations about biometric button.
*/
biometricAccessibilityLabel: string;
}
| {
biometricType?: never;
onBiometricPress?: never;
biometricAccessibilityLabel?: never;
};
biometricType?: never;
onBiometricPress?: never;
biometricAccessibilityLabel?: never;
};

type NumberPadProps = {
value: string;
onValueChange: (value: string) => void;
variant: ComponentProps<typeof NumberButton>["variant"];
/**
* Used to choose the component color variant between `dark` and `light`.
*
* The default value is `dark`.
*/
variant?: ComponentProps<typeof NumberButton>["variant"];
/**
* This label will be read from ScreenReaders and give informations about delete button.
*/
deleteAccessibilityLabel: string;
/**
* This function is passed to all numeric buttons to handle their press action.
* @param value
* @returns void
*/
onNumberPress: (value: number) => void;
/**
* This function is passed to the delete button to handle the action to trigger when it's pressed.
*
* @returns void
*/
onDeletePress: () => void;
} & BiometricAuthProps;

const mapIconSpecByBiometric: Record<
Expand All @@ -35,79 +63,53 @@ const mapIconSpecByBiometric: Record<
BIOMETRICS: { icon: "fingerprint", size: 24 }
};

const ButtonWrapper = ({ children }: { children: React.ReactNode }) => (
<View
style={[
IONumberPadButtonStyles.buttonSize,
IOStyles.alignCenter,
IOStyles.centerJustified
]}
>
{children}
</View>
);
/**
* This component displays a custom numeric keyboard.
*
* It accepts an optional `biometricType` prop which enables an extra keyboard button that accepts a `onBiometricPress` prop used to handle the action to be executed when it's pressed.
* @returns {JSX.Element} The rendered numeric keyboard component.
*/
export const NumberPad = ({
value,
variant = "dark",
onValueChange,
biometricType,
onBiometricPress,
biometricAccessibilityLabel,
deleteAccessibilityLabel
}: NumberPadProps) => {
const numberPadPress = (number: number) => {
const newValue = `${value}${number}`;
onValueChange(newValue);
};

const onDeletePress = () => {
const newValue = value.slice(0, -1);
onValueChange(newValue);
};

type ButtonType = "biometric" | "delete";
deleteAccessibilityLabel,
onNumberPress,
onBiometricPress,
onDeletePress
}: NumberPadProps): JSX.Element => {
/**
* Renders the buttons row from a given array.
*/
// eslint-disable-next-line arrow-body-style
const renderButtonsRow = useCallback((row: Array<Optional<number | string>>) => {
return row.map((item) => {
if (typeof item === "number") {
return (
<NumberButton
key={item}
number={item}
onPress={onNumberPress}
variant={variant}
/>
);
}

const RowButtons = ({
buttons
}: {
buttons: ReadonlyArray<number | ButtonType>;
}) => (
<View
style={[
IOStyles.rowSpaceBetween,
{
justifyContent: "space-between",
alignItems: "center",
flexGrow: 1
}
]}
>
{buttons.map(elem => {
if (typeof elem === "number") {
return (
<NumberButton
key={elem}
number={elem}
onPress={numberPadPress}
variant={variant}
if (item === "delete") {
return (
<ButtonWrapper key={item}>
<IconButton
icon="cancel"
color={variant === "dark" ? "contrast" : "primary"}
onPress={onDeletePress}
accessibilityLabel={deleteAccessibilityLabel}
/>
);
}

if (elem === "delete") {
return (
<ButtonWrapper key={elem}>
<IconButton
icon="cancel"
color={variant === "dark" ? "contrast" : "primary"}
onPress={onDeletePress}
accessibilityLabel={deleteAccessibilityLabel}
/>
</ButtonWrapper>
);
}
return biometricType ? (
<ButtonWrapper key={elem}>
</ButtonWrapper>
);
}
if (biometricType && mapIconSpecByBiometric[biometricType]) {
return (
<ButtonWrapper key={item}>
<IconButton
icon={mapIconSpecByBiometric[biometricType].icon}
iconSize={mapIconSpecByBiometric[biometricType].size}
Expand All @@ -116,22 +118,53 @@ export const NumberPad = ({
accessibilityLabel={biometricAccessibilityLabel}
/>
</ButtonWrapper>
) : (
<View key={"emptyElem"} style={IONumberPadButtonStyles.buttonSize} />
);
})}
</View>
);
}

return <View key={"emptyElem"} style={IONumberPadButtonStyles.buttonSize} />;
});
}, [biometricAccessibilityLabel, biometricType, deleteAccessibilityLabel, onBiometricPress, onDeletePress, onNumberPress, variant]);

// eslint-disable-next-line arrow-body-style
const numberPad = useMemo(() => {
return [[1, 2, 3], [4, 5, 6], [7, 8, 9], [biometricType, 0, 'delete']].map((row, i, self) =>
<Fragment key={i}>
<View
style={[
IOStyles.rowSpaceBetween,
styles.numberPad
]}
>
{renderButtonsRow(row)}
</View>
{i < self.length - 1 && <VSpacer />}
</Fragment>
);
}, [biometricType, renderButtonsRow]);

return (
<View style={IOStyles.horizontalContentPadding}>
<RowButtons buttons={[1, 2, 3]} />
<VSpacer />
<RowButtons buttons={[4, 5, 6]} />
<VSpacer />
<RowButtons buttons={[7, 8, 9]} />
<VSpacer />
<RowButtons buttons={["biometric", 0, "delete"]} />
{numberPad}
</View>
);
};

const ButtonWrapper = ({ children }: { children: React.ReactNode }) => (
<View
style={[
IONumberPadButtonStyles.buttonSize,
IOStyles.alignCenter,
IOStyles.centerJustified
]}
>
{children}
</View>
);

const styles = StyleSheet.create({
numberPad: {
justifyContent: "space-between",
alignItems: "center",
flexGrow: 1
}
});
Loading

0 comments on commit 037f501

Please sign in to comment.