From 9be0db024a98469e85bb2842ef40dbbf91bd090e Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 20 Jan 2023 18:34:50 +0800 Subject: [PATCH] fix(#158): tooltip displaced by keyboard --- lib/react-native-elements/tooltip/Tooltip.tsx | 356 ++++++++++++++++++ .../tooltip/Triangle.tsx | 40 ++ .../tooltip/getTooltipCoordinate.tsx | 142 +++++++ screens/Home/MyVcs/GetIdInputModal.tsx | 45 +-- screens/Profile/ProfileScreen.tsx | 149 ++++---- screens/Scan/ScanLayoutController.ts | 1 + 6 files changed, 637 insertions(+), 96 deletions(-) create mode 100644 lib/react-native-elements/tooltip/Tooltip.tsx create mode 100644 lib/react-native-elements/tooltip/Triangle.tsx create mode 100644 lib/react-native-elements/tooltip/getTooltipCoordinate.tsx diff --git a/lib/react-native-elements/tooltip/Tooltip.tsx b/lib/react-native-elements/tooltip/Tooltip.tsx new file mode 100644 index 00000000..2d085fca --- /dev/null +++ b/lib/react-native-elements/tooltip/Tooltip.tsx @@ -0,0 +1,356 @@ +import React, { Fragment } from 'react'; +import { + TouchableOpacity, + Modal, + View, + StatusBar, + I18nManager, + ViewStyle, + FlexStyle, + StyleProp, + StyleSheet, + ColorValue, + Platform, + Keyboard, +} from 'react-native'; +import { withTheme } from 'react-native-elements/dist/config'; +import { ThemeProps } from 'react-native-elements/dist/config'; +import { + ScreenWidth, + ScreenHeight, + isIOS, +} from 'react-native-elements/dist/helpers'; +import Triangle from './Triangle'; +import getTooltipCoordinate, { + getElementVisibleWidth, +} from './getTooltipCoordinate'; + +export type TooltipProps = { + withPointer?: boolean; + popover?: React.ReactElement<{}>; + toggleOnPress?: boolean; + toggleAction?: string | 'onPress' | 'onLongPress'; + height?: FlexStyle['height']; + width?: FlexStyle['width']; + containerStyle?: StyleProp; + pointerColor?: ColorValue; + onClose?(): void; + onOpen?(): void; + overlayColor?: ColorValue; + withOverlay?: boolean; + backgroundColor?: ColorValue; + highlightColor?: ColorValue; + skipAndroidStatusBar?: boolean; + ModalComponent?: typeof React.Component; + closeOnlyOnBackdropPress?: boolean; +} & typeof defaultProps; + +const defaultProps = { + withOverlay: true, + overlayColor: 'rgba(250, 250, 250, 0.70)', + highlightColor: 'transparent', + withPointer: true, + toggleOnPress: true, + toggleAction: 'onPress', + height: 40, + width: 150, + containerStyle: {}, + backgroundColor: '#617080', + onClose: () => {}, + onOpen: () => {}, + skipAndroidStatusBar: false, + ModalComponent: Modal, + closeOnlyOnBackdropPress: false, +}; + +type TooltipState = { + isVisible: boolean; + yOffset: number; + xOffset: number; + elementWidth: number; + elementHeight: number; +}; + +class Tooltip extends React.Component< + TooltipProps & Partial>, + TooltipState +> { + static defaultProps = defaultProps; + _isMounted: boolean = false; + state = { + isVisible: false, + yOffset: 0, + xOffset: 0, + elementWidth: 0, + elementHeight: 0, + }; + renderedElement?: View | null; + + toggleTooltip = () => { + const { onClose } = this.props; + Keyboard.dismiss(); + setTimeout(() => { + this.getElementPosition(); + this._isMounted && + this.setState((prevState) => { + if (prevState.isVisible) { + onClose && onClose(); + } + return { isVisible: !prevState.isVisible }; + }); + }, 100); + }; + + wrapWithPress = ( + toggleOnPress: TooltipProps['toggleOnPress'], + toggleAction: TooltipProps['toggleAction'], + children: React.ReactNode + ) => { + if (toggleOnPress) { + return ( + + {children} + + ); + } + return children; + }; + + containerStyle = (withOverlay: boolean, overlayColor: string): ViewStyle => { + return { + backgroundColor: withOverlay ? overlayColor : 'transparent', + flex: 1, + }; + }; + + getTooltipStyle = () => { + const { yOffset, xOffset, elementHeight, elementWidth } = this.state; + const { height, backgroundColor, width, withPointer, containerStyle } = + this.props; + const { x, y } = getTooltipCoordinate( + xOffset, + yOffset, + elementWidth, + elementHeight, + ScreenWidth, + ScreenHeight, + width, + height, + withPointer + ); + + return StyleSheet.flatten([ + { + position: 'absolute', + [I18nManager.isRTL ? 'right' : 'left']: x, + top: y, + width, + height, + backgroundColor, + // default styles + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + borderRadius: 10, + padding: 10, + }, + containerStyle, + ]); + }; + + renderPointer = (tooltipY: FlexStyle['top']) => { + const { yOffset, xOffset, elementHeight, elementWidth } = this.state; + const { backgroundColor, pointerColor } = this.props; + const pastMiddleLine = yOffset > (tooltipY || 0); + + return ( + + + + ); + }; + + getTooltipHighlightedButtonStyle = (): ViewStyle => { + const { highlightColor } = this.props; + const { yOffset, xOffset, elementWidth, elementHeight } = this.state; + + return { + position: 'absolute', + top: yOffset, + [I18nManager.isRTL ? 'right' : 'left']: xOffset, + backgroundColor: highlightColor, + overflow: 'visible', + width: elementWidth, + height: elementHeight, + }; + }; + + renderTouchableHighlightedButton = () => { + const TooltipHighlightedButtonStyle = + this.getTooltipHighlightedButtonStyle(); + + return ( + this.toggleTooltip()} + style={TooltipHighlightedButtonStyle}> + {this.props.children} + + ); + }; + + renderStaticHighlightedButton = () => { + const TooltipHighlightedButtonStyle = + this.getTooltipHighlightedButtonStyle(); + + return ( + {this.props.children} + ); + }; + + renderHighlightedButton = () => { + const { closeOnlyOnBackdropPress } = this.props; + if (closeOnlyOnBackdropPress) { + return this.renderTouchableHighlightedButton(); + } else { + return this.renderStaticHighlightedButton(); + } + }; + + renderContent = (withTooltip: boolean) => { + const { popover, withPointer, toggleOnPress, toggleAction } = this.props; + if (!withTooltip) { + return this.wrapWithPress( + toggleOnPress, + toggleAction, + this.props.children + ); + } + + const tooltipStyle = this.getTooltipStyle() as ViewStyle; + + return ( + + {this.renderHighlightedButton()} + {withPointer && this.renderPointer(tooltipStyle.top)} + + {popover} + + + ); + }; + + componentDidMount() { + this._isMounted = true; + // wait to compute onLayout values. + requestAnimationFrame(this.getElementPosition); + } + componentWillUnmount() { + this._isMounted = false; + } + + getElementPosition = () => { + const { skipAndroidStatusBar } = this.props; + this.renderedElement && + this.renderedElement.measure( + ( + _frameOffsetX, + _frameOffsetY, + width, + height, + pageOffsetX, + pageOffsetY + ) => { + this._isMounted && + this.setState({ + xOffset: pageOffsetX, + yOffset: + isIOS || skipAndroidStatusBar + ? pageOffsetY + : pageOffsetY - + Platform.select({ + android: StatusBar.currentHeight, + ios: 20, + default: 0, + }), + elementWidth: width, + elementHeight: height, + }); + } + ); + }; + + renderStaticModalContent = () => { + const { withOverlay, overlayColor } = this.props; + return ( + + + {this.renderContent(true)} + + ); + }; + + renderTogglingModalContent = () => { + const { withOverlay, overlayColor } = this.props; + return ( + + {this.renderContent(true)} + + ); + }; + + renderModalContent = () => { + const { closeOnlyOnBackdropPress } = this.props; + if (closeOnlyOnBackdropPress) { + return this.renderStaticModalContent(); + } else { + return this.renderTogglingModalContent(); + } + }; + + render() { + const { isVisible } = this.state; + const { onOpen, ModalComponent } = this.props; + return ( + { + this.renderedElement = e; + }}> + {this.renderContent(false)} + + {this.renderModalContent()} + + + ); + } +} + +export { Tooltip }; +export default withTheme(Tooltip, 'Tooltip'); diff --git a/lib/react-native-elements/tooltip/Triangle.tsx b/lib/react-native-elements/tooltip/Triangle.tsx new file mode 100644 index 00000000..4532c51c --- /dev/null +++ b/lib/react-native-elements/tooltip/Triangle.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native'; + +type TriangleProps = { + style?: StyleProp; + isDown?: boolean; +}; + +const Triangle: React.FunctionComponent = ({ + style, + isDown, +}) => ( + +); + +const styles = StyleSheet.create({ + down: { + transform: [{ rotate: '180deg' }], + }, + triangle: { + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftWidth: 8, + borderRightWidth: 8, + borderBottomWidth: 15, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: 'white', + }, +}); + +export default Triangle; diff --git a/lib/react-native-elements/tooltip/getTooltipCoordinate.tsx b/lib/react-native-elements/tooltip/getTooltipCoordinate.tsx new file mode 100644 index 00000000..05fb36ab --- /dev/null +++ b/lib/react-native-elements/tooltip/getTooltipCoordinate.tsx @@ -0,0 +1,142 @@ +const getArea = (a: number, b: number) => a * b; + +const getPointDistance = (a: number[], b: number[]) => + Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2)); + +export const getElementVisibleWidth = ( + elementWidth: number, + xOffset: number, + ScreenWidth: number +) => { + // Element is fully visible OR scrolled right + if (xOffset >= 0) { + return xOffset + elementWidth <= ScreenWidth // is element fully visible? + ? elementWidth // element is fully visible; + : ScreenWidth - xOffset; // calculate visible width of scrolled element + } + // Element is scrolled LEFT + return elementWidth - xOffset; // calculate visible width of scrolled element +}; + +/* +type Coord = { + x: number, + y: number, +}; + + ~Tooltip coordinate system:~ + The tooltip coordinates are based on the element which it is wrapping. + We take the x and y coordinates of the element and find the best position + to place the tooltip. To find the best position we look for the side with the + most space. In order to find the side with the most space we divide the the + surroundings in four quadrants and check for the one with biggest area. + Once we know the quandrant with the biggest area it place the tooltip in that + direction. + + To find the areas we first get 5 coordinate points. The center and the other 4 extreme points + which together make a perfect cross shape. + + Once we know the coordinates we can get the length of the vertices which form each quadrant. + Since they are squares we only need two. +*/ +const getTooltipCoordinate = ( + x: number, + y: number, + width: number, + height: number, + ScreenWidth: number, + ScreenHeight: number, + tooltipWidth: number, + tooltipHeight: number, + withPointer: boolean +) => { + // The following are point coordinates: [x, y] + const center = [ + x + getElementVisibleWidth(width, x, ScreenWidth) / 2, + y + height / 2, + ]; + const pOne = [center[0], 0]; + const pTwo = [ScreenWidth, center[1]]; + const pThree = [center[0], ScreenHeight]; + const pFour = [0, center[1]]; + // vertices + const vOne = getPointDistance(center, pOne); + const vTwo = getPointDistance(center, pTwo); + const vThree = getPointDistance(center, pThree); + const vFour = getPointDistance(center, pFour); + // Quadrant areas. + // type Areas = { + // area: number, + // id: number, + // }; + const areas = [ + getArea(vOne, vFour), + getArea(vOne, vTwo), + getArea(vTwo, vThree), + getArea(vThree, vFour), + ].map((each, index) => ({ area: each, id: index })); + const sortedArea = areas.sort((a, b) => b.area - a.area); + // deslocated points + const dX = 0.001; + const dY = height / 2; + // Deslocate the coordinates in the direction of the quadrant. + const directionCorrection = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ]; + const deslocateReferencePoint = [ + [-tooltipWidth, -tooltipHeight], + [0, -tooltipHeight], + [0, 0], + [-tooltipWidth, 0], + ]; + // current quadrant index + const qIndex = sortedArea[0].id; + const getWithPointerOffsetY = () => + withPointer ? 10 * directionCorrection[qIndex][1] : 0; + const getWithPointerOffsetX = () => + withPointer ? center[0] - 18 * directionCorrection[qIndex][0] : center[0]; + const newX = + getWithPointerOffsetX() + + (dX * directionCorrection[qIndex][0] + deslocateReferencePoint[qIndex][0]); + return { + x: constraintX(newX, qIndex, center[0], ScreenWidth, tooltipWidth), + y: + center[1] + + (dY * directionCorrection[qIndex][1] + + deslocateReferencePoint[qIndex][1]) + + getWithPointerOffsetY(), + }; +}; + +const constraintX = ( + newX: number, + qIndex: number, + x: number, + ScreenWidth: number, + tooltipWidth: number +) => { + switch (qIndex) { + // 0 and 3 are the left side quadrants. + case 0: + case 3: { + const maxWidth = newX > ScreenWidth ? ScreenWidth - 10 : newX; + return newX < 1 ? 10 : maxWidth; + } + // 1 and 2 are the right side quadrants + case 1: + case 2: { + const leftOverSpace = ScreenWidth - newX; + return leftOverSpace >= tooltipWidth + ? newX + : newX - (tooltipWidth - leftOverSpace + 10); + } + default: { + return 0; + } + } +}; + +export default getTooltipCoordinate; diff --git a/screens/Home/MyVcs/GetIdInputModal.tsx b/screens/Home/MyVcs/GetIdInputModal.tsx index 03437d6f..f39891ce 100644 --- a/screens/Home/MyVcs/GetIdInputModal.tsx +++ b/screens/Home/MyVcs/GetIdInputModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Dimensions, I18nManager } from 'react-native'; -import { Icon, Input, Tooltip } from 'react-native-elements'; -import { Button, Column, Row, Text } from '../../../components/ui'; +import { Icon, Input } from 'react-native-elements'; +import { Button, Centered, Column, Row, Text } from '../../../components/ui'; import { Modal } from '../../../components/ui/Modal'; import { Theme } from '../../../components/ui/styleUtils'; import { @@ -11,6 +11,7 @@ import { import { KeyboardAvoidingView, Platform } from 'react-native'; import { useTranslation } from 'react-i18next'; import { MessageOverlay } from '../../../components/MessageOverlay'; +import Tooltip from '../../../lib/react-native-elements/tooltip/Tooltip'; export const GetIdInputModal: React.FC = (props) => { const { t } = useTranslation('GetIdInputModal'); @@ -63,25 +64,27 @@ export const GetIdInputModal: React.FC = (props) => { skipAndroidStatusBar={true} onOpen={controller.ACTIVATE_ICON_COLOR} onClose={controller.DEACTIVATE_ICON_COLOR}> - {controller.isInvalid ? ( - - ) : ( - - )} + + {controller.isInvalid ? ( + + ) : ( + + )} + } errorStyle={{ color: Theme.Colors.errorMessage }} diff --git a/screens/Profile/ProfileScreen.tsx b/screens/Profile/ProfileScreen.tsx index b0379b02..0dbf4538 100644 --- a/screens/Profile/ProfileScreen.tsx +++ b/screens/Profile/ProfileScreen.tsx @@ -39,83 +39,82 @@ export const ProfileScreen: React.FC = (props) => { const { t } = useTranslation('ProfileScreen'); const controller = useProfileScreen(props); return ( - - - - - - - - - - {t('bioUnlock')} - - - + + - - - - - - {t('authFactorUnlock')} + + + + + + + + {t('bioUnlock')} + + + + + + + + + {t('authFactorUnlock')} + + + + + + + + + {t('logout')} + + + + + {t('version')}: {getVersion()} + + {controller.backendInfo.application.name !== '' ? ( + + + {controller.backendInfo.application.name}:{' '} + {controller.backendInfo.application.version} - - - - - - - - {t('logout')} - - - - - {t('version')}: {getVersion()} - - {controller.backendInfo.application.name !== '' ? ( - - - {controller.backendInfo.application.name}:{' '} - {controller.backendInfo.application.version} - - - MOSIP: {controller.backendInfo.config['mosip.host']} - - - ) : null} + + MOSIP: {controller.backendInfo.config['mosip.host']} + + + ) : null} + ); }; diff --git a/screens/Scan/ScanLayoutController.ts b/screens/Scan/ScanLayoutController.ts index d4a792cf..b89003b7 100644 --- a/screens/Scan/ScanLayoutController.ts +++ b/screens/Scan/ScanLayoutController.ts @@ -160,6 +160,7 @@ export function useScanLayout() { } else if (isOffline) { statusOverlay = { message: t('status.offline'), + onBackdropPress: DISMISS, }; }