├── .babelrc
├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .prettierrc.js
├── LICENSE
├── README.md
├── __tests__
├── __snapshots__
│ ├── geom.test.js.snap
│ └── styles.test.js.snap
├── geom.test.js
└── styles.test.js
├── example.gif
├── jest.setup.js
├── package.json
├── src
├── geom.js
├── styles.js
├── tooltip-children.context.js
├── tooltip.d.ts
└── tooltip.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react-native"],
3 | "retainLines": true
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: '@react-native-community',
4 | };
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | /node_modules
3 |
4 | # Flow Coverage
5 | /coverage/flow-coverage/assets
6 | /coverage/flow-coverage/sourcefiles/
7 | /coverage/flow-coverage/index.html
8 |
9 | # Jest Coverage
10 | /coverage/lcov-report
11 | coverage-summary.json
12 | lcov.info
13 |
14 | # Logs
15 | npm-debug.log
16 |
17 | # vscode settings
18 | .vscode
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | __tests__/
2 | coverage/
3 | .vscode/
4 |
5 | jest.setup.js
6 |
7 | yarn.lock
8 | example.gif
9 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | trailingComma: 'all',
4 | };
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Jason Gaare
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Native Walkthrough Tooltip [](https://www.npmjs.com/package/react-native-walkthrough-tooltip) [](https://www.npmjs.com/package/react-native-walkthrough-tooltip)
2 |
3 | React Native Walkthrough Tooltip is a fullscreen modal that highlights whichever element it wraps.\
4 | When not visible, the wrapped element is displayed normally.
5 |
6 | *Used by* [`react-native-walkthrough`](https://github.com/jasongaare/react-native-walkthrough): a lightweight walkthrough library for React Native using react-native-walkthrough-tooltip
7 |
8 | ### Table of Contents
9 |
10 | - [Installation](#installation)
11 | - [Breaking Changes in Version 1.0](#breaking-changes-in-version-10)
12 | - [Example Usage](#example-usage)
13 | - [Screenshot](#screenshot)
14 | - [How it works](#how-it-works)
15 | - [Props](#props)
16 | - [Style Props](#style-props)
17 | - [Class definitions for props](#class-definitions-for-props)
18 | - [TooltipChildrenContext](#tooltipchildrencontext)
19 |
20 | ### Installation
21 |
22 | ```bash
23 | yarn add react-native-walkthrough-tooltip
24 | ```
25 |
26 | ### Breaking Changes in Version 1.0
27 |
28 | For Version 1.0, the library was refactored and simplified.
29 |
30 | - **No more `animated` prop** - if you want to have your tooltips animated, use the last stable version: `0.6.1`. Hopefully animations can be added again in the sure (great idea for a PR!)
31 | - **No more `displayArea` and `childlessPlacementPadding` props** - these have been replaced with the `displayInsets` prop, which allows you to simply declare how many pixels in from each side of the screen to inset the area the tooltip may display.
32 | - **Tooltips are now bound by the displayInsets** - before if your content was larger than the displayArea prop, the tooltip would render outside of the display area. Now the tooltip should always resize to be inside the display area as defined by the `displayInsets` prop
33 | - **Removed the "auto" option for placement** - you must now specify a direction
34 | - **Added the "center" option for _childless_ placement** - option to center the tooltip within the bounds of the `displayInsets` when it does not point to a child
35 | - **Added `useReactNativeModal` prop** - this allows you to enable/disable the usage of React Native's `Modal` component to render the tooltip content. It is true by default.
36 |
37 | Changes to handling users pressing the tooltip child element:
38 |
39 | - **No more `onChildPress` and `onChildLongPress` props** - touches are now passed to the child by default. This allows you to maintain the original functionality of the child element. Further, the tooltip will also automatically dismiss on interaction with the child element.
40 | - **Added `closeOnChildInteraction` prop** - if you want the user to be able to interact with the child element, but not automatically dismiss the tooltip when they do so, set this to false (true by default)
41 | - **Added `allowChildInteraction` prop** - if you'd like to disable interaction with the child element, set this to false (true by default). When false, tapping on the child element will call `onClose` as if the user touched the background element.
42 |
43 | ### Example Usage
44 |
45 | To see an expo snack example, click [here](https://snack.expo.io/@matthewliuhello/react-native-walkthrough-tooltip-example)
46 |
47 | ```js
48 | import Tooltip from 'react-native-walkthrough-tooltip';
49 |
50 | Check this out!}
53 | placement="top"
54 | onClose={() => this.setState({ toolTipVisible: false })}
55 | >
56 |
57 | Press me
58 |
59 |
60 | ```
61 |
62 | ### Screenshot
63 |
64 |
65 |
66 | ### How it works
67 |
68 | The tooltip wraps an element _in place_ in your React Native rendering. When it renders, it measures the location of the element, using React Native's
69 | [measure](https://facebook.github.io/react-native/docs/direct-manipulation.html#measurecallback). When the tooltip is displayed, it renders a _copy_ of the wrapped element positioned absolutely on the screen at the coordinates returned after measuring ([see `TooltipChildrenContext` below](#tooltipchildrencontext) if you need to tell the difference between the _copy_ and the _original_ element). This allows you to touch the element in the tooltip modal rendered above your current screen.
70 |
71 | ### Props
72 |
73 | | Prop name | Type | Default value | Description |
74 | | ---------------- | ---------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75 | | accessible | bool| true | Set this to `false` if you do not want the root touchable element to be accessible. [See docs on accessible here](https://reactnative.dev/docs/accessibility#accessibility-properties)
76 | | allowChildInteraction | bool| true | By default, the user can touch and interact with the child element. When this prop is false, the user cannot interact with the child element while the tooltip is visible. |
77 | | arrowSize | `Size` | { width: 16, height: 8 } | The dimensions of the arrow on the bubble pointing to the highlighted element |
78 | | backgroundColor | string | 'rgba(0,0,0,0.5)' | Color of the fullscreen background beneath the tooltip. **_Overrides_** the `backgroundStyle` prop |
79 | | childContentSpacing | number | 4 | The distance between the tooltip-rendered child and the arrow pointing to it |
80 | | closeOnChildInteraction | bool | true | When child interaction is allowed, this prop determines if `onClose` should be called when the user interacts with the child element. Default is true (usually means the tooltip will dismiss once the user touches the element highlighted) |
81 | | closeOnContentInteraction | bool | true | this prop determines if `onClose` should be called when the user interacts with the content element. Default is true (usually means the tooltip will dismiss once the user touches the content element) |
82 | | content | function/Element | `` | This is the view displayed in the tooltip popover bubble |
83 | | displayInsets | object | { top: 24, bottom: 24, left: 24, right: 24 } | The number of pixels to inset the tooltip on the screen (think of it like padding). The tooltip bubble should never render outside of these insets, so you may need to adjust your `content` accordingly |
84 | | disableShadow | bool | false | When true, tooltips will not appear elevated. Disabling shadows will remove the warning: `RCTView has a shadow set but cannot calculate shadow efficiently` on IOS devices. |
85 | | isVisible | bool | false | When true, tooltip is displayed | |
86 | | onClose | function | null | Callback fired when the user taps the tooltip background overlay |
87 | | placement | string | "top" \| "center" | Where to position the tooltip - options: `top, bottom, left, right, center`. Default is `top` for tooltips rendered with children Default is `center` for tooltips rendered without children.
NOTE: `center` is only available with a childless placement, and the content will be centered within the bounds defined by the `displayInsets`. |
88 | | showChildInTooltip | bool | true | Set this to `false` if you do NOT want to display the child alongside the tooltip when the tooltip is visible |
89 | | supportedOrientations | array | ["portrait", "landscape"] | This prop allows you to control the supported orientations the tooltip modal can be displayed. It correlates directly with [the prop for React Native's Modal component](https://facebook.github.io/react-native/docs/modal#supportedorientations) (has no effect if `useReactNativeModal` is false) |
90 | | topAdjustment | number | 0 | Value which provides additional vertical offest for the child element displayed in a tooltip. Commonly set to: `Platform.OS === 'android' ? -StatusBar.currentHeight : 0` due to an issue with React Native's measure function on Android
91 | | horizontalAdjustment | number | 0 | Value which provides additional horizontal offest for the child element displayed in a tooltip. This is useful for adjusting the horizontal positioning of a highlighted child element if needed
92 | | useInteractionManager | bool | false | Set this to true if you want the tooltip to wait to become visible until the callback for `InteractionManager.runAfterInteractions` is executed. Can be useful if you need to wait for navigation transitions to complete, etc. [See docs on InteractionManager here](https://facebook.github.io/react-native/docs/interactionmanager)
93 | | useReactNativeModal | bool| true | By default, this library uses a `` component from React Native. If you need to disable this, and simply render an absolutely positioned full-screen view, set `useReactNativeModal={false}`. This is especially useful if you desire to render a Tooltip while you have a different `Modal` rendered.
94 |
95 | ### Style Props
96 |
97 | The tooltip styles should work out-of-the-box for most use cases, however should you need you can customize the styles of the tooltip using these props.
98 |
99 | | Prop name | Effect |
100 | | -------------------- | ------------------------------------------------------------------------------- |
101 | | arrowStyle | Styles the triangle that points to the called out element |
102 | | backgroundStyle | Styles the overlay view that sits behind the tooltip, but over the current view |
103 | | childrenWrapperStyle | Styles the view that wraps cloned children |
104 | | contentStyle | Styles the content wrapper that surrounds the `content` element |
105 | | tooltipStyle | Styles the tooltip that wraps the arrow and content elements |
106 |
107 | ### Class definitions for props
108 |
109 | * `Size` is an object with properties: `{ width: number, height: number }`
110 |
111 | ### TooltipChildrenContext
112 |
113 | [React Context](https://reactjs.org/docs/context.html) that can be used to distinguish "real" children rendered inside parent's layout from their copies rendered inside tooltip's modal. The duplicate child rendered in the tooltip modal is wrapped in a Context.Provider which provides object with prop `tooltipDuplicate` set to `true`, so informed decisions may be made, if necessary, based on where the child rendered.
114 |
115 | ```js
116 | import Tooltip, { TooltipChildrenContext } from 'react-native-walkthrough-tooltip';
117 | ...
118 |
119 |
120 |
121 |
122 | {({ tooltipDuplicate }) => (
123 | // will only assign a ref to the original component
124 |
125 | )}
126 |
127 |
128 |
129 | ```
130 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/geom.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Testing Computing Geometry correctly calculates bottom geometry 1`] = `
4 | Object {
5 | "anchorPoint": Point {
6 | "x": 56,
7 | "y": 414,
8 | },
9 | "placement": "bottom",
10 | "tooltipOrigin": Point {
11 | "x": 0,
12 | "y": 422,
13 | },
14 | }
15 | `;
16 |
17 | exports[`Testing Computing Geometry correctly calculates bottom geometry 2`] = `
18 | Object {
19 | "anchorPoint": Point {
20 | "x": 272,
21 | "y": 414,
22 | },
23 | "placement": "bottom",
24 | "tooltipOrigin": Point {
25 | "x": 197,
26 | "y": 432,
27 | },
28 | }
29 | `;
30 |
31 | exports[`Testing Computing Geometry correctly calculates bottom geometry 3`] = `
32 | Object {
33 | "anchorPoint": Point {
34 | "x": 99,
35 | "y": 235,
36 | },
37 | "placement": "bottom",
38 | "tooltipOrigin": Point {
39 | "x": 0,
40 | "y": 243,
41 | },
42 | }
43 | `;
44 |
45 | exports[`Testing Computing Geometry correctly calculates left geometry 1`] = `
46 | Object {
47 | "anchorPoint": Point {
48 | "x": 24,
49 | "y": 382,
50 | },
51 | "placement": "left",
52 | "tooltipOrigin": Point {
53 | "x": -192,
54 | "y": 332,
55 | },
56 | }
57 | `;
58 |
59 | exports[`Testing Computing Geometry correctly calculates left geometry 2`] = `
60 | Object {
61 | "anchorPoint": Point {
62 | "x": 240,
63 | "y": 382,
64 | },
65 | "placement": "left",
66 | "tooltipOrigin": Point {
67 | "x": 83,
68 | "y": 282,
69 | },
70 | }
71 | `;
72 |
73 | exports[`Testing Computing Geometry correctly calculates left geometry 3`] = `
74 | Object {
75 | "anchorPoint": Point {
76 | "x": 24,
77 | "y": 135,
78 | },
79 | "placement": "left",
80 | "tooltipOrigin": Point {
81 | "x": -292,
82 | "y": 0,
83 | },
84 | }
85 | `;
86 |
87 | exports[`Testing Computing Geometry correctly calculates right geometry 1`] = `
88 | Object {
89 | "anchorPoint": Point {
90 | "x": 88,
91 | "y": 382,
92 | },
93 | "placement": "right",
94 | "tooltipOrigin": Point {
95 | "x": 104,
96 | "y": 332,
97 | },
98 | }
99 | `;
100 |
101 | exports[`Testing Computing Geometry correctly calculates right geometry 2`] = `
102 | Object {
103 | "anchorPoint": Point {
104 | "x": 304,
105 | "y": 382,
106 | },
107 | "placement": "right",
108 | "tooltipOrigin": Point {
109 | "x": 311,
110 | "y": 282,
111 | },
112 | }
113 | `;
114 |
115 | exports[`Testing Computing Geometry correctly calculates right geometry 3`] = `
116 | Object {
117 | "anchorPoint": Point {
118 | "x": 174,
119 | "y": 135,
120 | },
121 | "placement": "right",
122 | "tooltipOrigin": Point {
123 | "x": 190,
124 | "y": 0,
125 | },
126 | }
127 | `;
128 |
129 | exports[`Testing Computing Geometry correctly calculates top geometry 1`] = `
130 | Object {
131 | "anchorPoint": Point {
132 | "x": 56,
133 | "y": 350,
134 | },
135 | "placement": "top",
136 | "tooltipOrigin": Point {
137 | "x": 0,
138 | "y": 242,
139 | },
140 | }
141 | `;
142 |
143 | exports[`Testing Computing Geometry correctly calculates top geometry 2`] = `
144 | Object {
145 | "anchorPoint": Point {
146 | "x": 272,
147 | "y": 350,
148 | },
149 | "placement": "top",
150 | "tooltipOrigin": Point {
151 | "x": 197,
152 | "y": 132,
153 | },
154 | }
155 | `;
156 |
157 | exports[`Testing Computing Geometry correctly calculates top geometry 3`] = `
158 | Object {
159 | "anchorPoint": Point {
160 | "x": 99,
161 | "y": 35,
162 | },
163 | "placement": "top",
164 | "tooltipOrigin": Point {
165 | "x": 0,
166 | "y": -473,
167 | },
168 | }
169 | `;
170 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/styles.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`testing styling shows stylesheet is as expected 1`] = `
4 | Object {
5 | "arrow": Object {
6 | "borderBottomColor": "transparent",
7 | "borderLeftColor": "transparent",
8 | "borderRightColor": "transparent",
9 | "borderTopColor": "transparent",
10 | "position": "absolute",
11 | },
12 | "background": Object {
13 | "bottom": 0,
14 | "left": 0,
15 | "position": "absolute",
16 | "right": 0,
17 | "top": 0,
18 | },
19 | "container": Object {
20 | "backgroundColor": "transparent",
21 | "bottom": 0,
22 | "left": 0,
23 | "opacity": 0,
24 | "position": "absolute",
25 | "right": 0,
26 | "top": 0,
27 | },
28 | "containerVisible": Object {
29 | "opacity": 1,
30 | },
31 | "content": Object {
32 | "backgroundColor": "#fff",
33 | "borderRadius": 4,
34 | "padding": 8,
35 | },
36 | "tooltip": Object {
37 | "backgroundColor": "transparent",
38 | "position": "absolute",
39 | "shadowColor": "black",
40 | "shadowOffset": Object {
41 | "height": 2,
42 | "width": 0,
43 | },
44 | "shadowOpacity": 0.8,
45 | "shadowRadius": 2,
46 | },
47 | }
48 | `;
49 |
--------------------------------------------------------------------------------
/__tests__/geom.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | Size,
3 | Rect,
4 | computeTopGeometry,
5 | computeBottomGeometry,
6 | computeLeftGeometry,
7 | computeRightGeometry,
8 | } from '../src/geom';
9 |
10 | const options1 = {
11 | displayArea: new Rect(0, 0, 375, 667),
12 | childRect: new Rect(24, 350, 64, 64),
13 | contentSize: new Size(200, 100),
14 | arrowSize: new Size(16, 8),
15 | };
16 |
17 | const options2 = {
18 | displayArea: new Rect(0, 0, 375, 667),
19 | childRect: new Rect(240, 350, 64, 64),
20 | contentSize: new Size(150, 200),
21 | arrowSize: new Size(7, 18),
22 | };
23 |
24 | const options3 = {
25 | displayArea: new Rect(0, 0, 375, 667),
26 | childRect: new Rect(24, 35, 150, 200),
27 | contentSize: new Size(300, 500),
28 | arrowSize: new Size(16, 8),
29 | };
30 |
31 | describe('Testing Computing Geometry', () => {
32 | it('correctly calculates top geometry', () => {
33 | expect(computeTopGeometry(options1)).toMatchSnapshot();
34 | expect(computeTopGeometry(options2)).toMatchSnapshot();
35 | expect(computeTopGeometry(options3)).toMatchSnapshot();
36 | });
37 |
38 | it('correctly calculates bottom geometry', () => {
39 | expect(computeBottomGeometry(options1)).toMatchSnapshot();
40 | expect(computeBottomGeometry(options2)).toMatchSnapshot();
41 | expect(computeBottomGeometry(options3)).toMatchSnapshot();
42 | });
43 |
44 | it('correctly calculates left geometry', () => {
45 | expect(computeLeftGeometry(options1)).toMatchSnapshot();
46 | expect(computeLeftGeometry(options2)).toMatchSnapshot();
47 | expect(computeLeftGeometry(options3)).toMatchSnapshot();
48 | });
49 |
50 | it('correctly calculates right geometry', () => {
51 | expect(computeRightGeometry(options1)).toMatchSnapshot();
52 | expect(computeRightGeometry(options2)).toMatchSnapshot();
53 | expect(computeRightGeometry(options3)).toMatchSnapshot();
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/__tests__/styles.test.js:
--------------------------------------------------------------------------------
1 | import styles from '../src/styles';
2 |
3 | describe('testing styling', () => {
4 | it('shows stylesheet is as expected', () => {
5 | expect(styles).toMatchSnapshot();
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasongaare/react-native-walkthrough-tooltip/7135589217f82f3cbc8dda51b2e1c30f374417a9/example.gif
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-walkthrough-tooltip",
3 | "version": "1.6.0",
4 | "description": "An inline wrapper for calling out React Native components via tooltip",
5 | "main": "src/tooltip.js",
6 | "scripts": {
7 | "test": "jest"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/jasongaare/react-native-walkthrough-tooltip.git"
12 | },
13 | "author": "Jason Gaare",
14 | "license": "MIT",
15 | "bugs": {
16 | "url": "https://github.com/jasongaare/react-native-walkthrough-tooltip/issues"
17 | },
18 | "homepage": "https://github.com/jasongaare/react-native-walkthrough-tooltip#readme",
19 | "keywords": [
20 | "react",
21 | "react-native",
22 | "walkthrough",
23 | "tooltip",
24 | "popover"
25 | ],
26 | "dependencies": {
27 | "prop-types": "^15.6.1",
28 | "react-fast-compare": "^2.0.4"
29 | },
30 | "devDependencies": {
31 | "@react-native-community/eslint-config": "0.0.5",
32 | "babel-core": "6.26.3",
33 | "babel-eslint": "8.2.3",
34 | "babel-jest": "22.4.4",
35 | "enzyme": "3.3.0",
36 | "enzyme-adapter-react-16": "1.1.1",
37 | "enzyme-to-json": "3.3.4",
38 | "eslint": "4.19.1",
39 | "jest": "22.4.4",
40 | "react": "^16.3.2",
41 | "react-dom": "16.3.3",
42 | "react-native": "^0.55.3"
43 | },
44 | "peerDependencies": {
45 | "@types/react": ">=16.8.24"
46 | },
47 | "jest": {
48 | "preset": "react-native",
49 | "collectCoverageFrom": [
50 | "src/**.js"
51 | ],
52 | "coverageReporters": [
53 | "json-summary",
54 | "text",
55 | "lcov"
56 | ],
57 | "snapshotSerializers": [
58 | "enzyme-to-json/serializer"
59 | ],
60 | "setupFiles": [
61 | "./jest.setup.js"
62 | ],
63 | "transformIgnorePatterns": [
64 | "node_modules/(?!(jest-)?react-native)"
65 | ]
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/geom.js:
--------------------------------------------------------------------------------
1 | class Point {
2 | constructor(x, y) {
3 | this.x = x;
4 | this.y = y;
5 | }
6 | }
7 |
8 | class Size {
9 | constructor(width, height) {
10 | this.width = width;
11 | this.height = height;
12 | }
13 | }
14 |
15 | class Rect {
16 | constructor(x, y, width, height) {
17 | this.x = x;
18 | this.y = y;
19 | this.width = width;
20 | this.height = height;
21 | }
22 | }
23 |
24 | const swapSizeDimmensions = size => new Size(size.height, size.width);
25 |
26 | const makeChildlessRect = ({ displayInsets, windowDims, placement }) => {
27 | switch (placement) {
28 | case 'bottom':
29 | return new Rect(windowDims.width / 2, displayInsets.top, 1, 1);
30 |
31 | case 'right':
32 | return new Rect(displayInsets.left, windowDims.height / 2, 1, 1);
33 |
34 | case 'left':
35 | return new Rect(
36 | windowDims.width - displayInsets.right,
37 | windowDims.height / 2,
38 | 1,
39 | 1,
40 | );
41 | case 'top':
42 | default:
43 | return new Rect(
44 | windowDims.width / 2,
45 | windowDims.height - displayInsets.bottom,
46 | 1,
47 | 1,
48 | );
49 | }
50 | };
51 |
52 | const computeCenterGeometry = ({
53 | childRect,
54 | contentSize,
55 | displayInsets,
56 | windowDims,
57 | }) => {
58 | const maxWidth =
59 | windowDims.width - (displayInsets.left + displayInsets.right);
60 | const maxHeight =
61 | windowDims.height - (displayInsets.top + displayInsets.bottom);
62 |
63 | const adjustedContentSize = new Size(
64 | contentSize.width >= maxWidth ? maxWidth : -1,
65 | contentSize.height >= maxHeight ? maxHeight : -1,
66 | );
67 |
68 | const tooltipOrigin = new Point(
69 | adjustedContentSize.width === -1
70 | ? (maxWidth - contentSize.width) / 2 + displayInsets.left
71 | : displayInsets.left,
72 | adjustedContentSize.height === -1
73 | ? (maxHeight - contentSize.height) / 2 + displayInsets.top
74 | : displayInsets.top,
75 | );
76 |
77 | const anchorPoint = new Point(
78 | childRect.x + childRect.width / 2.0,
79 | childRect.y,
80 | );
81 |
82 | return {
83 | tooltipOrigin,
84 | anchorPoint,
85 | placement: 'center',
86 | adjustedContentSize,
87 | };
88 | };
89 |
90 | const computeTopGeometry = ({
91 | childRect,
92 | contentSize,
93 | arrowSize,
94 | displayInsets,
95 | windowDims,
96 | childContentSpacing,
97 | }) => {
98 | const maxWidth =
99 | windowDims.width - (displayInsets.left + displayInsets.right);
100 |
101 | const adjustedContentSize = new Size(
102 | Math.min(maxWidth, contentSize.width),
103 | contentSize.height,
104 | );
105 |
106 | const tooltipOrigin = new Point(
107 | contentSize.width >= maxWidth
108 | ? displayInsets.left
109 | : Math.max(
110 | displayInsets.left,
111 | childRect.x + (childRect.width - adjustedContentSize.width) / 2,
112 | ),
113 | Math.max(
114 | displayInsets.top - childContentSpacing,
115 | childRect.y - contentSize.height - arrowSize.height - childContentSpacing,
116 | ),
117 | );
118 |
119 | const anchorPoint = new Point(
120 | childRect.x + childRect.width / 2.0,
121 | childRect.y - childContentSpacing,
122 | );
123 |
124 | // make sure arrow does not extend beyond displayInsets
125 | if (
126 | anchorPoint.x + arrowSize.width >
127 | windowDims.width - displayInsets.right
128 | ) {
129 | anchorPoint.x =
130 | windowDims.width -
131 | displayInsets.right -
132 | Math.abs(arrowSize.width - arrowSize.height) -
133 | 8;
134 | } else if (anchorPoint.x - arrowSize.width < displayInsets.left) {
135 | anchorPoint.x =
136 | displayInsets.left + Math.abs(arrowSize.width - arrowSize.height) + 8;
137 | }
138 |
139 | const topPlacementBottomBound = anchorPoint.y - arrowSize.height;
140 |
141 | if (tooltipOrigin.y + contentSize.height > topPlacementBottomBound) {
142 | adjustedContentSize.height = topPlacementBottomBound - tooltipOrigin.y;
143 | }
144 |
145 | if (tooltipOrigin.x + contentSize.width > maxWidth) {
146 | tooltipOrigin.x =
147 | windowDims.width - displayInsets.right - adjustedContentSize.width;
148 | }
149 |
150 | return {
151 | tooltipOrigin,
152 | anchorPoint,
153 | placement: 'top',
154 | adjustedContentSize,
155 | };
156 | };
157 |
158 | const computeBottomGeometry = ({
159 | childRect,
160 | contentSize,
161 | arrowSize,
162 | displayInsets,
163 | windowDims,
164 | childContentSpacing,
165 | }) => {
166 | const maxWidth =
167 | windowDims.width - (displayInsets.left + displayInsets.right);
168 |
169 | const adjustedContentSize = new Size(
170 | Math.min(maxWidth, contentSize.width),
171 | contentSize.height,
172 | );
173 |
174 | const tooltipOrigin = new Point(
175 | contentSize.width >= maxWidth
176 | ? displayInsets.left
177 | : Math.max(
178 | displayInsets.left,
179 | childRect.x + (childRect.width - adjustedContentSize.width) / 2,
180 | ),
181 | Math.min(
182 | windowDims.height - displayInsets.bottom + childContentSpacing,
183 | childRect.y + childRect.height + arrowSize.height + childContentSpacing,
184 | ),
185 | );
186 | const anchorPoint = new Point(
187 | childRect.x + childRect.width / 2.0,
188 | childRect.y + childRect.height + childContentSpacing,
189 | );
190 |
191 | // make sure arrow does not extend beyond displayInsets
192 | if (
193 | anchorPoint.x + arrowSize.width >
194 | windowDims.width - displayInsets.right
195 | ) {
196 | anchorPoint.x =
197 | windowDims.width -
198 | displayInsets.right -
199 | Math.abs(arrowSize.width - arrowSize.height) -
200 | 8;
201 | } else if (anchorPoint.x - arrowSize.width < displayInsets.left) {
202 | anchorPoint.x =
203 | displayInsets.left + Math.abs(arrowSize.width - arrowSize.height) + 8;
204 | }
205 |
206 | if (
207 | tooltipOrigin.y + contentSize.height >
208 | windowDims.height - displayInsets.bottom
209 | ) {
210 | adjustedContentSize.height =
211 | windowDims.height - displayInsets.bottom - tooltipOrigin.y;
212 | }
213 |
214 | if (tooltipOrigin.x + contentSize.width > maxWidth) {
215 | tooltipOrigin.x =
216 | windowDims.width - displayInsets.right - adjustedContentSize.width;
217 | }
218 |
219 | return {
220 | tooltipOrigin,
221 | anchorPoint,
222 | placement: 'bottom',
223 | adjustedContentSize,
224 | };
225 | };
226 |
227 | const computeLeftGeometry = ({
228 | childRect,
229 | contentSize,
230 | arrowSize,
231 | displayInsets,
232 | windowDims,
233 | childContentSpacing,
234 | }) => {
235 | const maxHeight =
236 | windowDims.height - (displayInsets.top + displayInsets.bottom);
237 |
238 | const adjustedContentSize = new Size(
239 | contentSize.width,
240 | Math.min(maxHeight, contentSize.height),
241 | );
242 |
243 | const tooltipOrigin = new Point(
244 | Math.max(
245 | displayInsets.left - childContentSpacing,
246 | childRect.x - contentSize.width - arrowSize.width - childContentSpacing,
247 | ),
248 | contentSize.height >= maxHeight
249 | ? displayInsets.top
250 | : Math.max(
251 | displayInsets.top,
252 | childRect.y + (childRect.height - adjustedContentSize.height) / 2,
253 | ),
254 | );
255 |
256 | const anchorPoint = new Point(
257 | childRect.x - childContentSpacing,
258 | childRect.y + childRect.height / 2.0,
259 | );
260 |
261 | // make sure arrow does not extend beyond displayInsets
262 | if (
263 | anchorPoint.y + arrowSize.width >
264 | windowDims.height - displayInsets.bottom
265 | ) {
266 | anchorPoint.y =
267 | windowDims.height -
268 | displayInsets.bottom -
269 | Math.abs(arrowSize.height - arrowSize.width) -
270 | 8;
271 | } else if (anchorPoint.y - arrowSize.height < displayInsets.top) {
272 | anchorPoint.y =
273 | displayInsets.top + Math.abs(arrowSize.height - arrowSize.width) + 8;
274 | }
275 |
276 | const leftPlacementRightBound = anchorPoint.x - arrowSize.width;
277 |
278 | if (tooltipOrigin.x + contentSize.width > leftPlacementRightBound) {
279 | adjustedContentSize.width = leftPlacementRightBound - tooltipOrigin.x;
280 | }
281 |
282 | if (tooltipOrigin.y + contentSize.height > maxHeight) {
283 | tooltipOrigin.y =
284 | windowDims.height - displayInsets.bottom - adjustedContentSize.height;
285 | }
286 |
287 | return {
288 | tooltipOrigin,
289 | anchorPoint,
290 | placement: 'left',
291 | adjustedContentSize,
292 | };
293 | };
294 |
295 | const computeRightGeometry = ({
296 | childRect,
297 | contentSize,
298 | arrowSize,
299 | displayInsets,
300 | windowDims,
301 | childContentSpacing,
302 | }) => {
303 | const maxHeight =
304 | windowDims.height - (displayInsets.top + displayInsets.bottom);
305 |
306 | const adjustedContentSize = new Size(
307 | contentSize.width,
308 | Math.min(maxHeight, contentSize.height),
309 | );
310 |
311 | const tooltipOrigin = new Point(
312 | Math.min(
313 | windowDims.width - displayInsets.right + childContentSpacing,
314 | childRect.x + childRect.width + arrowSize.width + childContentSpacing,
315 | ),
316 | contentSize.height >= maxHeight
317 | ? displayInsets.top
318 | : Math.max(
319 | displayInsets.top,
320 | childRect.y + (childRect.height - adjustedContentSize.height) / 2,
321 | ),
322 | );
323 |
324 | const anchorPoint = new Point(
325 | childRect.x + childRect.width + childContentSpacing,
326 | childRect.y + childRect.height / 2.0,
327 | );
328 |
329 | // make sure arrow does not extend beyond displayInsets
330 | if (
331 | anchorPoint.y + arrowSize.width >
332 | windowDims.height - displayInsets.bottom
333 | ) {
334 | anchorPoint.y =
335 | windowDims.height -
336 | displayInsets.bottom -
337 | Math.abs(arrowSize.height - arrowSize.width) -
338 | 8;
339 | } else if (anchorPoint.y - arrowSize.height < displayInsets.top) {
340 | anchorPoint.y =
341 | displayInsets.top + Math.abs(arrowSize.height - arrowSize.width) + 8;
342 | }
343 |
344 | if (
345 | tooltipOrigin.x + contentSize.width >
346 | windowDims.width - displayInsets.right
347 | ) {
348 | adjustedContentSize.width =
349 | windowDims.width - displayInsets.right - tooltipOrigin.x;
350 | }
351 |
352 | if (tooltipOrigin.y + contentSize.height > maxHeight) {
353 | tooltipOrigin.y =
354 | windowDims.height - displayInsets.bottom - adjustedContentSize.height;
355 | }
356 |
357 | return {
358 | tooltipOrigin,
359 | anchorPoint,
360 | placement: 'right',
361 | adjustedContentSize,
362 | };
363 | };
364 |
365 | export {
366 | Point,
367 | Size,
368 | Rect,
369 | swapSizeDimmensions,
370 | makeChildlessRect,
371 | computeCenterGeometry,
372 | computeTopGeometry,
373 | computeBottomGeometry,
374 | computeLeftGeometry,
375 | computeRightGeometry,
376 | };
377 |
--------------------------------------------------------------------------------
/src/styles.js:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from 'react-native';
2 |
3 | const styles = StyleSheet.create({
4 | container: {
5 | ...StyleSheet.absoluteFillObject,
6 | opacity: 0,
7 | backgroundColor: 'transparent',
8 | zIndex: 500,
9 | },
10 | containerVisible: {
11 | opacity: 1,
12 | },
13 | background: {
14 | ...StyleSheet.absoluteFillObject,
15 | },
16 | tooltip: {
17 | backgroundColor: 'transparent',
18 | position: 'absolute',
19 | },
20 | shadow: {
21 | shadowColor: 'black',
22 | shadowOffset: { width: 0, height: 2 },
23 | shadowRadius: 2,
24 | shadowOpacity: 0.8,
25 | },
26 | content: {
27 | borderRadius: 4,
28 | padding: 8,
29 | backgroundColor: '#fff',
30 | overflow: 'hidden',
31 | },
32 | arrow: {
33 | position: 'absolute',
34 | borderTopColor: 'transparent',
35 | borderRightColor: 'transparent',
36 | borderBottomColor: 'transparent',
37 | borderLeftColor: 'transparent',
38 | },
39 | });
40 |
41 | const arrowRotationForPlacement = placement => {
42 | switch (placement) {
43 | case 'bottom':
44 | return '180deg';
45 | case 'left':
46 | return '-90deg';
47 | case 'right':
48 | return '90deg';
49 | default:
50 | return '0deg';
51 | }
52 | };
53 |
54 | const arrowPlacementStyles = ({
55 | anchorPoint,
56 | arrowSize,
57 | placement,
58 | tooltipOrigin,
59 | }) => {
60 | // Create the arrow from a rectangle with the appropriate borderXWidth set
61 | // A rotation is then applied dependending on the placement
62 | // Also make it slightly bigger
63 | // to fix a visual artifact when the tooltip is animated with a scale
64 | const width = arrowSize.width + 2;
65 | const height = arrowSize.height * 2 + 2;
66 | let marginTop = 0;
67 | let marginLeft = 0;
68 |
69 | if (placement === 'bottom') {
70 | marginTop = arrowSize.height;
71 | } else if (placement === 'right') {
72 | marginLeft = arrowSize.height;
73 | }
74 |
75 | return {
76 | left: anchorPoint.x - tooltipOrigin.x - (width / 2 - marginLeft),
77 | top: anchorPoint.y - tooltipOrigin.y - (height / 2 - marginTop),
78 | width,
79 | height,
80 | borderTopWidth: height / 2,
81 | borderRightWidth: width / 2,
82 | borderBottomWidth: height / 2,
83 | borderLeftWidth: width / 2,
84 | };
85 | };
86 |
87 | const getArrowRotation = (arrowStyle, placement) => {
88 | // prevent rotation getting incorrectly overwritten
89 | const arrowRotation = arrowRotationForPlacement(placement);
90 | const transform = (StyleSheet.flatten(arrowStyle).transform || []).slice(0);
91 | transform.unshift({ rotate: arrowRotation });
92 |
93 | return { transform };
94 | };
95 |
96 | const tooltipPlacementStyles = ({ arrowSize, placement, tooltipOrigin }) => {
97 | const { height } = arrowSize;
98 |
99 | switch (placement) {
100 | case 'bottom':
101 | return {
102 | paddingTop: height,
103 | top: tooltipOrigin.y - height,
104 | left: tooltipOrigin.x,
105 | };
106 | case 'top':
107 | return {
108 | paddingBottom: height,
109 | top: tooltipOrigin.y,
110 | left: tooltipOrigin.x,
111 | };
112 | case 'right':
113 | return {
114 | paddingLeft: height,
115 | top: tooltipOrigin.y,
116 | left: tooltipOrigin.x - height,
117 | };
118 | case 'left':
119 | return {
120 | paddingRight: height,
121 | top: tooltipOrigin.y,
122 | left: tooltipOrigin.x,
123 | };
124 | case 'center':
125 | default:
126 | return {
127 | top: tooltipOrigin.y,
128 | left: tooltipOrigin.x,
129 | };
130 | }
131 | };
132 |
133 | const styleGenerator = styleGeneratorProps => {
134 | const {
135 | adjustedContentSize,
136 | displayInsets,
137 | measurementsFinished,
138 | ownProps,
139 | placement,
140 | topAdjustment,
141 | } = styleGeneratorProps;
142 |
143 | const { height, width } = adjustedContentSize;
144 | const { backgroundColor } = ownProps;
145 |
146 | const contentStyle = [
147 | styles.content,
148 | height > 0 && { height }, // ignore special case of -1 with center placement (and 0 when not yet measured)
149 | width > 0 && { width }, // ignore special case of -1 with center placement (and 0 when not yet measured)
150 | ownProps.contentStyle,
151 | ];
152 |
153 | const contentBackgroundColor = StyleSheet.flatten(contentStyle)
154 | .backgroundColor;
155 |
156 | const arrowStyle = [
157 | styles.arrow,
158 | arrowPlacementStyles(styleGeneratorProps),
159 | { borderTopColor: contentBackgroundColor },
160 | ownProps.arrowStyle,
161 | ];
162 |
163 | return {
164 | arrowStyle: [...arrowStyle, getArrowRotation(arrowStyle, placement)],
165 | backgroundStyle: [
166 | styles.background,
167 | ownProps.backgroundStyle,
168 | {
169 | paddingTop: displayInsets.top,
170 | paddingLeft: displayInsets.left,
171 | paddingRight: displayInsets.right,
172 | paddingBottom: displayInsets.bottom,
173 | backgroundColor,
174 | },
175 | ],
176 | containerStyle: [
177 | styles.container,
178 | StyleSheet.compose(
179 | adjustedContentSize.width !== 0 &&
180 | measurementsFinished &&
181 | styles.containerVisible,
182 | topAdjustment !== 0 && {
183 | top: topAdjustment,
184 | },
185 | ),
186 | ],
187 | contentStyle,
188 | tooltipStyle: [
189 | StyleSheet.compose(
190 | styles.tooltip,
191 | ownProps.disableShadow ? {} : styles.shadow,
192 | ),
193 | tooltipPlacementStyles(styleGeneratorProps),
194 | ownProps.tooltipStyle,
195 | ],
196 | };
197 | };
198 |
199 | export default styleGenerator;
200 |
--------------------------------------------------------------------------------
/src/tooltip-children.context.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default React.createContext({ tooltipDuplicate: false });
4 |
--------------------------------------------------------------------------------
/src/tooltip.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for react-native-walkthrough-tooltip 1.0.0
2 | // Original definitions by: Siraj Alam https://github.com/sirajalam049
3 |
4 | declare module 'react-native-walkthrough-tooltip' {
5 | import React from 'react';
6 | import { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native';
7 |
8 | type Orientation =
9 | | 'portrait'
10 | | 'portrait-upside-down'
11 | | 'landscape'
12 | | 'landscape-left'
13 | | 'landscape-right';
14 |
15 | export interface TooltipSize {
16 | width: number;
17 | height: number;
18 | }
19 |
20 | export interface TooltipDisplayInsets {
21 | top: number;
22 | bottom: number;
23 | left: number;
24 | right: number;
25 | }
26 |
27 | /**
28 | * Style Props
29 | * The tooltip styles should work out-of-the-box for most use cases,
30 | * however should you need you can customize the styles of the tooltip using these props.
31 | */
32 | export interface TooltipStyleProps {
33 | // Styles the triangle that points to the called out element
34 | arrowStyle?: StyleProp;
35 |
36 | // Styles the overlay view that sits behind the tooltip, but over the current view
37 | backgroundStyle?: StyleProp;
38 |
39 | // Styles the content wrapper that surrounds the content element
40 | contentStyle?: StyleProp;
41 |
42 | // Styles the tooltip that wraps the arrow and content elements
43 | tooltipStyle?: StyleProp;
44 |
45 | // Styles the View element that wraps the children to clone it
46 | childrenWrapperStyle?: StyleProp;
47 |
48 | // Styles the view element that wraps the original children
49 | parentWrapperStyle?: StyleProp
50 | }
51 |
52 | export interface TooltipProps extends Partial {
53 | // When true (default), user can interact with child element
54 | allowChildInteraction?: boolean;
55 |
56 | // The dimensions of the arrow on the bubble pointing to the highlighted element
57 | arrowSize?: TooltipSize;
58 |
59 | // Color of the fullscreen background beneath the tooltip. Overrides the backgroundStyle prop
60 | backgroundColor?: string;
61 |
62 | // When true (default), onClose prop is called when user touches child element
63 | closeOnChildInteraction?: boolean;
64 |
65 | // When true (default), onClose prop is called when user touches content element
66 | closeOnContentInteraction?: boolean;
67 |
68 | // When true (default), onClose prop is called when user touches background element
69 | closeOnBackgroundInteraction?: boolean;
70 |
71 | // This is the view displayed in the tooltip popover bubble
72 | content?: React.ReactElement;
73 |
74 | // The number of pixels to inset the tooltip on the screen
75 | displayInsets?: TooltipDisplayInsets;
76 |
77 | // When true, tooltip shadow aren't displayed
78 | // Fix: https://github.com/jasongaare/react-native-walkthrough-tooltip/issues/81
79 | disableShadow?: boolean;
80 |
81 | // When true, tooltip is displayed
82 | isVisible?: boolean;
83 |
84 | // Callback fired when the user taps the tooltip background overlay
85 | onClose?: (event: GestureResponderEvent) => void;
86 |
87 | /**
88 | * Where to position the tooltip - options: top, bottom, left, right, center.
89 | * Default is 'top' for tooltips rendered with children. Default is 'center' for tooltips
90 | * rendered without children. NOTE: center is only available with a childless placement,
91 | * and the content will be centered within the bounds defined by the displayInsets.
92 | */
93 | placement?: 'top' | 'bottom' | 'left' | 'right' | 'center';
94 |
95 | // Determines if the tooltip's children should be shown in the foreground when the tooltip is visible.
96 | showChildInTooltip?: boolean;
97 |
98 | // The supportedOrientations prop allows the modal to be rotated to any of the specified orientations.
99 | supportedOrientations?: Orientation[];
100 |
101 | /**
102 | * Set this to true if you want the tooltip to wait to become visible until the callback
103 | * from InteractionManager.runAfterInteractions is executed. Can be useful if you need
104 | * to wait for navigation transitions to complete, etc
105 | */
106 | useInteractionManager?: boolean;
107 |
108 | /**
109 | * When false, will not use a React Native Modal component to display tooltip,
110 | * but rather an absolutely positioned view
111 | */
112 | useReactNativeModal?: boolean;
113 |
114 | /**
115 | *The distance between the tooltip-rendered child and the arrow pointing to it
116 | */
117 | childContentSpacing?: number;
118 |
119 | /**
120 | *The top value to set for the container. This is useful to fix the issue with StatusBar in Android.
121 | ```js
122 | // Usage Example
123 |
124 | ```
125 | */
126 | topAdjustment?: number;
127 |
128 | /**
129 | * Horizontal adjustment in pixels for the container. If for some reason the alignment of the child element we are
130 | * highlighting is off, the horizontalAdjustment prop can be used to tweak the horizontal positioning of the child
131 | * element which we are highlighting.
132 | ```js
133 | // Usage Example
134 |
135 | ```
136 | */
137 | horizontalAdjustment?: number;
138 |
139 | /**
140 | *Set this to false if you want to override the default accessible on the root TouchableWithoutFeedback
141 | */
142 | accessible?: boolean;
143 |
144 | /** Will use given component instead of default react-native Modal component **/
145 | modalComponent?: object;
146 |
147 | // Support for nested elements within the Tooltip component.
148 | children?: React.ReactNode;
149 | }
150 |
151 | /**
152 | ```js
153 | // Usage Example
154 | import Tooltip, { TooltipChildrenContext } from 'react-native-walkthrough-tooltip';
155 |
156 |
157 | {({ tooltipDuplicate }) => (
158 |
159 | {children}
160 |
161 | )}
162 |
163 |
164 | ```
165 | */
166 | export const TooltipChildrenContext: React.Context<{
167 | tooltipDuplicate: boolean;
168 | }>;
169 |
170 | /**
171 | ```js
172 | // Simple Usage
173 | import Tooltip from 'react-native-walkthrough-tooltip';
174 | Check this out!}
177 | placement="top"
178 | onClose={() => this.setState({ toolTipVisible: false })}
179 | >
180 |
181 | Press me
182 |
183 |
184 | ```
185 | */
186 | class Tooltip extends React.Component {}
187 |
188 | export default Tooltip;
189 | }
190 |
--------------------------------------------------------------------------------
/src/tooltip.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import {
4 | Dimensions,
5 | InteractionManager,
6 | Modal,
7 | TouchableWithoutFeedback,
8 | View,
9 | } from 'react-native';
10 | import rfcIsEqual from 'react-fast-compare';
11 | import {
12 | Point,
13 | Size,
14 | Rect,
15 | swapSizeDimmensions,
16 | makeChildlessRect,
17 | computeCenterGeometry,
18 | computeTopGeometry,
19 | computeBottomGeometry,
20 | computeLeftGeometry,
21 | computeRightGeometry,
22 | } from './geom';
23 | import styleGenerator from './styles';
24 | import TooltipChildrenContext from './tooltip-children.context';
25 |
26 | export { TooltipChildrenContext };
27 |
28 | const DEFAULT_DISPLAY_INSETS = {
29 | top: 24,
30 | bottom: 24,
31 | left: 24,
32 | right: 24,
33 | };
34 |
35 | const computeDisplayInsets = insetsFromProps =>
36 | Object.assign({}, DEFAULT_DISPLAY_INSETS, insetsFromProps);
37 |
38 | const invertPlacement = placement => {
39 | switch (placement) {
40 | case 'top':
41 | return 'bottom';
42 | case 'bottom':
43 | return 'top';
44 | case 'right':
45 | return 'left';
46 | case 'left':
47 | return 'right';
48 | default:
49 | return placement;
50 | }
51 | };
52 |
53 | class Tooltip extends Component {
54 | static defaultProps = {
55 | allowChildInteraction: true,
56 | arrowSize: new Size(16, 8),
57 | backgroundColor: 'rgba(0,0,0,0.5)',
58 | childContentSpacing: 4,
59 | children: null,
60 | closeOnChildInteraction: true,
61 | closeOnContentInteraction: true,
62 | closeOnBackgroundInteraction: true,
63 | content: ,
64 | displayInsets: {},
65 | disableShadow: false,
66 | isVisible: false,
67 | onClose: () => {
68 | console.warn(
69 | '[react-native-walkthrough-tooltip] onClose prop not provided',
70 | );
71 | },
72 | placement: 'center', // falls back to "top" if there ARE children
73 | showChildInTooltip: true,
74 | supportedOrientations: ['portrait', 'landscape'],
75 | useInteractionManager: false,
76 | useReactNativeModal: true,
77 | topAdjustment: 0,
78 | horizontalAdjustment: 0,
79 | accessible: true,
80 | };
81 |
82 | static propTypes = {
83 | allowChildInteraction: PropTypes.bool,
84 | arrowSize: PropTypes.shape({
85 | height: PropTypes.number,
86 | width: PropTypes.number,
87 | }),
88 | backgroundColor: PropTypes.string,
89 | childContentSpacing: PropTypes.number,
90 | children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
91 | closeOnChildInteraction: PropTypes.bool,
92 | closeOnContentInteraction: PropTypes.bool,
93 | closeOnBackgroundInteraction: PropTypes.bool,
94 | content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
95 | displayInsets: PropTypes.shape({
96 | top: PropTypes.number,
97 | bottom: PropTypes.number,
98 | left: PropTypes.number,
99 | right: PropTypes.number,
100 | }),
101 | disableShadow: PropTypes.bool,
102 | isVisible: PropTypes.bool,
103 | onClose: PropTypes.func,
104 | placement: PropTypes.oneOf(['top', 'left', 'bottom', 'right', 'center']),
105 | showChildInTooltip: PropTypes.bool,
106 | supportedOrientations: PropTypes.arrayOf(PropTypes.string),
107 | useInteractionManager: PropTypes.bool,
108 | useReactNativeModal: PropTypes.bool,
109 | topAdjustment: PropTypes.number,
110 | horizontalAdjustment: PropTypes.number,
111 | accessible: PropTypes.bool,
112 | };
113 |
114 | constructor(props) {
115 | super(props);
116 |
117 | const { isVisible, useInteractionManager } = props;
118 |
119 | this.isMeasuringChild = false;
120 | this.interactionPromise = null;
121 | this.dimensionsSubscription = null;
122 |
123 | this.childWrapper = React.createRef();
124 | this.state = {
125 | // no need to wait for interactions if not visible initially
126 | waitingForInteractions: isVisible && useInteractionManager,
127 | contentSize: new Size(0, 0),
128 | adjustedContentSize: new Size(0, 0),
129 | anchorPoint: new Point(0, 0),
130 | tooltipOrigin: new Point(0, 0),
131 | childRect: new Rect(0, 0, 0, 0),
132 | displayInsets: computeDisplayInsets(props.displayInsets),
133 | // if we have no children, and place the tooltip at the "top" we want it to
134 | // behave like placement "bottom", i.e. display below the top of the screen
135 | placement:
136 | React.Children.count(props.children) === 0
137 | ? invertPlacement(props.placement)
138 | : props.placement,
139 | measurementsFinished: false,
140 | windowDims: Dimensions.get('window'),
141 | };
142 | }
143 |
144 | componentDidMount() {
145 | this.dimensionsSubscription = Dimensions.addEventListener(
146 | 'change',
147 | this.updateWindowDims,
148 | );
149 | }
150 |
151 | componentDidUpdate(prevProps, prevState) {
152 | const { content, isVisible, placement } = this.props;
153 | const { displayInsets } = this.state;
154 |
155 | const contentChanged = !rfcIsEqual(prevProps.content, content);
156 | const placementChanged = prevProps.placement !== placement;
157 | const becameVisible = isVisible && !prevProps.isVisible;
158 | const insetsChanged = !rfcIsEqual(prevState.displayInsets, displayInsets);
159 |
160 | if (contentChanged || placementChanged || becameVisible || insetsChanged) {
161 | setTimeout(() => {
162 | this.measureChildRect();
163 | });
164 | }
165 | }
166 |
167 | componentWillUnmount() {
168 | // removeEventListener deprecated
169 | // https://reactnative.dev/docs/dimensions#removeeventlistener
170 | if (this.dimensionsSubscription?.remove) {
171 | // react native >= 0.65.*
172 | this.dimensionsSubscription.remove();
173 | } else {
174 | // react native < 0.65.*
175 | Dimensions.removeEventListener('change', this.updateWindowDims);
176 | }
177 |
178 | if (this.interactionPromise) {
179 | this.interactionPromise.cancel();
180 | }
181 | }
182 |
183 | static getDerivedStateFromProps(nextProps, prevState) {
184 | const nextState = {};
185 |
186 | // update placement in state if the prop changed
187 | const nextPlacement =
188 | React.Children.count(nextProps.children) === 0
189 | ? invertPlacement(nextProps.placement)
190 | : nextProps.placement;
191 |
192 | if (nextPlacement !== prevState.placement) {
193 | nextState.placement = nextPlacement;
194 | }
195 |
196 | // update computed display insets if they changed
197 | const nextDisplayInsets = computeDisplayInsets(nextProps.displayInsets);
198 | if (!rfcIsEqual(nextDisplayInsets, prevState.displayInsets)) {
199 | nextState.displayInsets = nextDisplayInsets;
200 | }
201 |
202 | // set measurements finished flag to false when tooltip closes
203 | if (prevState.measurementsFinished && !nextProps.isVisible) {
204 | nextState.measurementsFinished = false;
205 | nextState.adjustedContentSize = new Size(0, 0);
206 | }
207 |
208 | if (Object.keys(nextState).length) {
209 | return nextState;
210 | }
211 |
212 | return null;
213 | }
214 |
215 | updateWindowDims = dims => {
216 | this.setState(
217 | {
218 | windowDims: dims.window,
219 | contentSize: new Size(0, 0),
220 | adjustedContentSize: new Size(0, 0),
221 | anchorPoint: new Point(0, 0),
222 | tooltipOrigin: new Point(0, 0),
223 | childRect: new Rect(0, 0, 0, 0),
224 | measurementsFinished: false,
225 | },
226 | () => {
227 | setTimeout(() => {
228 | this.measureChildRect();
229 | }, 500); // give the rotation a moment to finish
230 | },
231 | );
232 | };
233 |
234 | doChildlessPlacement = () => {
235 | this.onChildMeasurementComplete(
236 | makeChildlessRect({
237 | displayInsets: this.state.displayInsets,
238 | placement: this.state.placement, // MUST use from state, not props
239 | windowDims: this.state.windowDims,
240 | }),
241 | );
242 | };
243 |
244 | measureContent = e => {
245 | const { width, height } = e.nativeEvent.layout;
246 | const contentSize = new Size(width, height);
247 | this.setState({ contentSize }, () => {
248 | this.computeGeometry();
249 | });
250 | };
251 |
252 | onChildMeasurementComplete = rect => {
253 | this.setState(
254 | {
255 | childRect: rect,
256 | waitingForInteractions: false,
257 | },
258 | () => {
259 | this.isMeasuringChild = false;
260 | if (this.state.contentSize.width) {
261 | this.computeGeometry();
262 | }
263 | },
264 | );
265 | };
266 |
267 | measureChildRect = () => {
268 | const doMeasurement = () => {
269 | if (!this.isMeasuringChild) {
270 | this.isMeasuringChild = true;
271 | if (
272 | this.childWrapper.current &&
273 | typeof this.childWrapper.current.measure === 'function'
274 | ) {
275 | this.childWrapper.current.measure(
276 | (x, y, width, height, pageX, pageY) => {
277 | const childRect = new Rect(pageX, pageY, width, height);
278 | if (
279 | Object.values(childRect).every(value => value !== undefined)
280 | ) {
281 | this.onChildMeasurementComplete(childRect);
282 | } else {
283 | this.doChildlessPlacement();
284 | }
285 | },
286 | );
287 | } else {
288 | this.doChildlessPlacement();
289 | }
290 | }
291 | };
292 |
293 | if (this.props.useInteractionManager) {
294 | if (this.interactionPromise) {
295 | this.interactionPromise.cancel();
296 | }
297 | this.interactionPromise = InteractionManager.runAfterInteractions(() => {
298 | doMeasurement();
299 | });
300 | } else {
301 | doMeasurement();
302 | }
303 | };
304 |
305 | computeGeometry = () => {
306 | const { arrowSize, childContentSpacing } = this.props;
307 | const {
308 | childRect,
309 | contentSize,
310 | displayInsets,
311 | placement,
312 | windowDims,
313 | } = this.state;
314 |
315 | const options = {
316 | displayInsets,
317 | childRect,
318 | windowDims,
319 | arrowSize:
320 | placement === 'top' || placement === 'bottom'
321 | ? arrowSize
322 | : swapSizeDimmensions(arrowSize),
323 | contentSize,
324 | childContentSpacing,
325 | };
326 |
327 | let geom = computeTopGeometry(options);
328 |
329 | // special case for centered, childless placement tooltip
330 | if (
331 | placement === 'center' &&
332 | React.Children.count(this.props.children) === 0
333 | ) {
334 | geom = computeCenterGeometry(options);
335 | } else {
336 | switch (placement) {
337 | case 'bottom':
338 | geom = computeBottomGeometry(options);
339 | break;
340 | case 'left':
341 | geom = computeLeftGeometry(options);
342 | break;
343 | case 'right':
344 | geom = computeRightGeometry(options);
345 | break;
346 | case 'top':
347 | default:
348 | break; // computed just above if-else-block
349 | }
350 | }
351 |
352 | const { tooltipOrigin, anchorPoint, adjustedContentSize } = geom;
353 |
354 | this.setState({
355 | tooltipOrigin,
356 | anchorPoint,
357 | placement,
358 | measurementsFinished: childRect.width && contentSize.width,
359 | adjustedContentSize,
360 | });
361 | };
362 |
363 | renderChildInTooltip = () => {
364 | let { height, width, x, y } = this.state.childRect;
365 |
366 | if (this.props.horizontalAdjustment) {
367 | x = x + this.props.horizontalAdjustment;
368 | }
369 |
370 | const onTouchEnd = () => {
371 | if (this.props.closeOnChildInteraction) {
372 | this.props.onClose();
373 | }
374 | };
375 |
376 | return (
377 |
378 |
394 | {this.props.children}
395 |
396 |
397 | );
398 | };
399 |
400 | renderContentForTooltip = () => {
401 | const generatedStyles = styleGenerator({
402 | adjustedContentSize: this.state.adjustedContentSize,
403 | anchorPoint: this.state.anchorPoint,
404 | arrowSize: this.props.arrowSize,
405 | displayInsets: this.state.displayInsets,
406 | measurementsFinished: this.state.measurementsFinished,
407 | ownProps: { ...this.props },
408 | placement: this.state.placement,
409 | tooltipOrigin: this.state.tooltipOrigin,
410 | topAdjustment: this.props.topAdjustment,
411 | });
412 |
413 | const hasChildren = React.Children.count(this.props.children) > 0;
414 |
415 | const onPressBackground = () => {
416 | if (this.props.closeOnBackgroundInteraction) {
417 | this.props.onClose();
418 | }
419 | };
420 |
421 | const onPressContent = () => {
422 | if (this.props.closeOnContentInteraction) {
423 | this.props.onClose();
424 | }
425 | };
426 |
427 | return (
428 |
432 |
433 |
434 |
435 | {hasChildren ? : null}
436 |
440 |
444 | {this.props.content}
445 |
446 |
447 |
448 |
449 | {hasChildren && this.props.showChildInTooltip
450 | ? this.renderChildInTooltip()
451 | : null}
452 |
453 |
454 | );
455 | };
456 |
457 | render() {
458 | const {
459 | children,
460 | isVisible,
461 | useReactNativeModal,
462 | modalComponent,
463 | } = this.props;
464 |
465 | const hasChildren = React.Children.count(children) > 0;
466 | const showTooltip = isVisible && !this.state.waitingForInteractions;
467 | const ModalComponent = modalComponent || Modal;
468 |
469 | return (
470 |
471 | {useReactNativeModal ? (
472 |
478 | {this.renderContentForTooltip()}
479 |
480 | ) : null}
481 |
482 | {/* This renders the child element in place in the parent's layout */}
483 | {hasChildren ? (
484 |
489 | {children}
490 |
491 | ) : null}
492 |
493 | {!useReactNativeModal && showTooltip
494 | ? this.renderContentForTooltip()
495 | : null}
496 |
497 | );
498 | }
499 | }
500 |
501 | export default Tooltip;
502 |
--------------------------------------------------------------------------------