diff --git a/README.md b/README.md
index 5566c73..211b880 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ where you step the user through different parts of a screen. Also very useful fo
highlighting an element when the user enters the app from a deep link.
-
+
### ⚠️ Caveats ⚠️
@@ -48,7 +48,24 @@ import {
// Remember to wrap the ROOT of your app in HighlightableElementProvider!
return (
-
+
+
+
+
@@ -60,7 +77,7 @@ return (
*/}
{
// Called when the user clicks outside of the highlighted element.
// Set "highlightedElementId" to nullish to hide the overlay.
diff --git a/example/src/components/FavoriteList/FavoriteItem.tsx b/example/src/components/FavoriteList/FavoriteItem.tsx
index 8888028..4836329 100644
--- a/example/src/components/FavoriteList/FavoriteItem.tsx
+++ b/example/src/components/FavoriteList/FavoriteItem.tsx
@@ -1,6 +1,6 @@
import React from "react";
import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native";
-import { StyleSheet, Image, Text, View } from "react-native";
+import { Alert, Pressable, StyleSheet, Image, Text, View } from "react-native";
export const ITEM_HEIGHT = 60;
const BORDER_RADIUS = 10;
@@ -15,7 +15,10 @@ export type FavoriteItemProps = {
function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteItemProps) {
return (
-
+ Alert.alert("You pressed", title)}
+ >
{title}
@@ -24,7 +27,7 @@ function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteI
{duration}
-
+
);
}
diff --git a/example/src/components/FavoriteList/FavoriteList.tsx b/example/src/components/FavoriteList/FavoriteList.tsx
index 4242da4..5f1b7f0 100644
--- a/example/src/components/FavoriteList/FavoriteList.tsx
+++ b/example/src/components/FavoriteList/FavoriteList.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { Fragment } from "react";
import { StyleSheet, View } from "react-native";
import { HighlightableElement } from "react-native-highlight-overlay";
@@ -53,13 +53,22 @@ function FavoriteList({ setHighlightId }: FavoriteListProps) {
}}
/>
- {FAVORITES_LIST.map((favItem, idx, arr) => (
-
-
-
+ {FAVORITES_LIST.map((favItem) => (
+
+
+
+
+ {/* We don't want to highlight the margin, so place it outside */}
+
+
))}
@@ -73,8 +82,8 @@ const styles = StyleSheet.create({
listContainer: {
flex: 1,
},
- favoriteItem: {
- marginBottom: 15,
+ favoriteItemSpacing: {
+ height: 15,
},
});
diff --git a/example/src/components/SimilarList/SimilarList.tsx b/example/src/components/SimilarList/SimilarList.tsx
index 6b97822..93f9fa3 100644
--- a/example/src/components/SimilarList/SimilarList.tsx
+++ b/example/src/components/SimilarList/SimilarList.tsx
@@ -44,6 +44,10 @@ function SimilarList({ setHighlightId }: SimilarListProps) {
diff --git a/package.json b/package.json
index 3e8c050..f490c1c 100644
--- a/package.json
+++ b/package.json
@@ -121,5 +121,8 @@
}
]
]
+ },
+ "dependencies": {
+ "react-native-svg": "^12.1.1"
}
}
diff --git a/src/HighlightOverlay.tsx b/src/HighlightOverlay.tsx
index 1379d74..3f4b04e 100644
--- a/src/HighlightOverlay.tsx
+++ b/src/HighlightOverlay.tsx
@@ -1,7 +1,10 @@
-import React from "react";
-import { Pressable, StyleSheet, View } from "react-native";
+import React, { useState } from "react";
+import { StyleSheet, View } from "react-native";
+import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
+import constructClipPath from "./constructClipPath";
import { useHighlightableElements } from "./context";
+import type { Bounds } from "./context/context";
export type HighlightOverlayProps = {
highlightedElementId?: string | null;
@@ -13,41 +16,46 @@ function HighlightOverlay({ highlightedElementId, onDismiss }: HighlightOverlayP
const highlightedElementData =
highlightedElementId != null ? elements[highlightedElementId] : null;
+ const [parentSize, setParentSize] = useState();
+
+ const clickthrough = highlightedElementData?.options?.clickthroughHighlight ?? true;
+
return (
-
- {highlightedElementId != null && (
- <>
-
- {highlightedElementData != null && (
-
- {highlightedElementData.node}
-
- )}
- >
+ setParentSize(layout)}
+ pointerEvents="box-none"
+ >
+ {highlightedElementData != null && parentSize != null && (
+
+
+
+
+
+
+
+
+
+
)}
);
}
-const styles = StyleSheet.create({
- underlay: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: "black",
- opacity: 0.7,
- },
- highlightContainer: {
- position: "absolute",
- },
-});
-
export default HighlightOverlay;
diff --git a/src/HighlightableElement.tsx b/src/HighlightableElement.tsx
index d28a379..8d56033 100644
--- a/src/HighlightableElement.tsx
+++ b/src/HighlightableElement.tsx
@@ -4,13 +4,16 @@ import type { HostComponent } from "react-native";
import { View } from "react-native";
import { useHighlightableElements } from "./context";
+import type { HighlightOptions } from "./context/context";
export type HighlightableElementProps = PropsWithChildren<{
/** The id used by the HighlightOverlay to find this element. */
id: string;
+ /** The options that decide how this element should look. If left undefined, it only highlights the element. */
+ options?: HighlightOptions;
}>;
-function HighlightableElement({ id, children }: HighlightableElementProps) {
+function HighlightableElement({ id, options, children }: HighlightableElementProps) {
const ref = useRef(null);
const [_, { addElement, removeElement, rootRef }] = useHighlightableElements();
@@ -26,7 +29,7 @@ function HighlightableElement({ id, children }: HighlightableElementProps) {
// This is a typing error on ReactNative's part. 'rootRef' is a valid reference.
rootRef as unknown as HostComponent,
(x, y, width, height) => {
- addElement(id, children, { x, y, width, height });
+ addElement(id, children, { x, y, width, height }, options);
},
() => {
console.error(`Error measuring layout of focused element with id ${id}.`);
diff --git a/src/constructClipPath.ts b/src/constructClipPath.ts
new file mode 100644
index 0000000..c4d2904
--- /dev/null
+++ b/src/constructClipPath.ts
@@ -0,0 +1,96 @@
+import type { Bounds, ElementsRecord } from "./context/context";
+
+type ElementBounds = {
+ startX: number;
+ startY: number;
+ endX: number;
+ endY: number;
+};
+
+const M = (x: number, y: number) => `M ${x} ${y}`;
+const L = (x: number, y: number) => `L ${x} ${y}`;
+const arc = (toX: number, toY: number, radius: number) =>
+ `A ${radius},${radius} 0 0 0 ${toX},${toY}`;
+const z = "z";
+
+const constructClipPath = (data: ElementsRecord[string], containerSize: Bounds): string => {
+ const parentBounds = {
+ startX: 0,
+ startY: 0,
+ endX: containerSize.width,
+ endY: containerSize.height,
+ };
+
+ switch (data.options?.mode) {
+ case "circle": {
+ const {
+ bounds: { x, y, width, height },
+ options: { padding = 0 },
+ } = data;
+ const radius = Math.max(width, height) / 2;
+ return constructCircularPath(
+ parentBounds,
+ { cx: x + width / 2, cy: y + height / 2 },
+ radius + padding
+ );
+ }
+ case "rectangle": // Fallthrough
+ default: {
+ const padding = data.options?.padding ?? 0;
+ const borderRadius = data.options?.borderRadius ?? 0;
+
+ const startX = data.bounds.x - padding;
+ const endX = startX + data.bounds.width + padding * 2;
+ const startY = data.bounds.y - padding;
+ const endY = startY + data.bounds.height + padding * 2;
+ return constructRectangularPath(
+ parentBounds,
+ { startX, startY, endX, endY },
+ borderRadius
+ );
+ }
+ }
+};
+
+const constructRectangularPath = (
+ parentBounds: ElementBounds,
+ { startX, startY, endX, endY }: ElementBounds,
+ borderRadius: number
+): string => {
+ return [
+ M(parentBounds.startX, parentBounds.startY),
+ L(parentBounds.startX, parentBounds.endY),
+ L(parentBounds.endX, parentBounds.endY),
+ L(parentBounds.endX, parentBounds.startY),
+ z,
+ M(startX, startY + borderRadius),
+ L(startX, endY - borderRadius),
+ arc(startX + borderRadius, endY, borderRadius),
+ L(endX - borderRadius, endY),
+ arc(endX, endY - borderRadius, borderRadius),
+ L(endX, startY + borderRadius),
+ arc(endX - borderRadius, startY, borderRadius),
+ L(startX + borderRadius, startY),
+ arc(startX, startY + borderRadius, borderRadius),
+ ].join(" ");
+};
+
+const constructCircularPath = (
+ parentBounds: ElementBounds,
+ { cx, cy }: { cx: number; cy: number },
+ radius: number
+): string => {
+ return [
+ M(parentBounds.startX, parentBounds.startY),
+ L(parentBounds.startX, parentBounds.endY),
+ L(parentBounds.endX, parentBounds.endY),
+ L(parentBounds.endX, parentBounds.startY),
+ z,
+ M(cx, cy),
+ `m ${-radius} 0`,
+ `a ${radius},${radius} 0 1,0 ${radius * 2},0`,
+ `a ${radius},${radius} 0 1,0 ${-radius * 2},0`,
+ ].join(" ");
+};
+
+export default constructClipPath;
diff --git a/src/context/HighlightableElementProvider.tsx b/src/context/HighlightableElementProvider.tsx
index 52e89ef..440024c 100644
--- a/src/context/HighlightableElementProvider.tsx
+++ b/src/context/HighlightableElementProvider.tsx
@@ -26,8 +26,8 @@ function HighlightableElementProvider({
);
const [elements, setElements] = useState({});
- const addElement = useCallback((id, node, bounds) => {
- setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds } }));
+ const addElement = useCallback((id, node, bounds, options) => {
+ setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds, options } }));
}, []);
const removeElement: RemoveElement = useCallback((id) => {
diff --git a/src/context/context.ts b/src/context/context.ts
index fc34a31..a5511bd 100644
--- a/src/context/context.ts
+++ b/src/context/context.ts
@@ -1,13 +1,59 @@
import React from "react";
+export type HighlightOptions = CommonOptions & (RectangleOptions | CircleOptions);
+
+export type CommonOptions = {
+ /**
+ * If true, allows the user to click elements inside the highlight. Otherwise clicking inside the
+ * highlight will act the same as clicking outside the highlight, calling `onDismiss`.
+ * @default true
+ */
+ clickthroughHighlight?: boolean;
+};
+
+export type RectangleOptions = {
+ mode: "rectangle" | undefined;
+ /**
+ * The border radius of the rectangle.
+ * @default 0
+ */
+ borderRadius?: number;
+ /**
+ * The padding of the rectangle.
+ * @default 0
+ */
+ padding?: number;
+};
+
+export type CircleOptions = {
+ mode: "circle";
+ /**
+ * The padding of the circle.
+ * @default 0
+ */
+ padding?: number;
+};
+
export type Bounds = {
x: number;
y: number;
width: number;
height: number;
};
-export type ElementsRecord = Record;
-export type AddElement = (id: string, node: React.ReactNode, bounds: Bounds) => void;
+export type ElementsRecord = Record<
+ string,
+ {
+ node: React.ReactNode;
+ bounds: Bounds;
+ options?: HighlightOptions;
+ }
+>;
+export type AddElement = (
+ id: string,
+ node: React.ReactNode,
+ bounds: Bounds,
+ options?: HighlightOptions
+) => void;
export type RemoveElement = (id: string) => void;
const HighlightableElementContext = React.createContext<
diff --git a/yarn.lock b/yarn.lock
index 4b8df36..ecca1eb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2686,6 +2686,11 @@ bl@^4.1.0:
inherits "^2.0.4"
readable-stream "^3.4.0"
+boolbase@^1.0.0, boolbase@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+ integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
+
boxen@^5.0.0:
version "5.1.2"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50"
@@ -3426,6 +3431,29 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
+css-select@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef"
+ integrity sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==
+ dependencies:
+ boolbase "^1.0.0"
+ css-what "^3.2.1"
+ domutils "^1.7.0"
+ nth-check "^1.0.2"
+
+css-tree@^1.0.0-alpha.39:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
+ integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
+ dependencies:
+ mdn-data "2.0.14"
+ source-map "^0.6.1"
+
+css-what@^3.2.1:
+ version "3.4.2"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4"
+ integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==
+
cssom@^0.4.4:
version "0.4.4"
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10"
@@ -3684,6 +3712,24 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
+dom-serializer@0:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
+ integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+ dependencies:
+ domelementtype "^2.0.1"
+ entities "^2.0.0"
+
+domelementtype@1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
+ integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+
+domelementtype@^2.0.1:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
+ integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
+
domexception@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
@@ -3691,6 +3737,14 @@ domexception@^2.0.1:
dependencies:
webidl-conversions "^5.0.0"
+domutils@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+ integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
+ dependencies:
+ dom-serializer "0"
+ domelementtype "1"
+
dot-prop@^5.1.0, dot-prop@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
@@ -3742,6 +3796,11 @@ enquirer@^2.3.5:
dependencies:
ansi-colors "^4.1.1"
+entities@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
+ integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
+
envinfo@^7.7.2:
version "7.8.1"
resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475"
@@ -6389,6 +6448,11 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
+mdn-data@2.0.14:
+ version "2.0.14"
+ resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
+ integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
+
meow@^8.0.0:
version "8.1.2"
resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897"
@@ -6973,6 +7037,13 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1:
dependencies:
path-key "^3.0.0"
+nth-check@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
+ integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
+ dependencies:
+ boolbase "~1.0.0"
+
nullthrows@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1"
@@ -7684,6 +7755,14 @@ react-native-codegen@^0.0.7:
jscodeshift "^0.11.0"
nullthrows "^1.1.1"
+react-native-svg@^12.1.1:
+ version "12.1.1"
+ resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-12.1.1.tgz#5f292410b8bcc07bbc52b2da7ceb22caf5bcaaee"
+ integrity sha512-NIAJ8jCnXGCqGWXkkJ1GTzO4a3Md5at5sagYV8Vh4MXYnL4z5Rh428Wahjhh+LIjx40EE5xM5YtwyJBqOIba2Q==
+ dependencies:
+ css-select "^2.1.0"
+ css-tree "^1.0.0-alpha.39"
+
react-native@0.66.0:
version "0.66.0"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.66.0.tgz#99bdd83a9a612a71b94242767989d666d445b007"