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' ? ( + + ) : ( + + )} + + +
+ ); +}; + +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 && ( +
+
+ + +
+ } + 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 @@ + + + + + +