diff --git a/README.md b/README.md index 3ae1ca2..30a72c8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# react-native-good-tooltip +# React Native Good Tooltip -This tooltip was created with the best UX in mind. +Tooltips do not interrupt the user's flow. I'm not positive about the flow of using the app after touching the tooltip to close it. +So this component doesn't use Modal. ## Installation @@ -10,15 +11,57 @@ npm install react-native-good-tooltip ## Usage +**⚠️Warning⚠️️** +This component will need to be used with styles (z-Index and Overflow). +You will immediately see the need for zIndex if you use "bottom" placement. +Please refer to the example project and video. + +```tsx +import Tooltip from 'react-native-good-tooltip'; -```js -import { multiply } from 'react-native-good-tooltip'; // ... -const result = await multiply(3, 7); + + {/* your component */} + ``` +## Video + + + + +
+
+ +## Props + + +## Props +| Prop | Type | Default | Description | +|------------------------|----------------------------------------------------------------------|----------------------------------------|-----------------------------------------------------------------------------------------------| +| `placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | **required** | The position of the tooltip relative to the anchor. | +| `anchor` | `'center' \| 'left' \| 'right' \| 'top' \| 'bottom'` | `'center'` | The alignment of the tooltip relative to the anchor. | +| `text` | `string \| React.ReactElement` | **required** | The content of the tooltip. | +| `isVisible` | `boolean` | **required** | Whether the tooltip is visible. | +| `offset` | `{ position?: { x?: number, y?: number }, arrow?: { x?: number, y?: number } }` | `undefined` | The offset for the tooltip and arrow position. | +| `arrowElement` | `React.ReactElement` | `undefined` | Custom arrow element. | +| `styles` | `{color?: ColorValue,containerStyle?: ViewStyle,tooltipStyle?: ViewStyle,arrowSize?: { width?: number, height?: number },closeSize?: { width?: number, height?: number} }` | `undefined` | Custom styles for the tooltip. | +| `children` | `React.ReactElement` | `undefined` | The element to which the tooltip is anchored. | +| `onPress` | `() => void` | `undefined` | Function to call when the tooltip is pressed. | +| `onVisibleChange` | `(isVisible: boolean) => void` | `undefined` | Function to call when the visibility of the tooltip changes. | +| `disableAutoHide` | `boolean` | `false` | Whether to disable the auto-hide feature. | +| `delayShowTime` | `number` | `0` | The delay time before showing the tooltip. | +| `autoHideTime` | `number` | `5000` | The time after which the tooltip will automatically hide. | +| `requiredConfirmation` | `boolean` | `false` | Whether the tooltip requires confirmation to hide. | ## Contributing diff --git a/assets/close.png b/assets/close.png new file mode 100644 index 0000000..81b467b Binary files /dev/null and b/assets/close.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index c90d28b..73244aa 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,30 +1,194 @@ -import { useState, useEffect } from 'react'; -import { StyleSheet, View, Text } from 'react-native'; -import { multiply } from 'react-native-good-tooltip'; +import { FlatList, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import Tooltip from 'react-native-good-tooltip'; export default function App() { - const [result, setResult] = useState(); - - useEffect(() => { - multiply(3, 7).then(setResult); - }, []); + const data = [ + 'In FlatList', + 'zIndex must be specified using', + 'CellRendererComponent.', + ]; return ( - - Result: {result} - + + {/* Header*/} + + + + + + + + + + + + + + + + + + This Tooltip component does not use modal, so you must specify + z-Index. Especially if the placement is bottom, you need to be + especially careful. + + + + + This Tooltip component does not use modal, so you must specify + z-Index. Especially if the placement is bottom, you need to be + especially careful. + + + + + {/* Body*/} + { + return ( + + ); + }} + renderItem={({ item, index }) => ( + { + if (index % 3 === 0) return 'right'; + if (index % 2 === 0) return 'center'; + + return 'left'; + })()} + text={'List Item Tooltip ' + index} + > + + {`${item}`} + + + )} + ListFooterComponent={() => ( + + + + + There may be situations where you need to add the overflow: + 'visible' style. + + + + + + + + + + + + + + + + + )} + /> + ); } const styles = StyleSheet.create({ container: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + marginVertical: 8, + marginHorizontal: 16, }, box: { - width: 60, - height: 60, - marginVertical: 20, + width: 50, + height: 50, + }, + message: { + padding: 8, }, }); diff --git a/package.json b/package.json index eec9bea..8f94fe7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "scripts": { "example": "yarn workspace react-native-good-tooltip-example", "test": "jest", + "postinstall": "npx patch-package", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli lib", @@ -181,5 +182,8 @@ "create-react-native-library": { "type": "library", "version": "0.41.0" + }, + "dependencies": { + "react-native-anchor-point": "^1.0.6" } } diff --git a/patches/react-native-anchor-point+1.0.6.patch b/patches/react-native-anchor-point+1.0.6.patch new file mode 100644 index 0000000..fc086c3 --- /dev/null +++ b/patches/react-native-anchor-point+1.0.6.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/react-native-anchor-point/index.ts b/node_modules/react-native-anchor-point/index.ts +index f789fa7..a1a97c6 100644 +--- a/node_modules/react-native-anchor-point/index.ts ++++ b/node_modules/react-native-anchor-point/index.ts +@@ -33,8 +33,10 @@ export const withAnchorPoint = (transform: TransformsStyle, anchorPoint: Point, + shiftTranslateX.push({ + translateX: size.width * (anchorPoint.x - defaultAnchorPoint.x), + }); ++ // @ts-ignore + injectedTransform = [...shiftTranslateX, ...injectedTransform]; + // shift after rotation ++ // @ts-ignore + injectedTransform.push({ + translateX: size.width * (defaultAnchorPoint.x - anchorPoint.x), + }); diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 0000000..5f10376 --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,69 @@ +export const getAnchorPoint = (placement: string, anchor: string) => { + let x = 0; + let y = 0; + + // vertical + if (placement === 'top' && anchor === 'left') { + x = 0; + y = 1; + } + if (placement === 'top' && anchor === 'right') { + x = 1; + y = 1; + } + if (placement === 'top' && anchor === 'center') { + x = 0.5; + y = 1; + } + + if (placement === 'bottom' && anchor === 'left') { + x = 0; + y = 0; + } + if (placement === 'bottom' && anchor === 'center') { + x = 0.5; + y = 0; + } + if (placement === 'bottom' && anchor === 'right') { + x = 1; + y = 0; + } + + // horizontal + if (placement === 'left' && anchor === 'top') { + x = 1; + y = 0; + } + if (placement === 'left' && anchor === 'center') { + x = 1; + y = 0.5; + } + if (placement === 'left' && anchor === 'bottom') { + x = 1; + y = 1; + } + if (placement === 'right' && anchor === 'top') { + x = 0; + y = 0; + } + if (placement === 'right' && anchor === 'center') { + x = 0; + y = 0.5; + } + if (placement === 'right' && anchor === 'bottom') { + x = 0; + y = 1; + } + + return { x, y }; +}; + +export const createArrowShape = (width: number, height: number) => { + return { + borderLeftWidth: width / 2, + borderRightWidth: width / 2, + borderBottomWidth: height, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }; +}; diff --git a/src/index.tsx b/src/index.tsx index 9e42cf3..b74e165 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,397 @@ -export function multiply(a: number, b: number): Promise { - return Promise.resolve(a * b); +import React, { useEffect, useState } from 'react'; +import { + Animated, + Dimensions, + Platform, + Text, + TouchableOpacity, + View, + type ColorValue, + type LayoutChangeEvent, + type ViewStyle, + Image, +} from 'react-native'; +import { withAnchorPoint } from 'react-native-anchor-point'; +import { createArrowShape, getAnchorPoint } from './functions'; + +// DEFAULT VALUES +const SIDE_ARROW_INSET = 12; +const CLOSE_ICON_SIZE = { width: 16, height: 16 }; +const ARROW_SIZE = { width: 10, height: 6 }; +const TOOLTIP_STYLE = { + width: Dimensions.get('window').width * 0.3, + paddingVertical: 8, + paddingHorizontal: 12, +}; + +interface ToolTipProps { + placement: 'top' | 'bottom' | 'left' | 'right'; + anchor?: 'center' | 'left' | 'right' | 'top' | 'bottom'; + offset?: { + position?: { + x?: number; + y?: number; + }; + arrow?: { + x?: number; + y?: number; + }; + }; + arrowElement?: React.ReactElement; + styles?: { + color?: ColorValue | 'primary'; + containerStyle?: ViewStyle; + tooltipStyle?: ViewStyle; + arrowSize?: { + width?: number; + height?: number; + }; + closeSize?: { + width?: number; + height?: number; + }; + }; + text: string | React.ReactElement; + children?: React.ReactElement; + isVisible: boolean; + onPress?: () => void; + onVisibleChange?: (isVisible: boolean) => void; + disableAutoHide?: boolean; + delayShowTime?: number; + autoHideTime?: number; + requiredConfirmation?: boolean; } + +const Tooltip = ({ + isVisible, + anchor = 'center', + styles = { + tooltipStyle: TOOLTIP_STYLE, + arrowSize: ARROW_SIZE, + closeSize: CLOSE_ICON_SIZE, + }, + text, + children, + placement, + onPress, + offset, + arrowElement, + onVisibleChange, + disableAutoHide = false, + delayShowTime = 0, + autoHideTime = 5000, + requiredConfirmation = false, +}: ToolTipProps) => { + const [currentIsVisible, setCurrentIsVisible] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ + top: 0, + left: Platform.OS === 'android' && placement === 'right' ? -4 : 0, + }); + const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 }); + const animatedValue = new Animated.Value(0); + + const isVerticalPlacement = placement === 'top' || placement === 'bottom'; + const tooltipColor = (() => { + if (styles?.color) return styles?.color; + + return '#3Eb489'; + })(); + + const arrowStyle = { + ...createArrowShape( + styles?.arrowSize?.width || ARROW_SIZE.width, + styles?.arrowSize?.height || ARROW_SIZE.height + ), + borderBottomColor: tooltipColor, + }; + + useEffect(() => { + if ( + autoHideTime > 0 && + disableAutoHide === false && + requiredConfirmation === false + ) { + setTimeout(() => { + // 기본적으로 5초 뒤 사라짐 처리 + setCurrentIsVisible(false); + }, autoHideTime); + } + }, [autoHideTime, disableAutoHide, requiredConfirmation]); + + useEffect(() => { + if (onVisibleChange) { + onVisibleChange(isVisible); + } + }, [isVisible, onVisibleChange]); + + useEffect(() => { + setTimeout(() => { + setCurrentIsVisible(isVisible); + }, delayShowTime); + }, [delayShowTime, isVisible]); + + const handleVerticalTooltipLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + if (placement === 'top') { + setTooltipPosition({ top: -height, left: 0 }); + } + setTooltipSize({ + width, + height, + }); + }; + + const handleHorizontalTooltipLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + + const newLeft = width - 4; + if (placement === 'left') { + setTooltipPosition({ top: 0, left: -newLeft }); + } + setTooltipSize({ + width, + height, + }); + }; + + const renderVerticalArrow = () => ( + { + if (anchor === 'left') return 'flex-start'; + if (anchor === 'right') return 'flex-end'; + return 'center'; + })(), + }, + styles?.containerStyle, + ]} + > + { + let x = SIDE_ARROW_INSET + (offset?.arrow?.x || 0); + + if (!isVerticalPlacement) { + x = offset?.arrow?.x || 0; + } + if (anchor === 'center') { + x = offset?.arrow?.x || 0; + } + if (anchor === 'right') { + x = -x; + } + + return x; + })(), + top: (() => { + let y = SIDE_ARROW_INSET + (offset?.arrow?.y || 0); + + if (isVerticalPlacement) { + y = offset?.arrow?.y || 0; + } + if (anchor === 'center') { + y = offset?.arrow?.y || 0; + } + if (anchor === 'bottom') { + y = -y; + } + + // 은찬: AOS에서 간혈적으로 박스와 화살표 사이에 틈이 보이는 이슈가 있어서 0.1 추가 + return y + (Platform.OS === 'android' ? 0.1 : 0); + })(), + transform: (() => { + if (placement === 'bottom') return [{ rotate: '0deg' }]; + if (placement === 'top') return [{ rotate: '180deg' }]; + if (placement === 'left') return [{ rotate: '90deg' }]; + if (placement === 'right') return [{ rotate: '-90deg' }]; + return undefined; + })(), + }} + > + {arrowElement === undefined && arrowElement} + + + ); + + const renderHorizontalArrow = () => ( + { + let left = 0; + if (placement === 'left') { + left = -2; + } + if (placement === 'right') { + left = 2; + } + + // 은찬: AOS에서 간혈적으로 박스와 화살표 사이에 틈이 보이는 이슈가 있어서 0.1 추가 + return left + (Platform.OS === 'android' ? 0.1 : 0); + })(), + top: (() => { + return 0; + })(), + transform: (() => { + if (placement === 'bottom') return [{ rotate: '0deg' }]; + if (placement === 'top') return [{ rotate: '180deg' }]; + if (placement === 'left') return [{ rotate: '90deg' }]; + if (placement === 'right') return [{ rotate: '-90deg' }]; + return undefined; + })(), + }} + > + {arrowElement === undefined && arrowElement} + + ); + + const transformsStyle = (() => { + const { x, y } = getAnchorPoint(placement, anchor); + + if (currentIsVisible) { + Animated.spring(animatedValue, { + toValue: 1, + speed: 6, + useNativeDriver: true, + }).start(); + } else { + animatedValue.setValue(1); + Animated.timing(animatedValue, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(); + } + + return withAnchorPoint( + { transform: [{ scale: animatedValue }] }, + { x, y }, + { width: tooltipSize.width, height: tooltipSize.height } + ); + })(); + + const renderTooltipContent = () => ( + // View Overflow 영역에 있는 Tooltip을 선택하기 위해 TouchableOpacity 사용 + { + setCurrentIsVisible((prevState) => !prevState); + onPress && onPress(); + }} + style={{ + backgroundColor: tooltipColor, + borderRadius: 10, + ...(styles?.tooltipStyle || TOOLTIP_STYLE), + }} + > + + {/* content */} + {typeof text !== 'string' ? ( + text + ) : ( + + )} + + {requiredConfirmation && ( + <> + + + + )} + + + ); + + const renderVerticalTooltip = () => ( + { + if (anchor === 'left') return 'flex-start'; + if (anchor === 'right') return 'flex-end'; + return 'center'; + })(), + top: tooltipPosition.top + (offset?.position?.y || 0), + left: tooltipPosition.left + (offset?.position?.x || 0), + }} + > + + {placement === 'top' && renderTooltipContent()} + {renderVerticalArrow()} + {placement === 'bottom' && renderTooltipContent()} + + + ); + const renderHorizontalTooltip = () => ( + // 툴팁 & 화살표 정렬 기준 ( top, center, bottom ) + { + if (anchor === 'top') return 'flex-start'; + if (anchor === 'bottom') return 'flex-end'; + return 'center'; + })(), + }} + > + + {placement === 'left' && renderTooltipContent()} + {renderHorizontalArrow()} + {placement === 'right' && renderTooltipContent()} + + + ); + + return ( + + {placement === 'top' && renderVerticalTooltip()} + {isVerticalPlacement ? ( + children + ) : ( + + {placement === 'left' && renderHorizontalTooltip()} + {children} + {placement === 'right' && renderHorizontalTooltip()} + + )} + {placement === 'bottom' && renderVerticalTooltip()} + + ); +}; + +export default Tooltip;