diff --git a/docs/docs/fundamentals/states-events.md b/docs/docs/fundamentals/states-events.mdx
similarity index 85%
rename from docs/docs/fundamentals/states-events.md
rename to docs/docs/fundamentals/states-events.mdx
index 39347ebe5b..6d2cf5b351 100644
--- a/docs/docs/fundamentals/states-events.md
+++ b/docs/docs/fundamentals/states-events.mdx
@@ -38,17 +38,15 @@ A gesture can be in one of the six possible states:
The most typical flow of state is when a gesture picks up on an initial touch event, then recognizes it, then acknowledges its ending and resets itself back to the initial state.
-The flow looks as follows (longer arrows represent that there are possibly more touch events received before the state changes):
+The flow looks as follows:
-[`UNDETERMINED`](#undetermined) -> [`BEGAN`](#began) ------> [`ACTIVE`](#active) ------> [`END`](#end) -> [`UNDETERMINED`](#undetermined)
+import GestureStateFlowExample from '@site/src/examples/GestureStateFlowExample';
-Another possible flow is when a handler receives touches that cause a recognition failure:
-
-[`UNDETERMINED`](#undetermined) -> [`BEGAN`](#began) ------> [`FAILED`](#failed) -> [`UNDETERMINED`](#undetermined)
-
-At last, when a handler does properly recognize the gesture but then is interrupted by the touch system the gesture recognition is canceled and the flow looks as follows:
-
-[`UNDETERMINED`](#undetermined) -> [`BEGAN`](#began) ------> [`ACTIVE`](#active) ------> [`CANCELLED`](#cancelled) -> [`UNDETERMINED`](#undetermined)
+ }
+ label="Drag or long-press the circle"
+ larger={true}
+/>
## Events
diff --git a/docs/docs/gestures/state-manager.md b/docs/docs/gestures/state-manager.md
index de1f3404ad..2191213cff 100644
--- a/docs/docs/gestures/state-manager.md
+++ b/docs/docs/gestures/state-manager.md
@@ -11,7 +11,7 @@ sidebar_position: 15
### `begin()`
-Transition the gesture to the [`BEGAN`](/docs/fundamentals/states-events.md#began) state. This method will have no effect if the gesture has already activated or finished.
+Transition the gesture to the [`BEGAN`](/docs/fundamentals/states-events#began) state. This method will have no effect if the gesture has already activated or finished.
### `activate()`
@@ -20,8 +20,8 @@ If the gesture is [`exclusive`](/docs/fundamentals/gesture-composition) with ano
### `end()`
-Transition the gesture to the [`END`](/docs/fundamentals/states-events.md#end) state. This method will have no effect if the handler has already finished.
+Transition the gesture to the [`END`](/docs/fundamentals/states-events#end) state. This method will have no effect if the handler has already finished.
### `fail()`
-Transition the gesture to the [`FAILED`](/docs/fundamentals/states-events.md#failed) state. This method will have no effect if the handler has already finished.
+Transition the gesture to the [`FAILED`](/docs/fundamentals/states-events#failed) state. This method will have no effect if the handler has already finished.
diff --git a/docs/src/components/AnimableIcon/index.tsx b/docs/src/components/AnimableIcon/index.tsx
new file mode 100644
index 0000000000..78971f3a25
--- /dev/null
+++ b/docs/src/components/AnimableIcon/index.tsx
@@ -0,0 +1,40 @@
+import React, { useEffect, useState } from 'react';
+import styles from './styles.module.css';
+import clsx from 'clsx';
+import { useColorMode } from '@docusaurus/theme-common';
+
+export const Animation = {
+ FADE_IN_OUT: styles.iconClicked,
+};
+
+interface Props {
+ icon: JSX.Element;
+ iconDark?: JSX.Element;
+ animation: string;
+ onClick: (actionPerformed, setActionPerformed) => void;
+}
+
+const AnimableIcon = ({
+ icon,
+ iconDark,
+ animation = Animation.FADE_IN_OUT,
+ onClick,
+}: Props): JSX.Element => {
+ const { colorMode } = useColorMode();
+ const [actionPerformed, setActionPerformed] = useState(false);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => setActionPerformed(false), 1000);
+ return () => clearTimeout(timeout);
+ }, [actionPerformed]);
+
+ return (
+
onClick(actionPerformed, setActionPerformed)}
+ className={clsx(styles.actionIcon, actionPerformed && animation)}>
+ {colorMode === 'light' ? icon : iconDark || icon}
+
+ );
+};
+
+export default AnimableIcon;
diff --git a/docs/src/components/AnimableIcon/styles.module.css b/docs/src/components/AnimableIcon/styles.module.css
new file mode 100644
index 0000000000..f972f1294d
--- /dev/null
+++ b/docs/src/components/AnimableIcon/styles.module.css
@@ -0,0 +1,27 @@
+.actionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ padding: 0.25em;
+ cursor: pointer;
+
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.iconClicked {
+ animation: 1s iconClick;
+}
+
+@keyframes iconClick {
+ 0% {
+ border: 1px solid var(--swm-interactive-copy-button-off);
+ }
+ 50% {
+ border: 1px solid var(--swm-interactive-copy-button-on);
+ }
+ 100% {
+ border: 1px solid var(--swm-interactive-copy-button-off);
+ }
+}
diff --git a/docs/src/components/CollapseButton/index.tsx b/docs/src/components/CollapseButton/index.tsx
new file mode 100644
index 0000000000..8f035ba526
--- /dev/null
+++ b/docs/src/components/CollapseButton/index.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import styles from './styles.module.css';
+import Arrow from '@site/static/img/Arrow.svg';
+import ArrowDark from '@site/static/img/Arrow-dark.svg';
+import { useColorMode } from '@docusaurus/theme-common';
+import clsx from 'clsx';
+
+const CollapseButton: React.FC<{
+ label: string;
+ labelCollapsed: string;
+ collapsed: boolean;
+ onCollapse: () => void;
+ className?: string;
+}> = ({ label, labelCollapsed, collapsed, onCollapse, className }) => {
+ const { colorMode } = useColorMode();
+
+ return (
+ onCollapse()}>
+ {colorMode === 'light' ? (
+
+ ) : (
+
+ )}
+
+
{collapsed ? labelCollapsed : label}
+
+ );
+};
+
+export default CollapseButton;
diff --git a/docs/src/components/CollapseButton/styles.module.css b/docs/src/components/CollapseButton/styles.module.css
new file mode 100644
index 0000000000..908df5fa09
--- /dev/null
+++ b/docs/src/components/CollapseButton/styles.module.css
@@ -0,0 +1,29 @@
+.collapseButton {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.collapseButton button {
+ background-color: transparent;
+ border: none;
+ padding: 0;
+
+ font-family: var(--swm-body-font);
+ font-size: 16px;
+ color: var(--ifm-font-color-base);
+ cursor: pointer;
+}
+
+.arrow {
+ height: 12px;
+ width: 12px;
+ margin-right: 1rem;
+ margin-top: 2px;
+
+ transition: var(--swm-expandable-transition);
+}
+
+.collapseButton[data-collapsed='false'] .arrow {
+ transform: rotate(180deg);
+}
diff --git a/docs/src/components/CollapsibleCode/index.tsx b/docs/src/components/CollapsibleCode/index.tsx
new file mode 100644
index 0000000000..ae0ce28a48
--- /dev/null
+++ b/docs/src/components/CollapsibleCode/index.tsx
@@ -0,0 +1,36 @@
+import React, { useState } from 'react';
+import CodeBlock from '@theme/CodeBlock';
+import styles from './styles.module.css';
+
+import CollapseButton from '@site/src/components/CollapseButton';
+
+interface Props {
+ src: string;
+ lineBounds: number[];
+}
+
+export default function CollapsibleCode({ src, lineBounds }: Props) {
+ const [collapsed, setCollapsed] = useState(true);
+
+ if (!lineBounds) {
+ return {src} ;
+ }
+
+ const [start, end] = lineBounds;
+
+ const codeLines = src.split('\n');
+ const linesToShow = codeLines.slice(start, end + 1).join('\n');
+
+ return (
+
+ setCollapsed(!collapsed)}
+ className={styles.collapseButton}
+ />
+ {collapsed ? linesToShow : src}
+
+ );
+}
diff --git a/docs/src/components/CollapsibleCode/styles.module.css b/docs/src/components/CollapsibleCode/styles.module.css
new file mode 100644
index 0000000000..1502e735e7
--- /dev/null
+++ b/docs/src/components/CollapsibleCode/styles.module.css
@@ -0,0 +1,15 @@
+.container {
+ background-color: var(--swm-off-background);
+ border-radius: 0;
+ border: 1px solid var(--swm-border);
+ margin-bottom: 1em;
+}
+
+.container pre,
+.container code {
+ border: none;
+}
+
+.collapseButton {
+ padding: 1em 0 0 1em;
+}
diff --git a/docs/src/components/GestureExamples/GestureExampleItem/index.tsx b/docs/src/components/GestureExamples/GestureExampleItem/index.tsx
index a8820822e6..7a298a92ac 100644
--- a/docs/src/components/GestureExamples/GestureExampleItem/index.tsx
+++ b/docs/src/components/GestureExamples/GestureExampleItem/index.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import styles from './styles.module.css';
-import InteractiveExampleComponent from '@site/src/components/InteractiveExampleComponent';
+import LandingExampleComponent from '@site/src/components/LandingExampleComponent';
interface Props {
title: string;
@@ -17,7 +17,7 @@ const GestureExampleItem = ({ title, component, idx, href }: Props) => {
{title}
-
+
>
diff --git a/docs/src/components/InteractiveExample/InteractiveExampleComponent/index.tsx b/docs/src/components/InteractiveExample/InteractiveExampleComponent/index.tsx
new file mode 100644
index 0000000000..11c39951a4
--- /dev/null
+++ b/docs/src/components/InteractiveExample/InteractiveExampleComponent/index.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import styles from './styles.module.css';
+
+interface Props {
+ component: React.ReactNode;
+ label?: string;
+ idx?: number;
+}
+
+export default function InteractiveExampleComponent({
+ component,
+ label,
+ idx,
+}: Props) {
+ return (
+ Loading...}>
+ {() => (
+
+
{component}
+ {label &&
{label}
}
+
+ )}
+
+ );
+}
diff --git a/docs/src/components/InteractiveExample/InteractiveExampleComponent/styles.module.css b/docs/src/components/InteractiveExample/InteractiveExampleComponent/styles.module.css
new file mode 100644
index 0000000000..eec1a5a103
--- /dev/null
+++ b/docs/src/components/InteractiveExample/InteractiveExampleComponent/styles.module.css
@@ -0,0 +1,15 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ contain: content;
+ align-items: center;
+ background-color: none;
+ margin-bottom: var(--ifm-leading);
+}
+
+.label {
+ text-align: center;
+ font-size: 1rem;
+}
diff --git a/docs/src/components/InteractiveExample/index.tsx b/docs/src/components/InteractiveExample/index.tsx
new file mode 100644
index 0000000000..8a89da64ed
--- /dev/null
+++ b/docs/src/components/InteractiveExample/index.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import clsx from 'clsx';
+import { useCopyToClipboard } from 'usehooks-ts';
+
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import CodeBlock from '@theme/CodeBlock';
+import AnimableIcon, { Animation } from '@site/src/components/AnimableIcon';
+import { useReducedMotion } from 'react-native-reanimated';
+import ReducedMotionWarning from '../ReducedMotionWarning';
+
+import Copy from '@site/static/img/copy.svg';
+import CopyDark from '@site/static/img/copy-dark.svg';
+import Reset from '@site/static/img/reset.svg';
+import ResetDark from '@site/static/img/reset-dark.svg';
+
+import styles from './styles.module.css';
+
+interface Props {
+ src: string;
+ component: React.ReactNode;
+ label?: string;
+ showCode?: boolean; // whether to show code by default
+ larger?: boolean; // should the view be enlarged?
+}
+
+export default function InteractiveExample({
+ src,
+ component,
+ label,
+ showCode = false,
+ larger = false,
+}: Props) {
+ const [_, copy] = useCopyToClipboard();
+ const [key, setKey] = React.useState(0);
+ const [showPreview, setShowPreview] = React.useState(!showCode);
+
+ const resetExample = () => {
+ setKey(key + 1);
+ };
+
+ const prefersReducedMotion = useReducedMotion();
+
+ return (
+ Loading...}>
+ {() => (
+
+ {showPreview && prefersReducedMotion &&
}
+ {src && (
+
+
+ setShowPreview(true)}>
+ Preview
+
+ setShowPreview(false)}>
+ Code
+
+
+
}
+ iconDark={
}
+ animation={Animation.FADE_IN_OUT}
+ onClick={(actionPerformed, setActionPerformed) => {
+ if (!actionPerformed) {
+ copy(src);
+ setActionPerformed(true);
+ }
+ }}
+ />
+
+ )}
+
+ {showPreview ? (
+ <>
+
{component}
+
+
+
+ {label &&
{label}
}
+
}
+ iconDark={
}
+ animation={Animation.FADE_IN_OUT}
+ onClick={(actionPerformed, setActionPerformed) => {
+ if (!actionPerformed) {
+ resetExample();
+ setActionPerformed(true);
+ }
+ }}
+ />
+
+ >
+ ) : (
+
+ {src}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/docs/src/components/InteractiveExample/styles.module.css b/docs/src/components/InteractiveExample/styles.module.css
new file mode 100644
index 0000000000..db85c38b84
--- /dev/null
+++ b/docs/src/components/InteractiveExample/styles.module.css
@@ -0,0 +1,136 @@
+.container {
+ display: flex;
+ flex-direction: column;
+
+ contain: content;
+
+ background-color: var(--swm-off-background);
+ border: 1px solid var(--swm-border);
+ margin-bottom: var(--ifm-leading);
+}
+
+.largerContainer {
+ min-height: 400px;
+}
+
+/* Preferred height in code section of container. */
+.container[data-ispreview='false'] {
+ height: 400px;
+}
+
+/* Classes used to omit default docusaurus styling. */
+[class*='codeBlockContainer'] {
+ box-shadow: none;
+}
+
+.interactiveCodeBlock [class*='codeBlockContent'] pre {
+ border: none;
+}
+
+.interactiveCodeBlock [class*='codeBlockContent'] code {
+ background-color: var(--swm-off-background);
+ width: 100%;
+ padding: 0;
+ border: none;
+}
+
+/* Hide default action buttons, displayed by Docusaurus */
+.interactiveCodeBlock [class*='buttonGroup'] {
+ display: none;
+}
+
+.code {
+ flex: 1;
+}
+
+.buttonsContainer {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+
+ padding: 0.25em 0.75em 0.25em 1.25em;
+ margin: 1.5em 2em 1.5em 1.5em;
+}
+
+.upperButtonsContainer {
+ margin-bottom: 10px;
+}
+
+@media (max-width: 996px) {
+ .upperButtonsContainer {
+ justify-content: center;
+
+ margin: 1.5em 0;
+ }
+}
+
+.lowerButtonsContainer {
+ justify-content: space-between;
+ margin-top: 10px;
+}
+
+.iconStub {
+ width: 30px;
+ height: 30px;
+}
+
+.container[data-ispreview='false'] .buttonsContainer {
+ position: absolute;
+ right: 0;
+ top: 0;
+
+ border-radius: 25px;
+ background-color: var(--swm-code-lines-buttons-background);
+
+ z-index: 1;
+}
+
+@media (max-width: 996px) {
+ .container[data-ispreview='false'] .buttonsContainer {
+ position: relative;
+ width: fit-content;
+ margin: 1.5em auto;
+ }
+}
+
+.previewContainer {
+ flex: 1 1 auto;
+}
+
+/* Style preview only when user is in the 'code' section. */
+.container[data-ispreview='false'] .previewContainer {
+ flex: 1 1 auto;
+ overflow-y: auto;
+
+ padding: 0 24px;
+ margin: 16px 8px 8px 0;
+}
+
+@media (max-width: 996px) {
+ .container[data-ispreview='false'] .previewContainer {
+ margin-top: 0;
+ }
+}
+
+.actionButton {
+ margin-right: 0.5em;
+ padding: 0 0 2px 0;
+
+ border: none;
+ background-color: inherit;
+ color: var(--swm-interactive-button-color);
+ cursor: pointer;
+
+ font-family: var(--swm-body-font);
+ font-weight: 500;
+ font-size: 16px;
+}
+
+.actionButton:last-of-type {
+ margin-right: 2em;
+}
+
+.actionButtonActive {
+ color: var(--swm-interactive-button-active);
+ border-bottom: 1px solid var(--swm-interactive-button-active);
+}
diff --git a/docs/src/components/InteractiveExampleComponent/index.tsx b/docs/src/components/LandingExampleComponent/index.tsx
similarity index 84%
rename from docs/src/components/InteractiveExampleComponent/index.tsx
rename to docs/src/components/LandingExampleComponent/index.tsx
index 69def9ab51..65bd0f73e4 100644
--- a/docs/src/components/InteractiveExampleComponent/index.tsx
+++ b/docs/src/components/LandingExampleComponent/index.tsx
@@ -8,7 +8,7 @@ interface Props {
idx?: number;
}
-export default function InteractiveExampleComponent({ component, idx }: Props) {
+export default function LandingExample({ component, idx }: Props) {
return (
Loading...}>
{() => (
diff --git a/docs/src/components/InteractiveExampleComponent/styles.module.css b/docs/src/components/LandingExampleComponent/styles.module.css
similarity index 100%
rename from docs/src/components/InteractiveExampleComponent/styles.module.css
rename to docs/src/components/LandingExampleComponent/styles.module.css
diff --git a/docs/src/components/ReducedMotionWarning/index.tsx b/docs/src/components/ReducedMotionWarning/index.tsx
new file mode 100644
index 0000000000..a958d0b2aa
--- /dev/null
+++ b/docs/src/components/ReducedMotionWarning/index.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import styles from './styles.module.css';
+import Link from '@docusaurus/Link';
+
+import { useColorMode } from '@docusaurus/theme-common';
+import Danger from '/static/img/danger.svg';
+import DangerDark from '/static/img/danger-dark.svg';
+
+export default function ReducedMotionWarning() {
+ const { colorMode } = useColorMode();
+ return (
+
+
+ {colorMode === 'light' ? : }
+
+
+ It looks like reduced motion is turned on in your system preferences.
+ Some of the animations may be skipped.{' '}
+
+ Learn more
+
+
+
+ );
+}
diff --git a/docs/src/components/ReducedMotionWarning/styles.module.css b/docs/src/components/ReducedMotionWarning/styles.module.css
new file mode 100644
index 0000000000..d689c710a3
--- /dev/null
+++ b/docs/src/components/ReducedMotionWarning/styles.module.css
@@ -0,0 +1,22 @@
+.dangerMark {
+ display: flex;
+ padding: 0.125rem 0.75rem;
+}
+
+.link {
+ white-space: nowrap;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0.125rem 0;
+}
+
+.warningText {
+ margin: 0.25rem 0 0.75rem;
+}
+
+.container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--swm-admonition-color-caution);
+}
diff --git a/docs/src/css/index.css b/docs/src/css/index.css
index 9e9812a199..7e701e6d52 100644
--- a/docs/src/css/index.css
+++ b/docs/src/css/index.css
@@ -1,3 +1,3 @@
@import 'colors.css';
@import 'typography.css';
-@import 'overrides.css';
\ No newline at end of file
+@import 'overrides.css';
diff --git a/docs/src/examples/GestureStateFlowExample/Arrow.tsx b/docs/src/examples/GestureStateFlowExample/Arrow.tsx
new file mode 100644
index 0000000000..c5f84ad540
--- /dev/null
+++ b/docs/src/examples/GestureStateFlowExample/Arrow.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+// source: https://gist.github.com/jvaclavik/fbf0a951864c98ca34d8f95be01b561d#file-dependency-arrows-1-tsx
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+type ArrowProps = {
+ startPoint: Point;
+ endPoint: Point;
+};
+
+export default function App({ startPoint, endPoint }: ArrowProps) {
+ // Getting info about SVG canvas
+ const canvasStartPoint = {
+ x: Math.min(startPoint.x, endPoint.x),
+ y: Math.min(startPoint.y, endPoint.y),
+ };
+
+ const strokeWidth = 3;
+ const spaceForArrows = 100;
+ const totalPadding = strokeWidth + spaceForArrows;
+ const halfPadding = totalPadding / 2;
+
+ const canvasWidth = Math.abs(endPoint.x - startPoint.x) + totalPadding;
+ const canvasHeight = Math.abs(endPoint.y - startPoint.y) + totalPadding;
+
+ // with perfectly straight lines, canvas height/width is set to 0
+ // when that is fixed, stoke gets drawn on the border, getting halved
+ canvasStartPoint.x -= halfPadding;
+ canvasStartPoint.y -= halfPadding;
+
+ // adjust coordinates by canvas global offset
+ startPoint.x = startPoint.x - canvasStartPoint.x;
+ startPoint.y = startPoint.y - canvasStartPoint.y;
+ endPoint.x = endPoint.x - canvasStartPoint.x;
+ endPoint.y = endPoint.y - canvasStartPoint.y;
+
+ const avg = (a: number, b: number) => (a + b) / 2;
+
+ // we will be drawing two deflections from midpoint to origin
+ const midPoint = {
+ x: avg(startPoint.x, endPoint.x),
+ y: avg(startPoint.y, endPoint.y),
+ };
+
+ const midToOriginVector = {
+ x: midPoint.x - endPoint.x,
+ y: midPoint.y - endPoint.y,
+ };
+
+ type Coords = { x: number; y: number };
+
+ const truncate = ({ x, y }: Coords, length: number): Coords => {
+ const magnitude = Math.hypot(x, y);
+
+ let modifier = length / magnitude;
+ if (!Number.isFinite(modifier)) {
+ modifier = 0;
+ }
+
+ return {
+ x: x * modifier,
+ y: y * modifier,
+ };
+ };
+
+ const rotate = ({ x, y }: Coords, rotation: number): Coords => {
+ const rotationRadians = (Math.PI * rotation) / 180;
+ const cosResult = Math.cos(rotationRadians);
+ const sinResult = Math.sin(rotationRadians);
+ return {
+ x: x * cosResult - y * sinResult,
+ y: x * sinResult + y * cosResult,
+ };
+ };
+
+ const reverse = ({ x, y }: Coords): Coords => {
+ return {
+ x: -x,
+ y: -y,
+ };
+ };
+
+ const arrowLength = 9;
+
+ const truncatedVector = truncate(midToOriginVector, arrowLength);
+
+ const deflectionVectorLeft = rotate(truncatedVector, 50);
+ const deflectionVectorRight = rotate(truncatedVector, -50);
+ const deflectionVectorExtender = reverse(
+ truncate(midToOriginVector, arrowLength / 1.8)
+ );
+
+ const arrowColor = 'var(--swm-border)';
+
+ return (
+
+
+
+
+ );
+}
diff --git a/docs/src/examples/GestureStateFlowExample/ChartItem.tsx b/docs/src/examples/GestureStateFlowExample/ChartItem.tsx
new file mode 100644
index 0000000000..9a47906a61
--- /dev/null
+++ b/docs/src/examples/GestureStateFlowExample/ChartItem.tsx
@@ -0,0 +1,106 @@
+import { Grid } from '@mui/material';
+import React, { LegacyRef, useEffect } from 'react';
+import { StyleProp, StyleSheet, View, Text } from 'react-native';
+import ChartManager, { Item, WAVE_DELAY_MS } from './ChartManager';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+interface ChartItemProps {
+ item: Item;
+ chartManager: ChartManager;
+ innerRef?: LegacyRef;
+ style?: StyleProp;
+}
+
+export default function ChartItem({
+ item,
+ chartManager,
+ innerRef,
+ style,
+}: ChartItemProps) {
+ const progress = useSharedValue(0);
+
+ useEffect(() => {
+ if (item.id != ChartManager.EMPTY_SPACE_ID) {
+ const listenerId = chartManager.addListener(item.id, (isActive) => {
+ progress.value = withSpring(isActive ? 1 : 0, {
+ duration: 2 * WAVE_DELAY_MS,
+ });
+ });
+
+ return () => {
+ chartManager.removeListener(item.id, listenerId);
+ };
+ }
+ }, [chartManager]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ backgroundColor:
+ progress.value > 0.5
+ ? item.highlightColor
+ : 'var(--ifm-background-color)',
+ borderColor: progress.value > 0.5 ? 'transparent' : 'var(--swm-border)',
+ };
+ });
+
+ const animatedTextStyle = useAnimatedStyle(() => {
+ return {
+ color:
+ progress.value > 0.5
+ ? 'var(--swm-navy-light-100)'
+ : 'var(--swm-border)',
+ };
+ });
+
+ return (
+
+
+
+ {item.label}
+
+
+ {item.subtext}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ box: {
+ flex: 1,
+ flexDirection: 'column',
+ textAlign: 'center',
+ maxWidth: 900,
+ },
+ item: {
+ paddingVertical: 16,
+ backgroundColor: 'var(--ifm-background-color)',
+ borderWidth: 1,
+ borderColor: 'var(--swm-border)',
+ transition: 'all 350ms ease-in-out',
+ },
+ label: {
+ color: 'var(--swm-border)',
+ transition: 'color 350ms ease-in-out',
+ fontWeight: '500',
+ fontSize: 22,
+ },
+ subtext: {
+ fontWeight: '300',
+ fontSize: 14,
+ backgroundColor: 'var(--swm-off-background)',
+ },
+ hidden: {
+ opacity: 0,
+ },
+});
diff --git a/docs/src/examples/GestureStateFlowExample/ChartManager.ts b/docs/src/examples/GestureStateFlowExample/ChartManager.ts
new file mode 100644
index 0000000000..5c9e791b4e
--- /dev/null
+++ b/docs/src/examples/GestureStateFlowExample/ChartManager.ts
@@ -0,0 +1,260 @@
+import { useMemo } from 'react';
+import {
+ TapGesture,
+ PanGesture,
+ PinchGesture,
+ RotationGesture,
+ FlingGesture,
+ LongPressGesture,
+ ForceTouchGesture,
+ NativeGesture,
+ ManualGesture,
+ HoverGesture,
+ GestureStateChangeEvent,
+ State,
+} from 'react-native-gesture-handler';
+
+export const WAVE_DELAY_MS = 150;
+const Colors = {
+ BLUE: 'var(--swm-blue-light-80)',
+ GREEN: 'var(--swm-green-light-80)',
+ YELLOW: 'var(--swm-yellow-light-80)',
+ RED: 'var(--swm-red-light-80)',
+};
+
+export type Item = {
+ id: number;
+ label?: string;
+ subtext?: string;
+ isVisible: boolean;
+ highlightColor: string;
+};
+
+const stateToName = new Map([
+ [State.UNDETERMINED, 'UNDETERMINED'],
+ [State.FAILED, 'FAILED'],
+ [State.BEGAN, 'BEGAN'],
+ [State.CANCELLED, 'CANCELLED'],
+ [State.ACTIVE, 'ACTIVE'],
+ [State.END, 'END'],
+]);
+
+const labelColorMap = new Map([
+ [stateToName.get(State.BEGAN), Colors.BLUE],
+ [stateToName.get(State.ACTIVE), Colors.GREEN],
+ [stateToName.get(State.END), Colors.BLUE],
+ [stateToName.get(State.FAILED), Colors.RED],
+ [stateToName.get(State.CANCELLED), Colors.RED],
+ [stateToName.get(State.UNDETERMINED), Colors.YELLOW],
+]);
+
+class ChartConnection {
+ id: number;
+ from: number;
+ to: number;
+}
+
+type GesturesUnion =
+ | TapGesture
+ | PanGesture
+ | PinchGesture
+ | RotationGesture
+ | FlingGesture
+ | LongPressGesture
+ | ForceTouchGesture
+ | NativeGesture
+ | ManualGesture
+ | HoverGesture;
+
+type IdObject = {
+ began: number;
+ active: number;
+ end: number;
+ failed: number;
+ cancelled: number;
+ undetermined: number;
+};
+
+export class GestureHandle {
+ // within gesture, States can be used as unique IDs pointing to the item pool
+ _itemIds: IdObject;
+
+ get idObject() {
+ return this._itemIds;
+ }
+ set idObject(newObject: IdObject) {
+ this._itemIds = newObject;
+ }
+}
+
+export default class ChartManager {
+ private _items: Item[] = [];
+ private _connections: ChartConnection[] = [];
+ private _layout: number[][];
+ private _listeners: Map void>> =
+ useMemo(() => new Map(), []);
+
+ public static EMPTY_SPACE_ID = 0;
+
+ constructor() {
+ this.addItem(null, null, false);
+ }
+
+ get items(): Item[] {
+ return this._items;
+ }
+
+ get connections(): ChartConnection[] {
+ return this._connections;
+ }
+
+ get layout(): number[][] {
+ return this._layout;
+ }
+
+ set layout(layoutGrid: number[][]) {
+ this._layout = layoutGrid;
+ }
+
+ public addListener(
+ itemId: number,
+ listener: (isActive: boolean) => void
+ ): number {
+ const listenerId = this._listeners.get(itemId)?.size - 1 ?? 0;
+
+ // another map is used inside of _listeners to seamlessly remove listening functions from _listeners
+ if (this._listeners.has(itemId)) {
+ this._listeners.get(itemId).set(listenerId, listener);
+ } else {
+ this._listeners.set(itemId, new Map([[0, listener]]));
+ }
+
+ return listenerId;
+ }
+
+ public removeListener(itemId: number, listenerId: number): void {
+ this._listeners.get(itemId).delete(listenerId);
+ }
+
+ public clearListeners(): void {
+ this._listeners.clear();
+ }
+
+ public addItem(
+ label: State | string = null,
+ subtext: string | null = null,
+ isVisible: boolean = true
+ ): [(isActive: boolean) => void, number] {
+ const newId = this._items.length;
+
+ if (typeof label == 'number') {
+ label = stateToName.get(label);
+ }
+
+ let highlightColor = labelColorMap.get(label) ?? Colors.YELLOW;
+
+ const newItem = {
+ id: newId,
+ label: label,
+ subtext: subtext,
+ position: null,
+ isVisible: isVisible,
+ highlightColor: highlightColor,
+ };
+
+ this._items.push(newItem);
+
+ // this callback will be used by a .onX hook to broadcast this event to all listeners
+ return [
+ (isActive: boolean) => {
+ this._listeners.get(newId)?.forEach((listener) => listener(isActive));
+ },
+ newId,
+ ];
+ }
+
+ public addConnection(fromId: number, toId: number) {
+ this._connections.push({
+ id: this._connections.length,
+ from: fromId,
+ to: toId,
+ });
+ }
+
+ public newGesture(
+ gesture: GesturesUnion
+ ): [GestureHandle, GesturesUnion, () => void] {
+ const [beganCallback, beganId] = this.addItem(State.BEGAN);
+ const [activeCallback, activeId] = this.addItem(State.ACTIVE);
+ const [endCallback, endId] = this.addItem(State.END);
+ const [failedCallback, failedId] = this.addItem(State.FAILED);
+ const [cancelledCallback, cancelledId] = this.addItem(State.CANCELLED);
+ const [undeterminedCallback, undeterminedId] = this.addItem(
+ State.UNDETERMINED
+ );
+
+ const handle = new GestureHandle();
+ handle.idObject = {
+ began: beganId,
+ active: activeId,
+ end: endId,
+ failed: failedId,
+ cancelled: cancelledId,
+ undetermined: undeterminedId,
+ } as IdObject;
+
+ undeterminedCallback(true);
+
+ const resetAllStates = (event: GestureStateChangeEvent) => {
+ undeterminedCallback(true);
+ if (event.state == State.FAILED) {
+ failedCallback(true);
+ }
+ if (event.state == State.CANCELLED) {
+ cancelledCallback(true);
+ }
+ setTimeout(() => {
+ beganCallback(false);
+ activeCallback(false);
+ }, WAVE_DELAY_MS);
+ setTimeout(() => {
+ endCallback(false);
+ failedCallback(false);
+ cancelledCallback(false);
+ }, 2 * WAVE_DELAY_MS);
+ };
+
+ gesture
+ .onBegin(() => {
+ beganCallback(true);
+ undeterminedCallback(false);
+ })
+ .onStart(() => {
+ beganCallback(false);
+ activeCallback(true);
+ })
+ .onEnd(() => {
+ endCallback(true);
+ })
+ .onFinalize((event: GestureStateChangeEvent) => {
+ resetAllStates(event);
+ });
+
+ [
+ [undeterminedId, beganId],
+ [beganId, activeId],
+ [beganId, failedId],
+ [activeId, endId],
+ [activeId, cancelledId],
+ [beganId, cancelledId],
+ ].forEach(([from, to]) => {
+ this.addConnection(from, to);
+ });
+
+ const resetCb = () => {
+ undeterminedCallback(true);
+ };
+
+ return [handle, gesture, resetCb];
+ }
+}
diff --git a/docs/src/examples/GestureStateFlowExample/FlowChart.tsx b/docs/src/examples/GestureStateFlowExample/FlowChart.tsx
new file mode 100644
index 0000000000..45b0c7ec27
--- /dev/null
+++ b/docs/src/examples/GestureStateFlowExample/FlowChart.tsx
@@ -0,0 +1,106 @@
+import 'react-native-gesture-handler';
+import React, { useEffect, useRef, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+import ChartManager from './ChartManager';
+import { Grid } from '@mui/material';
+import ChartItem from './ChartItem';
+import Arrow from './Arrow';
+
+type Coordinate = {
+ x: number;
+ y: number;
+};
+
+type FlowChartProps = {
+ chartManager: ChartManager;
+};
+
+export default function FlowChart({ chartManager }: FlowChartProps) {
+ const itemsRef = useRef([]);
+ const itemsCoordsRef = useRef([]);
+ const rootRef = useRef(null);
+
+ // there's a bug where arrows are not shown on the first render on production build
+ // i hate this but it forces a re-render after the component is mounted
+ // a man's gotta do what a man's gotta do
+ const [counter, setCounter] = useState(0);
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ setCounter(counter + 1);
+ }, 0);
+ return () => clearTimeout(timeout);
+ }, []);
+
+ const getCenter = (side: number, size: number) => side + size / 2;
+
+ itemsCoordsRef.current = itemsRef.current.map((element) => {
+ // during unloading or overresizing, item may reload itself, causing it to be undefined
+ if (!element) {
+ return {
+ x: 0,
+ y: 0,
+ } as Coordinate;
+ }
+
+ const box = element.getBoundingClientRect();
+ const root = rootRef.current.getBoundingClientRect();
+ return {
+ x: getCenter(box.left, box.width) - root.left,
+ y: getCenter(box.top, box.height) - root.top,
+ } as Coordinate;
+ });
+
+ return (
+
+
+ {chartManager.layout.map((row, index) => (
+
+ {row
+ .map((itemId) => chartManager.items[itemId])
+ .map((item, index) => (
+ (itemsRef.current[item.id] = el)}
+ item={item}
+ chartManager={chartManager}
+ />
+ ))}
+
+ ))}
+
+ {chartManager.connections.map((connection) => {
+ // we have all the connections layed out,
+ // but the user may choose not to use some of the available items,
+ if (
+ !itemsCoordsRef.current[connection.from] ||
+ !itemsCoordsRef.current[connection.to]
+ ) {
+ return ;
+ }
+ return (
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ paddingHorizontal: 40,
+ },
+});
diff --git a/docs/src/examples/GestureStateFlowExample/index.tsx b/docs/src/examples/GestureStateFlowExample/index.tsx
new file mode 100644
index 0000000000..902e59117e
--- /dev/null
+++ b/docs/src/examples/GestureStateFlowExample/index.tsx
@@ -0,0 +1,155 @@
+import 'react-native-gesture-handler';
+import React, { useEffect, useMemo, useRef } from 'react';
+import { StyleSheet, View, useWindowDimensions, Text } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+import {
+ Gesture,
+ GestureDetector,
+ GestureHandlerRootView,
+} from 'react-native-gesture-handler';
+import ChartManager from './ChartManager';
+import FlowChart from './FlowChart';
+
+// widths pulled from CSS
+const MIN_DESKTOP_WIDTH = 1298;
+
+export default function App() {
+ const chartManager = useRef(new ChartManager());
+
+ const [panHandle, capturedPan, resetPan] = useMemo(
+ () => chartManager.current.newGesture(Gesture.Pan()),
+ []
+ );
+
+ const [pressHandle, capturedPress, resetLongPress] = useMemo(
+ () => chartManager.current.newGesture(Gesture.LongPress()),
+ []
+ );
+
+ useEffect(() => {
+ resetPan();
+ resetLongPress();
+ }, []);
+
+ const panIds = panHandle.idObject;
+ const pressIds = pressHandle.idObject;
+
+ const dimensions = useWindowDimensions();
+ const isDesktopMode = dimensions.width > MIN_DESKTOP_WIDTH;
+
+ // prettier-ignore
+ const desktopLayout = [
+ [panIds.undetermined, ChartManager.EMPTY_SPACE_ID, pressIds.undetermined, ChartManager.EMPTY_SPACE_ID],
+ [panIds.began, panIds.failed, pressIds.began, pressIds.failed],
+ [panIds.active, panIds.cancelled, pressIds.active, pressIds.cancelled],
+ [panIds.end, ChartManager.EMPTY_SPACE_ID, pressIds.end, ChartManager.EMPTY_SPACE_ID],
+ ];
+
+ // prettier-ignore
+ const phoneLayout = [
+ [panIds.undetermined],
+ [panIds.began, panIds.failed],
+ [panIds.active, panIds.cancelled],
+ [panIds.end, ChartManager.EMPTY_SPACE_ID],
+ ];
+
+ chartManager.current.layout = isDesktopMode ? desktopLayout : phoneLayout;
+
+ const pressed = useSharedValue(false);
+
+ const offset = useSharedValue(0);
+ const scale = useSharedValue(1);
+
+ const pan = Gesture.Pan()
+ .onBegin(() => {
+ pressed.value = true;
+ })
+ .onStart(() => {
+ scale.value = withSpring(0.7);
+ })
+ .onFinalize(() => {
+ offset.value = withSpring(0, { damping: 20, stiffness: 150 });
+ scale.value = withTiming(1);
+ pressed.value = false;
+ })
+ .onUpdate((event) => {
+ offset.value = event.translationX;
+ });
+
+ const press = Gesture.LongPress()
+ .onStart(() => {
+ scale.value = withSpring(1.3, { stiffness: 175 });
+ })
+ .onFinalize(() => {
+ scale.value = withTiming(1);
+ });
+
+ const composedPan = Gesture.Simultaneous(pan, capturedPan);
+ const composedPress = Gesture.Simultaneous(press, capturedPress);
+ const composed = Gesture.Race(composedPan, composedPress);
+
+ const animatedStyles = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: withSpring(offset.value, { duration: 1000 }) },
+ { scale: scale.value },
+ ],
+ backgroundColor: pressed.value ? '#ffe04b' : '#b58df1',
+ }));
+
+ return (
+ <>
+
+
+ Gesture.Pan()
+ {isDesktopMode && (
+ Gesture.LongPress()
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ chartContainer: {
+ marginBottom: 60,
+ },
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ },
+ circle: {
+ height: 120,
+ width: 120,
+ borderRadius: 500,
+ cursor: 'grab',
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ width: '100%',
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginTop: 24,
+ marginBottom: 14,
+ color: 'var(--ifm-font-color-base)',
+ },
+});
diff --git a/docs/src/theme/MDXComponents.js b/docs/src/theme/MDXComponents.js
new file mode 100644
index 0000000000..ffd713462a
--- /dev/null
+++ b/docs/src/theme/MDXComponents.js
@@ -0,0 +1,13 @@
+// Import the original mapper
+import MDXComponents from '@theme-original/MDXComponents';
+import InteractiveExample from '@site/src/components/InteractiveExample';
+import CollapsibleCode from '@site/src/components/CollapsibleCode';
+
+export default {
+ // Re-use the default mapping
+ ...MDXComponents,
+ // Map the "" tag to our Highlight component
+ // `Highlight` will receive all props that were passed to `` in MDX
+ InteractiveExample,
+ CollapsibleCode,
+};
diff --git a/docs/static/img/copy-dark.svg b/docs/static/img/copy-dark.svg
new file mode 100644
index 0000000000..44ebb8a593
--- /dev/null
+++ b/docs/static/img/copy-dark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/docs/static/img/copy.svg b/docs/static/img/copy.svg
new file mode 100644
index 0000000000..0598022092
--- /dev/null
+++ b/docs/static/img/copy.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/docs/static/img/reset-dark.svg b/docs/static/img/reset-dark.svg
new file mode 100644
index 0000000000..f792dab564
--- /dev/null
+++ b/docs/static/img/reset-dark.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/docs/static/img/reset.svg b/docs/static/img/reset.svg
new file mode 100644
index 0000000000..5995887149
--- /dev/null
+++ b/docs/static/img/reset.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+