Skip to content

Commit

Permalink
Merge pull request #1 from Charanor/feature/mask-highlight
Browse files Browse the repository at this point in the history
Mask highlight
  • Loading branch information
Charanor committed Oct 15, 2021
2 parents be74670 + 343996a commit 4fc45d9
Show file tree
Hide file tree
Showing 11 changed files with 323 additions and 55 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

### ⚠️ Caveats ⚠️
Expand Down Expand Up @@ -48,7 +48,24 @@ import {
// Remember to wrap the ROOT of your app in HighlightableElementProvider!
return (
<HighlightableElementProvider>
<HighlightableElement id="important_item">
<HighlightableElement
id="important_item_1"
options={{
// Options are useful if you want to configure the highlight, but can be left blank.
mode: "rectangle",
padding: 5,
borderRadius: 15,
}}
>
<TheRestOfTheOwl />
</HighlightableElement>
<HighlightableElement
id="important_item_2"
options={{
mode: "circle",
padding: 5,
}}
>
<TheRestOfTheOwl />
</HighlightableElement>

Expand All @@ -60,7 +77,7 @@ return (
*/}
<HighlightOverlay
// You would usually use a state variable for this :)
highlightedElementId="important_item"
highlightedElementId="important_item_1"
onDismiss={() => {
// Called when the user clicks outside of the highlighted element.
// Set "highlightedElementId" to nullish to hide the overlay.
Expand Down
9 changes: 6 additions & 3 deletions example/src/components/FavoriteList/FavoriteItem.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +15,10 @@ export type FavoriteItemProps = {

function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteItemProps) {
return (
<View style={[styles.container, style]}>
<Pressable
style={[styles.container, style]}
onPress={() => Alert.alert("You pressed", title)}
>
<Image source={imageSource} style={styles.image} />
<View style={styles.textSection}>
<Text style={styles.title}>{title}</Text>
Expand All @@ -24,7 +27,7 @@ function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteI
<Text style={styles.duration}>{duration}</Text>
</View>
</View>
</View>
</Pressable>
);
}

Expand Down
29 changes: 19 additions & 10 deletions example/src/components/FavoriteList/FavoriteList.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -53,13 +53,22 @@ function FavoriteList({ setHighlightId }: FavoriteListProps) {
}}
/>
<View style={styles.listContainer}>
{FAVORITES_LIST.map((favItem, idx, arr) => (
<HighlightableElement
key={getUniqueKeyForItem(favItem)}
id={getUniqueKeyForItem(favItem)}
>
<FavoriteItem style={styles.favoriteItem} {...favItem} />
</HighlightableElement>
{FAVORITES_LIST.map((favItem) => (
<Fragment key={getUniqueKeyForItem(favItem)}>
<HighlightableElement
id={getUniqueKeyForItem(favItem)}
options={{
mode: "rectangle",
clickthroughHighlight: false,
padding: 5,
borderRadius: 10,
}}
>
<FavoriteItem {...favItem} />
</HighlightableElement>
{/* We don't want to highlight the margin, so place it outside */}
<View style={styles.favoriteItemSpacing} />
</Fragment>
))}
</View>
</View>
Expand All @@ -73,8 +82,8 @@ const styles = StyleSheet.create({
listContainer: {
flex: 1,
},
favoriteItem: {
marginBottom: 15,
favoriteItemSpacing: {
height: 15,
},
});

Expand Down
4 changes: 4 additions & 0 deletions example/src/components/SimilarList/SimilarList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function SimilarList({ setHighlightId }: SimilarListProps) {
<HighlightableElement
key={getUniqueKeyForItem(item)}
id={getUniqueKeyForItem(item)}
options={{
mode: "circle",
padding: 15,
}}
>
<SimilarItem {...item} />
</HighlightableElement>
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,8 @@
}
]
]
},
"dependencies": {
"react-native-svg": "^12.1.1"
}
}
74 changes: 41 additions & 33 deletions src/HighlightOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,41 +16,46 @@ function HighlightOverlay({ highlightedElementId, onDismiss }: HighlightOverlayP
const highlightedElementData =
highlightedElementId != null ? elements[highlightedElementId] : null;

const [parentSize, setParentSize] = useState<Bounds | null>();

const clickthrough = highlightedElementData?.options?.clickthroughHighlight ?? true;

return (
<View style={StyleSheet.absoluteFill}>
{highlightedElementId != null && (
<>
<Pressable onPress={onDismiss} style={styles.underlay} />
{highlightedElementData != null && (
<View
style={[
styles.highlightContainer,
{
left: highlightedElementData.bounds.x,
top: highlightedElementData.bounds.y,
width: highlightedElementData.bounds.width,
height: highlightedElementData.bounds.height,
},
]}
>
{highlightedElementData.node}
</View>
)}
</>
<View
style={StyleSheet.absoluteFill}
onLayout={({ nativeEvent: { layout } }) => setParentSize(layout)}
pointerEvents="box-none"
>
{highlightedElementData != null && parentSize != null && (
<Svg
style={StyleSheet.absoluteFill}
pointerEvents={clickthrough ? "box-none" : "auto"}
onPress={!clickthrough ? onDismiss : undefined}
>
<G>
<Defs>
<ClipPath id="elementBounds">
<Path
d={constructClipPath(highlightedElementData, parentSize)}
clipRule="evenodd"
/>
</ClipPath>
</Defs>
<Rect
x={0}
y={0}
width="100%"
height="100%"
clipPath="#elementBounds"
fill="black"
fillOpacity={0.7}
onPress={onDismiss}
/>
</G>
</Svg>
)}
</View>
);
}

const styles = StyleSheet.create({
underlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "black",
opacity: 0.7,
},
highlightContainer: {
position: "absolute",
},
});

export default HighlightOverlay;
7 changes: 5 additions & 2 deletions src/HighlightableElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<View | null>(null);

const [_, { addElement, removeElement, rootRef }] = useHighlightableElements();
Expand All @@ -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<unknown>,
(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}.`);
Expand Down
96 changes: 96 additions & 0 deletions src/constructClipPath.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions src/context/HighlightableElementProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ function HighlightableElementProvider({
);
const [elements, setElements] = useState<ElementsRecord>({});

const addElement = useCallback<AddElement>((id, node, bounds) => {
setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds } }));
const addElement = useCallback<AddElement>((id, node, bounds, options) => {
setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds, options } }));
}, []);

const removeElement: RemoveElement = useCallback<RemoveElement>((id) => {
Expand Down
Loading

0 comments on commit 4fc45d9

Please sign in to comment.