Skip to content

Commit 4fc45d9

Browse files
authored
Merge pull request #1 from Charanor/feature/mask-highlight
Mask highlight
2 parents be74670 + 343996a commit 4fc45d9

File tree

11 files changed

+323
-55
lines changed

11 files changed

+323
-55
lines changed

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ where you step the user through different parts of a screen. Also very useful fo
88
highlighting an element when the user enters the app from a deep link.
99

1010
<p align="center">
11-
<img src="https://user-images.githubusercontent.com/16232214/136886173-7cc62e23-9a93-4449-9055-dba580bb6e64.gif" height="500" />
11+
<img src="https://user-images.githubusercontent.com/16232214/137453222-06d4987c-8041-4942-9c57-e85071fb3bd2.gif" height="500" />
1212
</p>
1313

1414
### ⚠️ Caveats ⚠️
@@ -48,7 +48,24 @@ import {
4848
// Remember to wrap the ROOT of your app in HighlightableElementProvider!
4949
return (
5050
<HighlightableElementProvider>
51-
<HighlightableElement id="important_item">
51+
<HighlightableElement
52+
id="important_item_1"
53+
options={{
54+
// Options are useful if you want to configure the highlight, but can be left blank.
55+
mode: "rectangle",
56+
padding: 5,
57+
borderRadius: 15,
58+
}}
59+
>
60+
<TheRestOfTheOwl />
61+
</HighlightableElement>
62+
<HighlightableElement
63+
id="important_item_2"
64+
options={{
65+
mode: "circle",
66+
padding: 5,
67+
}}
68+
>
5269
<TheRestOfTheOwl />
5370
</HighlightableElement>
5471

@@ -60,7 +77,7 @@ return (
6077
*/}
6178
<HighlightOverlay
6279
// You would usually use a state variable for this :)
63-
highlightedElementId="important_item"
80+
highlightedElementId="important_item_1"
6481
onDismiss={() => {
6582
// Called when the user clicks outside of the highlighted element.
6683
// Set "highlightedElementId" to nullish to hide the overlay.

example/src/components/FavoriteList/FavoriteItem.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react";
22
import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native";
3-
import { StyleSheet, Image, Text, View } from "react-native";
3+
import { Alert, Pressable, StyleSheet, Image, Text, View } from "react-native";
44

55
export const ITEM_HEIGHT = 60;
66
const BORDER_RADIUS = 10;
@@ -15,7 +15,10 @@ export type FavoriteItemProps = {
1515

1616
function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteItemProps) {
1717
return (
18-
<View style={[styles.container, style]}>
18+
<Pressable
19+
style={[styles.container, style]}
20+
onPress={() => Alert.alert("You pressed", title)}
21+
>
1922
<Image source={imageSource} style={styles.image} />
2023
<View style={styles.textSection}>
2124
<Text style={styles.title}>{title}</Text>
@@ -24,7 +27,7 @@ function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteI
2427
<Text style={styles.duration}>{duration}</Text>
2528
</View>
2629
</View>
27-
</View>
30+
</Pressable>
2831
);
2932
}
3033

example/src/components/FavoriteList/FavoriteList.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { Fragment } from "react";
22
import { StyleSheet, View } from "react-native";
33
import { HighlightableElement } from "react-native-highlight-overlay";
44

@@ -53,13 +53,22 @@ function FavoriteList({ setHighlightId }: FavoriteListProps) {
5353
}}
5454
/>
5555
<View style={styles.listContainer}>
56-
{FAVORITES_LIST.map((favItem, idx, arr) => (
57-
<HighlightableElement
58-
key={getUniqueKeyForItem(favItem)}
59-
id={getUniqueKeyForItem(favItem)}
60-
>
61-
<FavoriteItem style={styles.favoriteItem} {...favItem} />
62-
</HighlightableElement>
56+
{FAVORITES_LIST.map((favItem) => (
57+
<Fragment key={getUniqueKeyForItem(favItem)}>
58+
<HighlightableElement
59+
id={getUniqueKeyForItem(favItem)}
60+
options={{
61+
mode: "rectangle",
62+
clickthroughHighlight: false,
63+
padding: 5,
64+
borderRadius: 10,
65+
}}
66+
>
67+
<FavoriteItem {...favItem} />
68+
</HighlightableElement>
69+
{/* We don't want to highlight the margin, so place it outside */}
70+
<View style={styles.favoriteItemSpacing} />
71+
</Fragment>
6372
))}
6473
</View>
6574
</View>
@@ -73,8 +82,8 @@ const styles = StyleSheet.create({
7382
listContainer: {
7483
flex: 1,
7584
},
76-
favoriteItem: {
77-
marginBottom: 15,
85+
favoriteItemSpacing: {
86+
height: 15,
7887
},
7988
});
8089

example/src/components/SimilarList/SimilarList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ function SimilarList({ setHighlightId }: SimilarListProps) {
4444
<HighlightableElement
4545
key={getUniqueKeyForItem(item)}
4646
id={getUniqueKeyForItem(item)}
47+
options={{
48+
mode: "circle",
49+
padding: 15,
50+
}}
4751
>
4852
<SimilarItem {...item} />
4953
</HighlightableElement>

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,5 +121,8 @@
121121
}
122122
]
123123
]
124+
},
125+
"dependencies": {
126+
"react-native-svg": "^12.1.1"
124127
}
125128
}

src/HighlightOverlay.tsx

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import React from "react";
2-
import { Pressable, StyleSheet, View } from "react-native";
1+
import React, { useState } from "react";
2+
import { StyleSheet, View } from "react-native";
3+
import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
34

5+
import constructClipPath from "./constructClipPath";
46
import { useHighlightableElements } from "./context";
7+
import type { Bounds } from "./context/context";
58

69
export type HighlightOverlayProps = {
710
highlightedElementId?: string | null;
@@ -13,41 +16,46 @@ function HighlightOverlay({ highlightedElementId, onDismiss }: HighlightOverlayP
1316
const highlightedElementData =
1417
highlightedElementId != null ? elements[highlightedElementId] : null;
1518

19+
const [parentSize, setParentSize] = useState<Bounds | null>();
20+
21+
const clickthrough = highlightedElementData?.options?.clickthroughHighlight ?? true;
22+
1623
return (
17-
<View style={StyleSheet.absoluteFill}>
18-
{highlightedElementId != null && (
19-
<>
20-
<Pressable onPress={onDismiss} style={styles.underlay} />
21-
{highlightedElementData != null && (
22-
<View
23-
style={[
24-
styles.highlightContainer,
25-
{
26-
left: highlightedElementData.bounds.x,
27-
top: highlightedElementData.bounds.y,
28-
width: highlightedElementData.bounds.width,
29-
height: highlightedElementData.bounds.height,
30-
},
31-
]}
32-
>
33-
{highlightedElementData.node}
34-
</View>
35-
)}
36-
</>
24+
<View
25+
style={StyleSheet.absoluteFill}
26+
onLayout={({ nativeEvent: { layout } }) => setParentSize(layout)}
27+
pointerEvents="box-none"
28+
>
29+
{highlightedElementData != null && parentSize != null && (
30+
<Svg
31+
style={StyleSheet.absoluteFill}
32+
pointerEvents={clickthrough ? "box-none" : "auto"}
33+
onPress={!clickthrough ? onDismiss : undefined}
34+
>
35+
<G>
36+
<Defs>
37+
<ClipPath id="elementBounds">
38+
<Path
39+
d={constructClipPath(highlightedElementData, parentSize)}
40+
clipRule="evenodd"
41+
/>
42+
</ClipPath>
43+
</Defs>
44+
<Rect
45+
x={0}
46+
y={0}
47+
width="100%"
48+
height="100%"
49+
clipPath="#elementBounds"
50+
fill="black"
51+
fillOpacity={0.7}
52+
onPress={onDismiss}
53+
/>
54+
</G>
55+
</Svg>
3756
)}
3857
</View>
3958
);
4059
}
4160

42-
const styles = StyleSheet.create({
43-
underlay: {
44-
...StyleSheet.absoluteFillObject,
45-
backgroundColor: "black",
46-
opacity: 0.7,
47-
},
48-
highlightContainer: {
49-
position: "absolute",
50-
},
51-
});
52-
5361
export default HighlightOverlay;

src/HighlightableElement.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import type { HostComponent } from "react-native";
44
import { View } from "react-native";
55

66
import { useHighlightableElements } from "./context";
7+
import type { HighlightOptions } from "./context/context";
78

89
export type HighlightableElementProps = PropsWithChildren<{
910
/** The id used by the HighlightOverlay to find this element. */
1011
id: string;
12+
/** The options that decide how this element should look. If left undefined, it only highlights the element. */
13+
options?: HighlightOptions;
1114
}>;
1215

13-
function HighlightableElement({ id, children }: HighlightableElementProps) {
16+
function HighlightableElement({ id, options, children }: HighlightableElementProps) {
1417
const ref = useRef<View | null>(null);
1518

1619
const [_, { addElement, removeElement, rootRef }] = useHighlightableElements();
@@ -26,7 +29,7 @@ function HighlightableElement({ id, children }: HighlightableElementProps) {
2629
// This is a typing error on ReactNative's part. 'rootRef' is a valid reference.
2730
rootRef as unknown as HostComponent<unknown>,
2831
(x, y, width, height) => {
29-
addElement(id, children, { x, y, width, height });
32+
addElement(id, children, { x, y, width, height }, options);
3033
},
3134
() => {
3235
console.error(`Error measuring layout of focused element with id ${id}.`);

src/constructClipPath.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Bounds, ElementsRecord } from "./context/context";
2+
3+
type ElementBounds = {
4+
startX: number;
5+
startY: number;
6+
endX: number;
7+
endY: number;
8+
};
9+
10+
const M = (x: number, y: number) => `M ${x} ${y}`;
11+
const L = (x: number, y: number) => `L ${x} ${y}`;
12+
const arc = (toX: number, toY: number, radius: number) =>
13+
`A ${radius},${radius} 0 0 0 ${toX},${toY}`;
14+
const z = "z";
15+
16+
const constructClipPath = (data: ElementsRecord[string], containerSize: Bounds): string => {
17+
const parentBounds = {
18+
startX: 0,
19+
startY: 0,
20+
endX: containerSize.width,
21+
endY: containerSize.height,
22+
};
23+
24+
switch (data.options?.mode) {
25+
case "circle": {
26+
const {
27+
bounds: { x, y, width, height },
28+
options: { padding = 0 },
29+
} = data;
30+
const radius = Math.max(width, height) / 2;
31+
return constructCircularPath(
32+
parentBounds,
33+
{ cx: x + width / 2, cy: y + height / 2 },
34+
radius + padding
35+
);
36+
}
37+
case "rectangle": // Fallthrough
38+
default: {
39+
const padding = data.options?.padding ?? 0;
40+
const borderRadius = data.options?.borderRadius ?? 0;
41+
42+
const startX = data.bounds.x - padding;
43+
const endX = startX + data.bounds.width + padding * 2;
44+
const startY = data.bounds.y - padding;
45+
const endY = startY + data.bounds.height + padding * 2;
46+
return constructRectangularPath(
47+
parentBounds,
48+
{ startX, startY, endX, endY },
49+
borderRadius
50+
);
51+
}
52+
}
53+
};
54+
55+
const constructRectangularPath = (
56+
parentBounds: ElementBounds,
57+
{ startX, startY, endX, endY }: ElementBounds,
58+
borderRadius: number
59+
): string => {
60+
return [
61+
M(parentBounds.startX, parentBounds.startY),
62+
L(parentBounds.startX, parentBounds.endY),
63+
L(parentBounds.endX, parentBounds.endY),
64+
L(parentBounds.endX, parentBounds.startY),
65+
z,
66+
M(startX, startY + borderRadius),
67+
L(startX, endY - borderRadius),
68+
arc(startX + borderRadius, endY, borderRadius),
69+
L(endX - borderRadius, endY),
70+
arc(endX, endY - borderRadius, borderRadius),
71+
L(endX, startY + borderRadius),
72+
arc(endX - borderRadius, startY, borderRadius),
73+
L(startX + borderRadius, startY),
74+
arc(startX, startY + borderRadius, borderRadius),
75+
].join(" ");
76+
};
77+
78+
const constructCircularPath = (
79+
parentBounds: ElementBounds,
80+
{ cx, cy }: { cx: number; cy: number },
81+
radius: number
82+
): string => {
83+
return [
84+
M(parentBounds.startX, parentBounds.startY),
85+
L(parentBounds.startX, parentBounds.endY),
86+
L(parentBounds.endX, parentBounds.endY),
87+
L(parentBounds.endX, parentBounds.startY),
88+
z,
89+
M(cx, cy),
90+
`m ${-radius} 0`,
91+
`a ${radius},${radius} 0 1,0 ${radius * 2},0`,
92+
`a ${radius},${radius} 0 1,0 ${-radius * 2},0`,
93+
].join(" ");
94+
};
95+
96+
export default constructClipPath;

src/context/HighlightableElementProvider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ function HighlightableElementProvider({
2626
);
2727
const [elements, setElements] = useState<ElementsRecord>({});
2828

29-
const addElement = useCallback<AddElement>((id, node, bounds) => {
30-
setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds } }));
29+
const addElement = useCallback<AddElement>((id, node, bounds, options) => {
30+
setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds, options } }));
3131
}, []);
3232

3333
const removeElement: RemoveElement = useCallback<RemoveElement>((id) => {

0 commit comments

Comments
 (0)