fix(#158): tooltip displaced by keyboard

This commit is contained in:
Paolo Miguel de Leon
2023-01-20 18:34:50 +08:00
parent 5b924c2ff3
commit 9be0db024a
6 changed files with 637 additions and 96 deletions

View File

@@ -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<ViewStyle>;
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<ThemeProps<TooltipProps>>,
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 (
<TouchableOpacity
{...{ [toggleAction]: this.toggleTooltip }}
delayLongPress={250}
activeOpacity={1}>
{children}
</TouchableOpacity>
);
}
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 (
<View
style={{
position: 'absolute',
top: pastMiddleLine ? yOffset - 13 : yOffset + elementHeight - 2,
[I18nManager.isRTL ? 'right' : 'left']:
xOffset +
getElementVisibleWidth(elementWidth, xOffset, ScreenWidth) / 2 -
7.5,
}}>
<Triangle
style={{ borderBottomColor: pointerColor || backgroundColor }}
isDown={pastMiddleLine}
/>
</View>
);
};
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 (
<TouchableOpacity
testID="tooltipTouchableHighlightedButton"
onPress={() => this.toggleTooltip()}
style={TooltipHighlightedButtonStyle}>
{this.props.children}
</TouchableOpacity>
);
};
renderStaticHighlightedButton = () => {
const TooltipHighlightedButtonStyle =
this.getTooltipHighlightedButtonStyle();
return (
<View style={TooltipHighlightedButtonStyle}>{this.props.children}</View>
);
};
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 (
<View>
{this.renderHighlightedButton()}
{withPointer && this.renderPointer(tooltipStyle.top)}
<View style={tooltipStyle} testID="tooltipPopoverContainer">
{popover}
</View>
</View>
);
};
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 (
<Fragment>
<TouchableOpacity
style={this.containerStyle(withOverlay, overlayColor)}
onPress={this.toggleTooltip}
activeOpacity={1}
/>
<View>{this.renderContent(true)}</View>
</Fragment>
);
};
renderTogglingModalContent = () => {
const { withOverlay, overlayColor } = this.props;
return (
<TouchableOpacity
style={this.containerStyle(withOverlay, overlayColor)}
onPress={this.toggleTooltip}
activeOpacity={1}>
{this.renderContent(true)}
</TouchableOpacity>
);
};
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 (
<View
collapsable={false}
ref={(e) => {
this.renderedElement = e;
}}>
{this.renderContent(false)}
<ModalComponent
animationType="fade"
visible={isVisible}
transparent
onShow={onOpen}>
{this.renderModalContent()}
</ModalComponent>
</View>
);
}
}
export { Tooltip };
export default withTheme(Tooltip, 'Tooltip');

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
type TriangleProps = {
style?: StyleProp<ViewStyle>;
isDown?: boolean;
};
const Triangle: React.FunctionComponent<TriangleProps> = ({
style,
isDown,
}) => (
<View
style={StyleSheet.flatten([
styles.triangle,
style,
isDown ? styles.down : {},
])}
/>
);
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;

View File

@@ -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;

View File

@@ -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<GetIdInputModalProps> = (props) => {
const { t } = useTranslation('GetIdInputModal');
@@ -63,25 +64,27 @@ export const GetIdInputModal: React.FC<GetIdInputModalProps> = (props) => {
skipAndroidStatusBar={true}
onOpen={controller.ACTIVATE_ICON_COLOR}
onClose={controller.DEACTIVATE_ICON_COLOR}>
{controller.isInvalid ? (
<Icon
name="error"
size={18}
color={
!controller.iconColor
? Theme.Colors.errorMessage
: Theme.Colors.Icon
}
/>
) : (
<Icon
name={'help'}
size={18}
color={
!controller.iconColor ? null : Theme.Colors.Icon
}
/>
)}
<Centered width={32} fill>
{controller.isInvalid ? (
<Icon
name="error"
size={18}
color={
!controller.iconColor
? Theme.Colors.errorMessage
: Theme.Colors.Icon
}
/>
) : (
<Icon
name={'help'}
size={18}
color={
!controller.iconColor ? null : Theme.Colors.Icon
}
/>
)}
</Centered>
</Tooltip>
}
errorStyle={{ color: Theme.Colors.errorMessage }}

View File

@@ -39,83 +39,82 @@ export const ProfileScreen: React.FC<MainRouteProps> = (props) => {
const { t } = useTranslation('ProfileScreen');
const controller = useProfileScreen(props);
return (
<Column
fill
padding="24 0"
backgroundColor={Theme.Colors.lightGreyBackgroundColor}>
<MessageOverlay
isVisible={controller.alertMsg != ''}
onBackdropPress={controller.hideAlert}
title={controller.alertMsg}
/>
<EditableListItem
label={t('name')}
value={controller.name}
onEdit={controller.UPDATE_NAME}
/>
<EditableListItem
label={t('vcLabel')}
value={controller.vcLabel.singular}
onEdit={controller.UPDATE_VC_LABEL}
/>
<LanguageSetting />
<Revoke label={t('revokeLabel')} />
<ListItem bottomDivider disabled={!controller.canUseBiometrics}>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileLabel}>{t('bioUnlock')}</Text>
</ListItem.Title>
</ListItem.Content>
<Switch
value={controller.isBiometricUnlockEnabled}
onValueChange={controller.useBiometrics}
color={Theme.Colors.profileValue}
<Column fill backgroundColor={Theme.Colors.lightGreyBackgroundColor}>
<Column scroll padding="24 0">
<MessageOverlay
isVisible={controller.alertMsg != ''}
onBackdropPress={controller.hideAlert}
title={controller.alertMsg}
/>
</ListItem>
<ListItem bottomDivider disabled>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileAuthFactorUnlock}>
{t('authFactorUnlock')}
<EditableListItem
label={t('name')}
value={controller.name}
onEdit={controller.UPDATE_NAME}
/>
<EditableListItem
label={t('vcLabel')}
value={controller.vcLabel.singular}
onEdit={controller.UPDATE_VC_LABEL}
/>
<LanguageSetting />
<Revoke label={t('revokeLabel')} />
<ListItem bottomDivider disabled={!controller.canUseBiometrics}>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileLabel}>{t('bioUnlock')}</Text>
</ListItem.Title>
</ListItem.Content>
<Switch
value={controller.isBiometricUnlockEnabled}
onValueChange={controller.useBiometrics}
color={Theme.Colors.profileValue}
/>
</ListItem>
<ListItem bottomDivider disabled>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileAuthFactorUnlock}>
{t('authFactorUnlock')}
</Text>
</ListItem.Title>
</ListItem.Content>
</ListItem>
<Credits label={t('credits')} color={Theme.Colors.profileLabel} />
<ListItem bottomDivider onPress={controller.LOGOUT}>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileLabel}>{t('logout')}</Text>
</ListItem.Title>
</ListItem.Content>
</ListItem>
<Text
weight="semibold"
margin="32 0 0 0"
align="center"
size="smaller"
color={Theme.Colors.profileVersion}>
{t('version')}: {getVersion()}
</Text>
{controller.backendInfo.application.name !== '' ? (
<View>
<Text
weight="semibold"
align="center"
size="smaller"
color={Theme.Colors.profileValue}>
{controller.backendInfo.application.name}:{' '}
{controller.backendInfo.application.version}
</Text>
</ListItem.Title>
</ListItem.Content>
</ListItem>
<Credits label={t('credits')} color={Theme.Colors.profileLabel} />
<ListItem bottomDivider onPress={controller.LOGOUT}>
<ListItem.Content>
<ListItem.Title>
<Text color={Theme.Colors.profileLabel}>{t('logout')}</Text>
</ListItem.Title>
</ListItem.Content>
</ListItem>
<Text
weight="semibold"
margin="32 0 0 0"
align="center"
size="smaller"
color={Theme.Colors.profileVersion}>
{t('version')}: {getVersion()}
</Text>
{controller.backendInfo.application.name !== '' ? (
<View>
<Text
weight="semibold"
align="center"
size="smaller"
color={Theme.Colors.profileValue}>
{controller.backendInfo.application.name}:{' '}
{controller.backendInfo.application.version}
</Text>
<Text
weight="semibold"
align="center"
size="smaller"
color={Theme.Colors.profileValue}>
MOSIP: {controller.backendInfo.config['mosip.host']}
</Text>
</View>
) : null}
<Text
weight="semibold"
align="center"
size="smaller"
color={Theme.Colors.profileValue}>
MOSIP: {controller.backendInfo.config['mosip.host']}
</Text>
</View>
) : null}
</Column>
</Column>
);
};

View File

@@ -160,6 +160,7 @@ export function useScanLayout() {
} else if (isOffline) {
statusOverlay = {
message: t('status.offline'),
onBackdropPress: DISMISS,
};
}