@rootnative/inertia 0.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/gestureLayer/index.d.mts +119 -0
- package/dist/gestureLayer/index.d.ts +119 -0
- package/dist/gestureLayer/index.js +346 -0
- package/dist/gestureLayer/index.js.map +1 -0
- package/dist/gestureLayer/index.mjs +344 -0
- package/dist/gestureLayer/index.mjs.map +1 -0
- package/dist/index.d.mts +507 -0
- package/dist/index.d.ts +507 -0
- package/dist/index.js +1546 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1515 -0
- package/dist/index.mjs.map +1 -0
- package/dist/motion/Image.d.mts +12 -0
- package/dist/motion/Image.d.ts +12 -0
- package/dist/motion/Image.js +1138 -0
- package/dist/motion/Image.js.map +1 -0
- package/dist/motion/Image.mjs +1132 -0
- package/dist/motion/Image.mjs.map +1 -0
- package/dist/motion/Pressable.d.mts +15 -0
- package/dist/motion/Pressable.d.ts +15 -0
- package/dist/motion/Pressable.js +1138 -0
- package/dist/motion/Pressable.js.map +1 -0
- package/dist/motion/Pressable.mjs +1132 -0
- package/dist/motion/Pressable.mjs.map +1 -0
- package/dist/motion/ScrollView.d.mts +12 -0
- package/dist/motion/ScrollView.d.ts +12 -0
- package/dist/motion/ScrollView.js +1138 -0
- package/dist/motion/ScrollView.js.map +1 -0
- package/dist/motion/ScrollView.mjs +1132 -0
- package/dist/motion/ScrollView.mjs.map +1 -0
- package/dist/motion/Text.d.mts +11 -0
- package/dist/motion/Text.d.ts +11 -0
- package/dist/motion/Text.js +1138 -0
- package/dist/motion/Text.js.map +1 -0
- package/dist/motion/Text.mjs +1132 -0
- package/dist/motion/Text.mjs.map +1 -0
- package/dist/motion/View.d.mts +11 -0
- package/dist/motion/View.d.ts +11 -0
- package/dist/motion/View.js +1138 -0
- package/dist/motion/View.js.map +1 -0
- package/dist/motion/View.mjs +1132 -0
- package/dist/motion/View.mjs.map +1 -0
- package/dist/testing/index.d.mts +57 -0
- package/dist/testing/index.d.ts +57 -0
- package/dist/testing/index.js +19 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/index.mjs +16 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/touch/index.d.mts +146 -0
- package/dist/touch/index.d.ts +146 -0
- package/dist/touch/index.js +166 -0
- package/dist/touch/index.js.map +1 -0
- package/dist/touch/index.mjs +164 -0
- package/dist/touch/index.mjs.map +1 -0
- package/dist/types-cU43dEmH.d.mts +339 -0
- package/dist/types-cU43dEmH.d.ts +339 -0
- package/dist/useGesture-BnY65PlQ.d.ts +84 -0
- package/dist/useGesture-DxtXdz-K.d.mts +84 -0
- package/jest-preset.js +33 -0
- package/jest-setup.js +213 -0
- package/llms.txt +142 -0
- package/package.json +151 -0
- package/src/__type-tests__/animate.test-d.tsx +88 -0
- package/src/__type-tests__/variants.test-d.tsx +67 -0
- package/src/config/MotionConfig.tsx +30 -0
- package/src/config/MotionConfigContext.ts +53 -0
- package/src/config/index.ts +9 -0
- package/src/gestureLayer/index.ts +21 -0
- package/src/gestureLayer/useGestureLayer.ts +285 -0
- package/src/gestures/focusVisibility.ts +61 -0
- package/src/gestures/index.ts +1 -0
- package/src/index.ts +72 -0
- package/src/layout/index.ts +16 -0
- package/src/layout/resolveLayout.ts +54 -0
- package/src/layout/sharedRegistry.ts +111 -0
- package/src/layout/useSharedLayout.ts +289 -0
- package/src/motion/Image.tsx +9 -0
- package/src/motion/Pressable.tsx +12 -0
- package/src/motion/ScrollView.tsx +9 -0
- package/src/motion/Text.tsx +8 -0
- package/src/motion/View.tsx +8 -0
- package/src/motion/createMotionComponent.tsx +1306 -0
- package/src/motion/index.ts +26 -0
- package/src/motion/installCheck.ts +65 -0
- package/src/presence/Presence.tsx +165 -0
- package/src/presence/PresenceContext.ts +28 -0
- package/src/presence/index.ts +6 -0
- package/src/testing/index.ts +78 -0
- package/src/touch/index.ts +18 -0
- package/src/touch/useTouchDrag.ts +289 -0
- package/src/transitions/easing.ts +53 -0
- package/src/transitions/index.ts +7 -0
- package/src/transitions/keys.ts +32 -0
- package/src/transitions/resolve.ts +169 -0
- package/src/transitions/resolveSequence.ts +87 -0
- package/src/transitions/runtime.ts +63 -0
- package/src/transitions/sig.ts +40 -0
- package/src/transitions/spring.ts +42 -0
- package/src/types.ts +383 -0
- package/src/values/index.ts +26 -0
- package/src/values/useAnimation.ts +69 -0
- package/src/values/useBooleanSpring.ts +33 -0
- package/src/values/useColorTransition.ts +72 -0
- package/src/values/useGesture.ts +144 -0
- package/src/values/useMotionValue.ts +33 -0
- package/src/values/useScroll.ts +72 -0
- package/src/values/useShadow.ts +116 -0
- package/src/values/useSpring.ts +98 -0
- package/src/values/useTransform.ts +132 -0
- package/src/values/useVariants.ts +60 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MotionImage } from './Image'
|
|
2
|
+
import { MotionPressable } from './Pressable'
|
|
3
|
+
import { MotionScrollView } from './ScrollView'
|
|
4
|
+
import { MotionText } from './Text'
|
|
5
|
+
import { MotionView } from './View'
|
|
6
|
+
|
|
7
|
+
export { createMotionComponent } from './createMotionComponent'
|
|
8
|
+
export {
|
|
9
|
+
MotionView,
|
|
10
|
+
MotionText,
|
|
11
|
+
MotionImage,
|
|
12
|
+
MotionPressable,
|
|
13
|
+
MotionScrollView,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `Motion.*` namespace. Each property is a primitive with its style prop
|
|
18
|
+
* inferred from the underlying RN component. There is no shared style fallback.
|
|
19
|
+
*/
|
|
20
|
+
export const Motion = {
|
|
21
|
+
View: MotionView,
|
|
22
|
+
Text: MotionText,
|
|
23
|
+
Image: MotionImage,
|
|
24
|
+
Pressable: MotionPressable,
|
|
25
|
+
ScrollView: MotionScrollView,
|
|
26
|
+
} as const
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { reanimatedVersion } from 'react-native-reanimated'
|
|
2
|
+
|
|
3
|
+
declare const __DEV__: boolean
|
|
4
|
+
declare const process: { env?: Record<string, string | undefined> }
|
|
5
|
+
|
|
6
|
+
let alreadyChecked = false
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Surface a clear, actionable error at first `createMotionComponent` call when
|
|
10
|
+
* the consumer's Reanimated install is broken. Production builds, repeat calls,
|
|
11
|
+
* and Jest test runs are all skipped — the check is purely a dev-time
|
|
12
|
+
* paper-cut sander for the two failure modes we can detect from JS:
|
|
13
|
+
*
|
|
14
|
+
* 1. `react-native-reanimated` resolves but is on a v3.x line we don't
|
|
15
|
+
* support (the plugin name and worklet runtime both changed at v4).
|
|
16
|
+
* 2. The worklets babel plugin (`react-native-worklets/plugin` in v4) isn't
|
|
17
|
+
* wired into `babel.config.js`, so `'worklet'` directives are dead strings
|
|
18
|
+
* and the first `withSpring` / `withTiming` call would crash on the UI
|
|
19
|
+
* thread with a generic "non-worklet function called" error.
|
|
20
|
+
*
|
|
21
|
+
* The "Reanimated isn't installed at all" case isn't handled here — Metro
|
|
22
|
+
* fails to resolve the static `import 'react-native-reanimated'` at the top
|
|
23
|
+
* of `createMotionComponent.tsx` long before this check runs.
|
|
24
|
+
*/
|
|
25
|
+
export function ensureReanimatedInstalled(): void {
|
|
26
|
+
if (!__DEV__ || alreadyChecked) return
|
|
27
|
+
// The standard `react-native-reanimated/mock` doesn't run the worklets
|
|
28
|
+
// babel plugin, so the marker probe would false-positive every test run.
|
|
29
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
alreadyChecked = true
|
|
33
|
+
|
|
34
|
+
// Read the version off Reanimated's own runtime export rather than reaching
|
|
35
|
+
// into its `package.json`. A `require('.../package.json')` here would make
|
|
36
|
+
// esbuild emit a `__require` shim that throws on web bundlers (Expo web), and
|
|
37
|
+
// Reanimated's `exports` field may block the subpath anyway.
|
|
38
|
+
const version: string | undefined = reanimatedVersion
|
|
39
|
+
|
|
40
|
+
if (version) {
|
|
41
|
+
const major = parseInt(version.split('.')[0] ?? '0', 10)
|
|
42
|
+
if (major < 4) {
|
|
43
|
+
console.error(
|
|
44
|
+
`[inertia] react-native-reanimated v${version} is installed, but @rootnative/inertia requires v4.0.0 or later. ` +
|
|
45
|
+
`Upgrade with \`pnpm add react-native-reanimated@^4\` (or your package manager's equivalent).`,
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The worklets plugin rewrites any function carrying a top-of-body
|
|
52
|
+
// `'worklet'` directive to expose a `__workletHash` property at runtime.
|
|
53
|
+
// Its absence means the plugin didn't run.
|
|
54
|
+
const probe = function probe() {
|
|
55
|
+
'worklet'
|
|
56
|
+
return 0
|
|
57
|
+
} as { __workletHash?: number }
|
|
58
|
+
if (typeof probe.__workletHash !== 'number') {
|
|
59
|
+
console.error(
|
|
60
|
+
`[inertia] The Reanimated worklets babel plugin is not configured. ` +
|
|
61
|
+
`Add \`'react-native-worklets/plugin'\` as the LAST entry in the \`plugins\` array of your \`babel.config.js\`, ` +
|
|
62
|
+
`then restart Metro with a fresh cache: \`npx expo start -c\` or \`npx react-native start --reset-cache\`.`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
type Key,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import { PresenceContext, type PresenceContextValue } from './PresenceContext'
|
|
13
|
+
|
|
14
|
+
interface RenderEntry {
|
|
15
|
+
key: Key
|
|
16
|
+
element: ReactElement
|
|
17
|
+
isPresent: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wrap a list of children with mount / unmount transitions. When a child is
|
|
22
|
+
* removed from the incoming list it stays in the snapshot until its exit
|
|
23
|
+
* animation completes; descendants consume the per-child `<PresenceContext>`
|
|
24
|
+
* to coordinate.
|
|
25
|
+
*
|
|
26
|
+
* Children must be `<Motion.*>` primitives (or any component that consumes
|
|
27
|
+
* `usePresence()` and calls `safeToRemove`). Plain elements without that
|
|
28
|
+
* contract will linger in the snapshot once removed; document that and pick
|
|
29
|
+
* the right primitive.
|
|
30
|
+
*
|
|
31
|
+
* Children also need explicit `key`s so removal is detectable across
|
|
32
|
+
* renders. Without a key, React falls back to positional identity and
|
|
33
|
+
* removal looks like a prop change — Presence has nothing to mark exiting.
|
|
34
|
+
*/
|
|
35
|
+
export function Presence({ children }: { children: ReactNode }) {
|
|
36
|
+
const incoming = useMemo(() => {
|
|
37
|
+
const out: ReactElement[] = []
|
|
38
|
+
Children.forEach(children, (child) => {
|
|
39
|
+
if (!isValidElement(child)) return
|
|
40
|
+
if (child.key === null) {
|
|
41
|
+
if (__DEV__) {
|
|
42
|
+
console.warn(
|
|
43
|
+
'[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
out.push(child)
|
|
49
|
+
})
|
|
50
|
+
return out
|
|
51
|
+
}, [children])
|
|
52
|
+
|
|
53
|
+
// Snapshot of elements removed from `incoming` whose exit animation is
|
|
54
|
+
// still in flight. setExiting is called synchronously during render below
|
|
55
|
+
// (the documented pattern for derived-from-prop-change state), so React
|
|
56
|
+
// re-renders with the new snapshot before committing — no visual frame
|
|
57
|
+
// where the departing child has vanished.
|
|
58
|
+
const [exiting, setExiting] = useState<Map<Key, ReactElement>>(
|
|
59
|
+
() => new Map(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// Tracks the previous render's `incoming` so we can diff. Updated
|
|
63
|
+
// synchronously alongside the setState call.
|
|
64
|
+
const prevIncomingRef = useRef<ReactElement[]>(incoming)
|
|
65
|
+
|
|
66
|
+
if (prevIncomingRef.current !== incoming) {
|
|
67
|
+
const prev = prevIncomingRef.current
|
|
68
|
+
prevIncomingRef.current = incoming
|
|
69
|
+
const incomingKeys = new Set(incoming.map((el) => el.key as Key))
|
|
70
|
+
let next: Map<Key, ReactElement> | null = null
|
|
71
|
+
const ensureMutable = () => {
|
|
72
|
+
if (!next) next = new Map(exiting)
|
|
73
|
+
return next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Departures: in prev but not in current → snapshot for exit.
|
|
77
|
+
for (const oldEl of prev) {
|
|
78
|
+
const key = oldEl.key as Key
|
|
79
|
+
if (!incomingKeys.has(key) && !exiting.has(key)) {
|
|
80
|
+
ensureMutable().set(key, oldEl)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Returns: was exiting and reappears → drop the snapshot. The live
|
|
84
|
+
// `incoming` entry takes over with the same key, so React reconciles
|
|
85
|
+
// the underlying Motion instance and the in-flight exit animation
|
|
86
|
+
// interrupts back toward `animate` values.
|
|
87
|
+
for (const el of incoming) {
|
|
88
|
+
const key = el.key as Key
|
|
89
|
+
if (exiting.has(key)) {
|
|
90
|
+
ensureMutable().delete(key)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (next) setExiting(next)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handleRemove = useCallback((key: Key) => {
|
|
98
|
+
setExiting((prev) => {
|
|
99
|
+
if (!prev.has(key)) return prev
|
|
100
|
+
const next = new Map(prev)
|
|
101
|
+
next.delete(key)
|
|
102
|
+
return next
|
|
103
|
+
})
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
// Single combined render list. Putting `incoming` and `exiting` entries in
|
|
107
|
+
// one array (rather than two `.map` calls inside a fragment) ensures React
|
|
108
|
+
// reconciles by `key` across positions — when an entry moves from
|
|
109
|
+
// present-list to exiting-list, the component instance persists.
|
|
110
|
+
const renderList: RenderEntry[] = []
|
|
111
|
+
for (const el of incoming) {
|
|
112
|
+
renderList.push({
|
|
113
|
+
key: el.key as Key,
|
|
114
|
+
element: el,
|
|
115
|
+
isPresent: true,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
for (const [key, el] of exiting) {
|
|
119
|
+
if (!renderList.some((entry) => entry.key === key)) {
|
|
120
|
+
renderList.push({ key, element: el, isPresent: false })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
{renderList.map(({ key, element, isPresent }) => (
|
|
127
|
+
<PresenceItem
|
|
128
|
+
key={key}
|
|
129
|
+
itemKey={key}
|
|
130
|
+
isPresent={isPresent}
|
|
131
|
+
onRemove={handleRemove}
|
|
132
|
+
>
|
|
133
|
+
{element}
|
|
134
|
+
</PresenceItem>
|
|
135
|
+
))}
|
|
136
|
+
</>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function PresenceItem({
|
|
141
|
+
itemKey,
|
|
142
|
+
isPresent,
|
|
143
|
+
onRemove,
|
|
144
|
+
children,
|
|
145
|
+
}: {
|
|
146
|
+
itemKey: Key
|
|
147
|
+
isPresent: boolean
|
|
148
|
+
onRemove: (key: Key) => void
|
|
149
|
+
children: ReactNode
|
|
150
|
+
}) {
|
|
151
|
+
const value = useMemo<PresenceContextValue>(
|
|
152
|
+
() => ({
|
|
153
|
+
isPresent,
|
|
154
|
+
safeToRemove: () => onRemove(itemKey),
|
|
155
|
+
}),
|
|
156
|
+
[isPresent, itemKey, onRemove],
|
|
157
|
+
)
|
|
158
|
+
return (
|
|
159
|
+
<PresenceContext.Provider value={value}>
|
|
160
|
+
{children}
|
|
161
|
+
</PresenceContext.Provider>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
declare const __DEV__: boolean
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-child contract between `<Presence>` and its descendant Motion
|
|
5
|
+
* primitives. `<Presence>` provides a fresh value to each rendered child;
|
|
6
|
+
* Motion primitives consume it to gate exit animations.
|
|
7
|
+
*
|
|
8
|
+
* - `isPresent`: `true` while the child is in the incoming children list.
|
|
9
|
+
* Flips to `false` when the parent removes it; the child remains rendered
|
|
10
|
+
* until `safeToRemove` is called.
|
|
11
|
+
* - `safeToRemove`: callback the child invokes when its exit animation has
|
|
12
|
+
* settled. `<Presence>` then drops the snapshot entry and unmounts.
|
|
13
|
+
*/
|
|
14
|
+
export interface PresenceContextValue {
|
|
15
|
+
isPresent: boolean
|
|
16
|
+
safeToRemove: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PresenceContext = createContext<PresenceContextValue | null>(null)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the surrounding `<Presence>` contract from a child component. Returns
|
|
23
|
+
* `null` when there is no `<Presence>` ancestor — useful for components that
|
|
24
|
+
* want to support both standalone and Presence-wrapped use without branching.
|
|
25
|
+
*/
|
|
26
|
+
export function usePresence(): PresenceContextValue | null {
|
|
27
|
+
return useContext(PresenceContext)
|
|
28
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers for Inertia consumers.
|
|
3
|
+
*
|
|
4
|
+
* The Reanimated Jest mock that ships with the library is **static-render**:
|
|
5
|
+
* `useAnimatedStyle` runs the worklet exactly once per call, and shared
|
|
6
|
+
* values are plain `{ value }` refs. After the animation effect fires
|
|
7
|
+
* (`sv.value = withSpring(target) → target` under the mock), the rendered
|
|
8
|
+
* style has already been captured at the at-rest shared-value snapshot —
|
|
9
|
+
* so without intervention, every Inertia component looks frozen at its
|
|
10
|
+
* `initial` values in tests.
|
|
11
|
+
*
|
|
12
|
+
* `renderWithMotion` papers over that by forcing a second render after the
|
|
13
|
+
* first effect pass. The shared values now hold their target values, so
|
|
14
|
+
* `useAnimatedStyle` re-evaluates against the post-animation state and the
|
|
15
|
+
* rendered styles match what a real device would settle on.
|
|
16
|
+
*
|
|
17
|
+
* Use this from `@rootnative/inertia/testing`:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { renderWithMotion } from '@rootnative/inertia/testing'
|
|
21
|
+
*
|
|
22
|
+
* const { getByTestId } = renderWithMotion(
|
|
23
|
+
* <Motion.View testID="card" initial={{ opacity: 0 }} animate={{ opacity: 1 }} />,
|
|
24
|
+
* )
|
|
25
|
+
* // getByTestId('card') has opacity: 1, not 0.
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* For tests that need to re-render with new props and re-flush, use
|
|
29
|
+
* `flushMotion(result, nextUi)`.
|
|
30
|
+
*/
|
|
31
|
+
import { render, type RenderOptions } from '@testing-library/react-native'
|
|
32
|
+
import { cloneElement, type ReactElement } from 'react'
|
|
33
|
+
|
|
34
|
+
type RenderResult = ReturnType<typeof render>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a Motion subtree and immediately flush animations to their target
|
|
38
|
+
* values. Returns the standard `@testing-library/react-native` render result.
|
|
39
|
+
*
|
|
40
|
+
* Internally this calls `render(...)`, then re-renders the same element
|
|
41
|
+
* inside `act(...)` so the post-effect shared-value updates flow into the
|
|
42
|
+
* `useAnimatedStyle` re-run. Both passes happen synchronously — the call
|
|
43
|
+
* returns once styles reflect the `animate` target.
|
|
44
|
+
*/
|
|
45
|
+
export function renderWithMotion(
|
|
46
|
+
ui: ReactElement,
|
|
47
|
+
options?: RenderOptions,
|
|
48
|
+
): RenderResult {
|
|
49
|
+
const result = render(ui, options)
|
|
50
|
+
// `cloneElement` produces a fresh element with the same props so React
|
|
51
|
+
// doesn't bail out of the second render via reference-equal short-circuit
|
|
52
|
+
// — that's what causes `useAnimatedStyle` to skip its post-effect re-read
|
|
53
|
+
// when the same element is passed to `rerender`.
|
|
54
|
+
flushMotion(result, cloneElement(ui))
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Re-render a previously-mounted Motion subtree to flush pending animations
|
|
60
|
+
* to their target values. Pass the same element you originally rendered
|
|
61
|
+
* (or a new one for tests that update props between flushes).
|
|
62
|
+
*
|
|
63
|
+
* The flush is synchronous. `@testing-library/react-native`'s `rerender`
|
|
64
|
+
* already wraps in `act` internally, so no explicit `act(...)` is needed
|
|
65
|
+
* here.
|
|
66
|
+
*/
|
|
67
|
+
export function flushMotion(rendered: RenderResult, ui: ReactElement): void {
|
|
68
|
+
// One re-render is enough for non-sequence animations: the mount-effect
|
|
69
|
+
// has run, shared values now hold their target values, and the next
|
|
70
|
+
// `useAnimatedStyle` invocation will read them. Sequence steps chain
|
|
71
|
+
// through `withSpring` / `withTiming` settle callbacks — those are still
|
|
72
|
+
// captured for tests that invoke them manually (see `onAnimationEnd.test`).
|
|
73
|
+
//
|
|
74
|
+
// `cloneElement` defeats React's reference-equal element bail-out so the
|
|
75
|
+
// second render actually reaches `useAnimatedStyle` instead of being
|
|
76
|
+
// short-circuited by the reconciler.
|
|
77
|
+
rendered.rerender(cloneElement(ui))
|
|
78
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PanResponder-backed drag hook. Lives in core because `PanResponder` is
|
|
3
|
+
* built into React Native — no extra peer dependency. Use this when you
|
|
4
|
+
* need keyboard a11y alongside drag, or when you don't want to take
|
|
5
|
+
* `react-native-gesture-handler` as a dependency.
|
|
6
|
+
*
|
|
7
|
+
* For pointer-only drag in a project that already uses gesture-handler,
|
|
8
|
+
* prefer `useDrag` from `@rootnative/inertia-gestures` — its UI-thread
|
|
9
|
+
* release path is more precise.
|
|
10
|
+
*/
|
|
11
|
+
export { useTouchDrag } from './useTouchDrag'
|
|
12
|
+
export type {
|
|
13
|
+
TouchReleaseInfo,
|
|
14
|
+
TouchReleaseResult,
|
|
15
|
+
TouchReleaseTransition,
|
|
16
|
+
UseTouchDragOptions,
|
|
17
|
+
UseTouchDragResult,
|
|
18
|
+
} from './useTouchDrag'
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
PanResponder,
|
|
4
|
+
type PanResponderGestureState,
|
|
5
|
+
type PanResponderInstance,
|
|
6
|
+
} from 'react-native'
|
|
7
|
+
import {
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useSharedValue,
|
|
10
|
+
type SharedValue,
|
|
11
|
+
} from 'react-native-reanimated'
|
|
12
|
+
import { buildReleaseAnimation } from '../transitions'
|
|
13
|
+
import type { TransitionConfig } from '../types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Same drag-result shape as `useDrag` from `@rootnative/inertia-gestures`,
|
|
17
|
+
* minus the `gesture` field (PanResponder spreads handlers, no
|
|
18
|
+
* `<GestureDetector>` wrapper). The shared values + animatedStyle are
|
|
19
|
+
* interchangeable across both hooks; consumers can swap implementations
|
|
20
|
+
* without touching their `useAnimatedStyle` consumers.
|
|
21
|
+
*/
|
|
22
|
+
export interface UseTouchDragResult {
|
|
23
|
+
/** Spread onto a `View` / `Pressable` to install the pan responder. */
|
|
24
|
+
panHandlers: PanResponderInstance['panHandlers']
|
|
25
|
+
/** Stable animated `transform` style. */
|
|
26
|
+
animatedStyle: ReturnType<typeof useAnimatedStyle>
|
|
27
|
+
/** Live x translation, persistent across gestures. */
|
|
28
|
+
dragX: SharedValue<number>
|
|
29
|
+
/** Live y translation, persistent across gestures. */
|
|
30
|
+
dragY: SharedValue<number>
|
|
31
|
+
/** True while the gesture is active. */
|
|
32
|
+
isDragging: SharedValue<boolean>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Release transition shape for PanResponder's JS-thread `onRelease`. Mirrors
|
|
37
|
+
* the gesture-handler adapter's `ReleaseTransition` but with `to` typed as
|
|
38
|
+
* required for spring/timing/no-animation (decay omits it).
|
|
39
|
+
*/
|
|
40
|
+
export type TouchReleaseTransition =
|
|
41
|
+
| (TransitionConfig & { type: 'spring'; to: number })
|
|
42
|
+
| (TransitionConfig & { type: 'timing'; to: number })
|
|
43
|
+
| (TransitionConfig & { type: 'decay' })
|
|
44
|
+
| (TransitionConfig & { type: 'no-animation'; to: number })
|
|
45
|
+
|
|
46
|
+
export interface TouchReleaseInfo {
|
|
47
|
+
x: number
|
|
48
|
+
y: number
|
|
49
|
+
velocity: { x: number; y: number }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TouchReleaseResult {
|
|
53
|
+
x?: TouchReleaseTransition
|
|
54
|
+
y?: TouchReleaseTransition
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UseTouchDragOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Restrict the drag to one axis. Defaults to `'both'`. When `'x'` is set
|
|
60
|
+
* the y-axis shared value never updates (and vice versa); velocity is
|
|
61
|
+
* still reported on both for `onDragEnd`.
|
|
62
|
+
*/
|
|
63
|
+
axis?: 'x' | 'y' | 'both'
|
|
64
|
+
/**
|
|
65
|
+
* Travel bounds (px from resting). Each side is independently optional.
|
|
66
|
+
* Out-of-bounds values clamp to the limit unless `elastic > 0`.
|
|
67
|
+
*/
|
|
68
|
+
constraints?: {
|
|
69
|
+
left?: number
|
|
70
|
+
right?: number
|
|
71
|
+
top?: number
|
|
72
|
+
bottom?: number
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rubber-band coefficient applied to overshoot past `constraints`. `0`
|
|
76
|
+
* (default) hard-clamps; `0.2`-`0.4` is a typical Framer-Motion feel.
|
|
77
|
+
*/
|
|
78
|
+
elastic?: number
|
|
79
|
+
/**
|
|
80
|
+
* Fires when the user starts dragging. JS thread.
|
|
81
|
+
*/
|
|
82
|
+
onDragStart?: () => void
|
|
83
|
+
/**
|
|
84
|
+
* Fires when the user releases or the gesture terminates. JS thread.
|
|
85
|
+
*
|
|
86
|
+
* Velocity is in px/sec to match the `@rootnative/inertia-gestures` API
|
|
87
|
+
* (PanResponder's native `vx` / `vy` are px/ms; the hook normalizes).
|
|
88
|
+
*/
|
|
89
|
+
onDragEnd?: (info: TouchReleaseInfo) => void
|
|
90
|
+
/**
|
|
91
|
+
* Optional release-animation callback. Return per-axis release transitions
|
|
92
|
+
* to animate the SVs to a settled position via Inertia's transition
|
|
93
|
+
* resolver — spring snap-to-tick, decay with bounds, timing settle.
|
|
94
|
+
*
|
|
95
|
+
* Unlike the gesture-handler version, this callback runs on the **JS
|
|
96
|
+
* thread** (PanResponder is JS-only). The returned transitions still drive
|
|
97
|
+
* UI-thread animations via Reanimated — only the decision logic is JS-side.
|
|
98
|
+
*/
|
|
99
|
+
onRelease?: (info: TouchReleaseInfo) => TouchReleaseResult | void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* PanResponder-backed drag hook. Pointer-equivalent of `useDrag` from
|
|
104
|
+
* `@rootnative/inertia-gestures`, with two differences:
|
|
105
|
+
*
|
|
106
|
+
* 1. No `react-native-gesture-handler` peer dep required — PanResponder is
|
|
107
|
+
* built into React Native, so this lives in core.
|
|
108
|
+
* 2. Returns `panHandlers` to spread on a `View` / `Pressable` instead of
|
|
109
|
+
* a `gesture` to plug into `<GestureDetector>`.
|
|
110
|
+
*
|
|
111
|
+
* Use this when:
|
|
112
|
+
* - You need keyboard a11y alongside drag (a slider with arrow-key step,
|
|
113
|
+
* a scrollbar with `PageUp` / `PageDown`). PanResponder composes
|
|
114
|
+
* cleanly with `onKeyDown`; gesture-handler doesn't surface keyboard.
|
|
115
|
+
* - You don't want to take `react-native-gesture-handler` as a dependency
|
|
116
|
+
* (smaller bundle, simpler install).
|
|
117
|
+
*
|
|
118
|
+
* Skip this when:
|
|
119
|
+
* - You're already using `react-native-gesture-handler` elsewhere (use
|
|
120
|
+
* `useDrag` from `@rootnative/inertia-gestures` for consistency and
|
|
121
|
+
* better worklet-thread fidelity on release velocity).
|
|
122
|
+
* - You need momentum semantics like the gesture-handler `usePan` —
|
|
123
|
+
* PanResponder's release velocity is JS-thread and slightly less precise.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```tsx
|
|
127
|
+
* import { useTouchDrag } from '@rootnative/inertia/touch'
|
|
128
|
+
*
|
|
129
|
+
* function Slider({ ticks }: { ticks: number[] }) {
|
|
130
|
+
* const drag = useTouchDrag({
|
|
131
|
+
* axis: 'x',
|
|
132
|
+
* constraints: { left: 0, right: 280 },
|
|
133
|
+
* onRelease: (e) => {
|
|
134
|
+
* const snap = nearestTick(e.x, ticks)
|
|
135
|
+
* return { x: { type: 'spring', to: snap, velocity: e.velocity.x } }
|
|
136
|
+
* },
|
|
137
|
+
* })
|
|
138
|
+
*
|
|
139
|
+
* return (
|
|
140
|
+
* <Motion.View
|
|
141
|
+
* style={[styles.thumb, drag.animatedStyle]}
|
|
142
|
+
* {...drag.panHandlers}
|
|
143
|
+
* />
|
|
144
|
+
* )
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function useTouchDrag(
|
|
149
|
+
options: UseTouchDragOptions = {},
|
|
150
|
+
): UseTouchDragResult {
|
|
151
|
+
const { axis = 'both', constraints, elastic = 0 } = options
|
|
152
|
+
|
|
153
|
+
const dragX = useSharedValue(0)
|
|
154
|
+
const dragY = useSharedValue(0)
|
|
155
|
+
const startX = useSharedValue(0)
|
|
156
|
+
const startY = useSharedValue(0)
|
|
157
|
+
const isDragging = useSharedValue(false)
|
|
158
|
+
|
|
159
|
+
// Snapshot scalars into local consts so the responder callbacks close over
|
|
160
|
+
// primitives, not the `options` literal — a fresh `options` each render
|
|
161
|
+
// would otherwise force the PanResponder identity to change.
|
|
162
|
+
const lockX = axis !== 'y'
|
|
163
|
+
const lockY = axis !== 'x'
|
|
164
|
+
const left = constraints?.left
|
|
165
|
+
const right = constraints?.right
|
|
166
|
+
const top = constraints?.top
|
|
167
|
+
const bottom = constraints?.bottom
|
|
168
|
+
const elasticCoef = elastic
|
|
169
|
+
const { onDragStart, onDragEnd, onRelease } = options
|
|
170
|
+
|
|
171
|
+
const responder = useMemo(
|
|
172
|
+
() => buildResponder(),
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
[
|
|
175
|
+
lockX,
|
|
176
|
+
lockY,
|
|
177
|
+
left,
|
|
178
|
+
right,
|
|
179
|
+
top,
|
|
180
|
+
bottom,
|
|
181
|
+
elasticCoef,
|
|
182
|
+
onDragStart,
|
|
183
|
+
onDragEnd,
|
|
184
|
+
onRelease,
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// Hoisted out of the inline `useMemo` factory to keep the dep list readable
|
|
189
|
+
// and avoid re-declaring closure helpers each render.
|
|
190
|
+
function buildResponder(): PanResponderInstance {
|
|
191
|
+
const handleEnd = (g: PanResponderGestureState) => {
|
|
192
|
+
isDragging.value = false
|
|
193
|
+
const x = dragX.value
|
|
194
|
+
const y = dragY.value
|
|
195
|
+
// PanResponder velocity is px/ms; multiply to match the
|
|
196
|
+
// `@rootnative/inertia-gestures` API (px/sec from gesture-handler).
|
|
197
|
+
const vx = g.vx * 1000
|
|
198
|
+
const vy = g.vy * 1000
|
|
199
|
+
if (onRelease) {
|
|
200
|
+
const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
|
|
201
|
+
if (result) {
|
|
202
|
+
if (result.x && lockX) {
|
|
203
|
+
const toX = 'to' in result.x ? result.x.to : x
|
|
204
|
+
dragX.value = buildReleaseAnimation(
|
|
205
|
+
result.x,
|
|
206
|
+
toX,
|
|
207
|
+
) as unknown as number
|
|
208
|
+
}
|
|
209
|
+
if (result.y && lockY) {
|
|
210
|
+
const toY = 'to' in result.y ? result.y.to : y
|
|
211
|
+
dragY.value = buildReleaseAnimation(
|
|
212
|
+
result.y,
|
|
213
|
+
toY,
|
|
214
|
+
) as unknown as number
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (onDragEnd) onDragEnd({ x, y, velocity: { x: vx, y: vy } })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return PanResponder.create({
|
|
222
|
+
// Always claim the start so taps that turn into drags don't slip
|
|
223
|
+
// through to a parent ScrollView. Consumers can compose their own
|
|
224
|
+
// capture predicates by wrapping the returned `panHandlers`.
|
|
225
|
+
onStartShouldSetPanResponder: () => true,
|
|
226
|
+
onMoveShouldSetPanResponder: () => true,
|
|
227
|
+
onPanResponderGrant: () => {
|
|
228
|
+
startX.value = dragX.value
|
|
229
|
+
startY.value = dragY.value
|
|
230
|
+
isDragging.value = true
|
|
231
|
+
if (onDragStart) onDragStart()
|
|
232
|
+
},
|
|
233
|
+
onPanResponderMove: (_e, g) => {
|
|
234
|
+
if (lockX) {
|
|
235
|
+
dragX.value = applyBounds(
|
|
236
|
+
startX.value + g.dx,
|
|
237
|
+
left,
|
|
238
|
+
right,
|
|
239
|
+
elasticCoef,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
if (lockY) {
|
|
243
|
+
dragY.value = applyBounds(
|
|
244
|
+
startY.value + g.dy,
|
|
245
|
+
top,
|
|
246
|
+
bottom,
|
|
247
|
+
elasticCoef,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
onPanResponderRelease: (_e, g) => handleEnd(g),
|
|
252
|
+
onPanResponderTerminate: (_e, g) => handleEnd(g),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
257
|
+
transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
|
|
258
|
+
}))
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
panHandlers: responder.panHandlers,
|
|
262
|
+
animatedStyle,
|
|
263
|
+
dragX,
|
|
264
|
+
dragY,
|
|
265
|
+
isDragging,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
|
|
271
|
+
* bound is scaled by `elastic`, giving a rubber-band feel. `min` / `max`
|
|
272
|
+
* may be `undefined` to leave that side unbounded.
|
|
273
|
+
*
|
|
274
|
+
* JS-thread (PanResponder callbacks are JS, not worklets).
|
|
275
|
+
*/
|
|
276
|
+
function applyBounds(
|
|
277
|
+
value: number,
|
|
278
|
+
min: number | undefined,
|
|
279
|
+
max: number | undefined,
|
|
280
|
+
elastic: number,
|
|
281
|
+
): number {
|
|
282
|
+
if (min !== undefined && value < min) {
|
|
283
|
+
return elastic > 0 ? min + (value - min) * elastic : min
|
|
284
|
+
}
|
|
285
|
+
if (max !== undefined && value > max) {
|
|
286
|
+
return elastic > 0 ? max + (value - max) * elastic : max
|
|
287
|
+
}
|
|
288
|
+
return value
|
|
289
|
+
}
|