aniview 1.0.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 +21 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/Aniview.d.ts +63 -0
- package/dist/Aniview.d.ts.map +1 -0
- package/dist/Aniview.js +831 -0
- package/dist/Aniview.js.map +1 -0
- package/dist/AniviewPanel.d.ts +33 -0
- package/dist/AniviewPanel.d.ts.map +1 -0
- package/dist/AniviewPanel.js +66 -0
- package/dist/AniviewPanel.js.map +1 -0
- package/dist/GestureStressTest.d.ts +3 -0
- package/dist/GestureStressTest.d.ts.map +1 -0
- package/dist/GestureStressTest.js +125 -0
- package/dist/GestureStressTest.js.map +1 -0
- package/dist/aniviewConfig.d.ts +175 -0
- package/dist/aniviewConfig.d.ts.map +1 -0
- package/dist/aniviewConfig.js +568 -0
- package/dist/aniviewConfig.js.map +1 -0
- package/dist/aniviewProvider.d.ts +93 -0
- package/dist/aniviewProvider.d.ts.map +1 -0
- package/dist/aniviewProvider.js +229 -0
- package/dist/aniviewProvider.js.map +1 -0
- package/dist/core/AniviewLock.d.ts +16 -0
- package/dist/core/AniviewLock.d.ts.map +1 -0
- package/dist/core/AniviewLock.js +18 -0
- package/dist/core/AniviewLock.js.map +1 -0
- package/dist/core/AniviewMath.d.ts +41 -0
- package/dist/core/AniviewMath.d.ts.map +1 -0
- package/dist/core/AniviewMath.js +69 -0
- package/dist/core/AniviewMath.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/useAniview.d.ts +39 -0
- package/dist/useAniview.d.ts.map +1 -0
- package/dist/useAniview.js +32 -0
- package/dist/useAniview.js.map +1 -0
- package/dist/useAniviewContext.d.ts +156 -0
- package/dist/useAniviewContext.d.ts.map +1 -0
- package/dist/useAniviewContext.js +3 -0
- package/dist/useAniviewContext.js.map +1 -0
- package/dist/useAniviewLock.d.ts +20 -0
- package/dist/useAniviewLock.d.ts.map +1 -0
- package/dist/useAniviewLock.js +32 -0
- package/dist/useAniviewLock.js.map +1 -0
- package/package.json +60 -0
- package/src/Aniview.tsx +868 -0
- package/src/AniviewPanel.tsx +141 -0
- package/src/GestureStressTest.tsx +144 -0
- package/src/__tests__/AniviewLock.test.ts +58 -0
- package/src/__tests__/AniviewMath.test.ts +211 -0
- package/src/__tests__/AniviewSnapshot.test.tsx +85 -0
- package/src/__tests__/__snapshots__/AniviewSnapshot.test.tsx.snap +7 -0
- package/src/__tests__/aniviewConfig.test.ts +70 -0
- package/src/aniviewConfig.tsx +688 -0
- package/src/aniviewProvider.tsx +307 -0
- package/src/core/AniviewLock.ts +23 -0
- package/src/core/AniviewMath.ts +107 -0
- package/src/index.ts +6 -0
- package/src/useAniview.tsx +75 -0
- package/src/useAniviewContext.tsx +170 -0
- package/src/useAniviewLock.tsx +37 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { AniviewContextType, AniviewFrame, BakedFrame, IAniviewConfig } from "./useAniviewContext";
|
|
2
|
+
import { Gesture } from "react-native-gesture-handler";
|
|
3
|
+
import {
|
|
4
|
+
SharedValue,
|
|
5
|
+
withSpring,
|
|
6
|
+
cancelAnimation,
|
|
7
|
+
makeMutable,
|
|
8
|
+
runOnJS
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import * as AniviewMath from './core/AniviewMath';
|
|
11
|
+
|
|
12
|
+
export type AdjacencyMap = Record<number, Record<number, number>>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **AniviewConfig** — Layout Engine & Gesture Orchestrator
|
|
16
|
+
*
|
|
17
|
+
* Holds the page grid definition, overlap ratios, spring physics, and
|
|
18
|
+
* adjacency rules. It provides two critical pipelines:
|
|
19
|
+
*
|
|
20
|
+
* ### 1. Coordinate Mapping (`getPageOffset`, `register`)
|
|
21
|
+
* Converts a `pageId` into (x, y) world coordinates by walking the layout
|
|
22
|
+
* matrix and accumulating page widths/heights minus overlap ratios. The
|
|
23
|
+
* `register()` method is called during the Aniview bake phase to pre-compute
|
|
24
|
+
* keyframe target positions in world space.
|
|
25
|
+
*
|
|
26
|
+
* ### 2. Gesture Generation (`generateGesture`)
|
|
27
|
+
* Produces a RNGH Pan gesture with:
|
|
28
|
+
* - Axis locking (first-movement direction wins)
|
|
29
|
+
* - Bitmask-based directional locks (1=left, 2=right, 4=up, 8=down)
|
|
30
|
+
* - Edge resistance at world boundaries
|
|
31
|
+
* - Velocity-sensitive neighbor snapping with configurable thresholds
|
|
32
|
+
* - Spring-based snap animation with boundary dampening
|
|
33
|
+
*
|
|
34
|
+
* ### Overlaps
|
|
35
|
+
* The `overlaps` parameter reduces the gap between adjacent pages by a
|
|
36
|
+
* fraction of the viewport size. `{ cols: [0.5] }` means the first and
|
|
37
|
+
* second columns share 50% of the screen width, creating a drawer effect.
|
|
38
|
+
* Each element in the array corresponds to the gap between adjacent
|
|
39
|
+
* rows/columns (so `cols` needs `numCols - 1` values).
|
|
40
|
+
*
|
|
41
|
+
* ### Layout Cache
|
|
42
|
+
* Components can unmount and remount without losing their measured position
|
|
43
|
+
* thanks to `registerLayout()`/`getLayout()`. This enables true virtualization.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```tsx
|
|
47
|
+
* // 3 horizontal pages with the middle one as default
|
|
48
|
+
* const config = new AniviewConfig(
|
|
49
|
+
* [[1, 1, 1]],
|
|
50
|
+
* 1,
|
|
51
|
+
* { LEFT: 0, CENTER: 1, RIGHT: 2 },
|
|
52
|
+
* {},
|
|
53
|
+
* { cols: [0, 0] }, // no overlap
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @see {@link AniviewProvider} which consumes this config
|
|
58
|
+
*/
|
|
59
|
+
export class AniviewConfig implements IAniviewConfig {
|
|
60
|
+
/** The grid layout matrix. `1` = valid page, `0` = empty slot. Rows are vertical, columns are horizontal. */
|
|
61
|
+
public readonly layout: number[][];
|
|
62
|
+
/** Current viewport dimensions */
|
|
63
|
+
// public readonly contextDims: AniviewContextType['dimensions']; // Replaced by getter
|
|
64
|
+
/** The numeric page ID that serves as the world origin (camera starts here) */
|
|
65
|
+
public readonly defaultPage: number;
|
|
66
|
+
/** Optional custom adjacency rules for snapping (overrides grid-based neighbors) */
|
|
67
|
+
public readonly adjacencyGraph: AdjacencyMap;
|
|
68
|
+
/** Map of semantic names to numeric page IDs (e.g., `{ HOME: 0 }`) */
|
|
69
|
+
public readonly pageMap: Record<string, number>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Overlap ratios between adjacent rows, expressed as fractions of viewport height (0–1).
|
|
73
|
+
* Length should be `numRows - 1`. A value of 0.3 means 30% vertical overlap.
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
private readonly rowOverlaps: number[];
|
|
77
|
+
/**
|
|
78
|
+
* Overlap ratios between adjacent columns, expressed as fractions of viewport width (0–1).
|
|
79
|
+
* Length should be `numCols - 1`. A value of 0.5 means 50% horizontal overlap.
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
private readonly colOverlaps: number[];
|
|
83
|
+
|
|
84
|
+
/** Standard physics for snapping animations */
|
|
85
|
+
private springConfig = {
|
|
86
|
+
damping: 30,
|
|
87
|
+
stiffness: 150,
|
|
88
|
+
mass: 0.5,
|
|
89
|
+
overshootClamping: true,
|
|
90
|
+
restDisplacementThreshold: 0.01,
|
|
91
|
+
restSpeedThreshold: 2
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Internal dimension state - mutable for onLayout updates */
|
|
95
|
+
private _contextDims: AniviewContextType['dimensions'];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* CACHE: Stores measured positions of components.
|
|
99
|
+
* This is critical for TRUE VIRTUALIZATION (unmounting).
|
|
100
|
+
* Key: pageId_componentIndex (or similar unique key)
|
|
101
|
+
*/
|
|
102
|
+
private layoutCache: Record<string, { x: number; y: number }> = {};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param layout - Grid matrix defining page positions (e.g., `[[1, 1]]` for 2 horizontal pages)
|
|
106
|
+
* @param defaultPage - Initial page (numeric ID or semantic name). Defaults to `0`.
|
|
107
|
+
* @param pageMap - Semantic name → numeric ID mapping
|
|
108
|
+
* @param initialDims - Initial viewport dimensions (usually set later via `onLayout`)
|
|
109
|
+
* @param overlaps - Ratio-based overlaps between adjacent pages: `{ cols?: number[], rows?: number[] }`
|
|
110
|
+
* @param providedGraph - Custom adjacency graph for non-grid snapping behavior
|
|
111
|
+
*/
|
|
112
|
+
constructor(
|
|
113
|
+
layout: number[][],
|
|
114
|
+
defaultPage: number | string | null = null,
|
|
115
|
+
pageMap: Record<string, number> = {},
|
|
116
|
+
initialDims: Partial<AniviewContextType['dimensions']> = {},
|
|
117
|
+
overlaps: { cols?: number[]; rows?: number[] } = {},
|
|
118
|
+
providedGraph: AdjacencyMap | null = null
|
|
119
|
+
) {
|
|
120
|
+
this.pageMap = pageMap || {};
|
|
121
|
+
this.layout = layout || [[1]];
|
|
122
|
+
this._contextDims = {
|
|
123
|
+
width: initialDims.width || 0,
|
|
124
|
+
height: initialDims.height || 0,
|
|
125
|
+
offsetX: initialDims.offsetX || 0,
|
|
126
|
+
offsetY: initialDims.offsetY || 0
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Resolve semantic default page
|
|
130
|
+
this.defaultPage = this.resolvePageId(defaultPage ?? 0);
|
|
131
|
+
this.adjacencyGraph = providedGraph || {};
|
|
132
|
+
|
|
133
|
+
const numRows = this.layout.length;
|
|
134
|
+
const numCols = this.layout[0]?.length || 0;
|
|
135
|
+
|
|
136
|
+
this.rowOverlaps = new Array(Math.max(0, numRows - 1)).fill(0);
|
|
137
|
+
if (overlaps.rows) overlaps.rows.forEach((v, i) => { if (i < this.rowOverlaps.length) this.rowOverlaps[i] = v; });
|
|
138
|
+
|
|
139
|
+
this.colOverlaps = new Array(Math.max(0, numCols - 1)).fill(0);
|
|
140
|
+
if (overlaps.cols) overlaps.cols.forEach((v, i) => { if (i < this.colOverlaps.length) this.colOverlaps[i] = v; });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public registerLayout(componentId: string, layout: { x: number; y: number }) {
|
|
144
|
+
this.layoutCache[componentId] = layout;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public getLayout(componentId: string) {
|
|
148
|
+
return this.layoutCache[componentId];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get contextDims() {
|
|
152
|
+
return this._contextDims;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
public updateDimensions(dims: AniviewContextType['dimensions']) {
|
|
157
|
+
this._contextDims = dims;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public updateSpringConfig(config: any) {
|
|
161
|
+
this.springConfig = { ...this.springConfig, ...config };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns the shared physics configuration for snap animations.
|
|
166
|
+
*/
|
|
167
|
+
public getSpringConfig() {
|
|
168
|
+
return this.springConfig;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Gets the global (x, y) offset for a specific page relative to origin. */
|
|
172
|
+
public getPageOffset(pageId: number | string, dims: AniviewContextType['dimensions']) {
|
|
173
|
+
const resolvedId = this.resolvePageId(pageId);
|
|
174
|
+
return AniviewMath.getPageOffset(
|
|
175
|
+
resolvedId,
|
|
176
|
+
this.layout,
|
|
177
|
+
dims,
|
|
178
|
+
this.defaultPage,
|
|
179
|
+
this.rowOverlaps,
|
|
180
|
+
this.colOverlaps
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public resolvePageId(pageId: number | string): number {
|
|
185
|
+
if (typeof pageId === 'number') return pageId;
|
|
186
|
+
if (this.pageMap[pageId] !== undefined) return this.pageMap[pageId];
|
|
187
|
+
|
|
188
|
+
// Fallback: If it's a string but NOT in the map, try to parse as number
|
|
189
|
+
const parsed = parseInt(pageId, 10);
|
|
190
|
+
return isNaN(parsed) ? 0 : parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// To be injected by generateGesture/Provider
|
|
194
|
+
private _currentPageSV: SharedValue<number | string> = makeMutable<number | string>(0);
|
|
195
|
+
|
|
196
|
+
public getCurrentPage(): SharedValue<number | string> {
|
|
197
|
+
return this._currentPageSV;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public _setCurrentPageSV(sv: SharedValue<number | string>) {
|
|
201
|
+
this._currentPageSV = sv;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pre-calculates absolute coordinates for a component's keyframes.
|
|
206
|
+
* Segregates Spatial (X/Y) frames from Event-based (1D) frames.
|
|
207
|
+
*/
|
|
208
|
+
public register(
|
|
209
|
+
pageId: number | string,
|
|
210
|
+
dims: AniviewContextType['dimensions'],
|
|
211
|
+
keyframes?: AniviewFrame[] | Record<string, AniviewFrame>,
|
|
212
|
+
localLayout?: { x: number; y: number }
|
|
213
|
+
) {
|
|
214
|
+
const resolvedHomeId = this.resolvePageId(pageId);
|
|
215
|
+
const homeOffset = this.getPageOffset(resolvedHomeId, dims);
|
|
216
|
+
const localX = localLayout?.x || 0;
|
|
217
|
+
const localY = localLayout?.y || 0;
|
|
218
|
+
|
|
219
|
+
const bakedFrames: Record<string, BakedFrame> = {};
|
|
220
|
+
const eventLanes: Record<string, BakedFrame[]> = {};
|
|
221
|
+
|
|
222
|
+
if (keyframes) {
|
|
223
|
+
const entries = Array.isArray(keyframes)
|
|
224
|
+
? keyframes.map((f, i) => ({ key: `f_${i}`, frame: f }))
|
|
225
|
+
: Object.entries(keyframes).map(([k, f]) => ({ key: k, frame: f }));
|
|
226
|
+
|
|
227
|
+
entries.forEach(({ key, frame }) => {
|
|
228
|
+
// Spatial Frame Logic (uses 'page')
|
|
229
|
+
if (frame.page !== undefined || frame.event === undefined) {
|
|
230
|
+
const targetPageId = frame.page !== undefined ? this.resolvePageId(frame.page) : resolvedHomeId;
|
|
231
|
+
const targetOffset = this.getPageOffset(targetPageId, dims);
|
|
232
|
+
bakedFrames[key] = {
|
|
233
|
+
...frame,
|
|
234
|
+
worldX: targetOffset.x - homeOffset.x,
|
|
235
|
+
worldY: targetOffset.y - homeOffset.y,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Event Frame Logic (uses 'event' and 'value')
|
|
240
|
+
if (frame.event) {
|
|
241
|
+
if (!eventLanes[frame.event]) eventLanes[frame.event] = [];
|
|
242
|
+
eventLanes[frame.event].push({
|
|
243
|
+
...frame,
|
|
244
|
+
worldX: 0,
|
|
245
|
+
worldY: 0,
|
|
246
|
+
value: frame.value ?? 0
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Sort event frames by value for 1D interpolation
|
|
253
|
+
Object.keys(eventLanes).forEach(k => {
|
|
254
|
+
eventLanes[k].sort((a, b) => (a.value || 0) - (b.value || 0));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
homeOffset,
|
|
259
|
+
bakedFrames,
|
|
260
|
+
eventLanes,
|
|
261
|
+
localLayout: { x: localX, y: localY }
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
/** Minimal offset context for direct page references */
|
|
267
|
+
public registerPage(pageId: number | string, dims: AniviewContextType['dimensions']) {
|
|
268
|
+
return {
|
|
269
|
+
offset: this.getPageOffset(pageId, dims),
|
|
270
|
+
dimensions: {
|
|
271
|
+
width: dims.width,
|
|
272
|
+
height: dims.height
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Returns all valid page IDs defined in the layout matrix */
|
|
278
|
+
public getPages(): number[] {
|
|
279
|
+
const pages: number[] = [];
|
|
280
|
+
const rows = this.layout.length;
|
|
281
|
+
if (rows === 0) return [0];
|
|
282
|
+
const cols = this.layout[0].length;
|
|
283
|
+
for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
|
|
284
|
+
for (let colIndex = 0; colIndex < cols; colIndex++) {
|
|
285
|
+
if (this.layout[rowIndex][colIndex] === 1) {
|
|
286
|
+
pages.push(rowIndex * cols + colIndex);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return pages;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Map of PageID -> (x, y) coordinates */
|
|
294
|
+
public getPagesMap(dims: AniviewContextType['dimensions']): Record<number, { x: number; y: number }> {
|
|
295
|
+
const map: Record<number, { x: number; y: number }> = {};
|
|
296
|
+
const pages = this.getPages();
|
|
297
|
+
for (let index = 0; index < pages.length; index++) {
|
|
298
|
+
const pageId = pages[index];
|
|
299
|
+
map[pageId] = this.getPageOffset(pageId, dims);
|
|
300
|
+
}
|
|
301
|
+
return map;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Returns calculated min/max world boundaries for gesture clamping */
|
|
305
|
+
public getWorldBounds(dims: AniviewContextType['dimensions']) {
|
|
306
|
+
return AniviewMath.getWorldBounds(
|
|
307
|
+
this.getPages(),
|
|
308
|
+
this.layout,
|
|
309
|
+
dims,
|
|
310
|
+
this.defaultPage,
|
|
311
|
+
this.rowOverlaps,
|
|
312
|
+
this.colOverlaps
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Generates the core Pan Gesture logic.
|
|
318
|
+
* Uses local closures to ensure UI-thread safety and prevent context loss.
|
|
319
|
+
*/
|
|
320
|
+
public generateGesture(
|
|
321
|
+
x: SharedValue<number>,
|
|
322
|
+
y: SharedValue<number>,
|
|
323
|
+
onPageChange?: (pageId: number | string) => void,
|
|
324
|
+
lockMask?: SharedValue<number>,
|
|
325
|
+
simultaneousHandlers?: any,
|
|
326
|
+
gestureEnabled?: SharedValue<boolean>,
|
|
327
|
+
dims?: AniviewContextType['dimensions'],
|
|
328
|
+
isSnapping?: SharedValue<boolean>,
|
|
329
|
+
lastTargetId?: SharedValue<number | string>
|
|
330
|
+
) {
|
|
331
|
+
// Persistent constants Captured for the UI thread
|
|
332
|
+
const layout = this.layout;
|
|
333
|
+
const contextDims = dims || this._contextDims;
|
|
334
|
+
const defaultPage = this.defaultPage;
|
|
335
|
+
const rowOverlaps = this.rowOverlaps;
|
|
336
|
+
const colOverlaps = this.colOverlaps;
|
|
337
|
+
const pages = this.getPages();
|
|
338
|
+
const bounds = AniviewMath.getWorldBounds(pages, layout, contextDims, defaultPage, rowOverlaps, colOverlaps);
|
|
339
|
+
const pageMap = this.pageMap;
|
|
340
|
+
|
|
341
|
+
/** Edge resistance strength (0.0 = hard wall, 1.0 = no resistance) */
|
|
342
|
+
const RESISTANCE = 0.08;
|
|
343
|
+
const SPRING_CONFIG = this.springConfig;
|
|
344
|
+
const isSingleRow = layout.length <= 1;
|
|
345
|
+
|
|
346
|
+
const screenWidth = contextDims.width;
|
|
347
|
+
const screenHeight = contextDims.height;
|
|
348
|
+
const rowLength = Math.max(1, layout[0]?.length || 0);
|
|
349
|
+
|
|
350
|
+
// Helper to resolve ID on UI thread
|
|
351
|
+
const resolveId = (pid: number | string) => {
|
|
352
|
+
'worklet';
|
|
353
|
+
if (typeof pid === 'number') return pid;
|
|
354
|
+
if (pageMap && pageMap[pid] !== undefined) return pageMap[pid];
|
|
355
|
+
return 0; // Fallback
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Pre-calculate snap points to keep onUpdate/onEnd fast
|
|
359
|
+
const snapPointsProcessed = pages.map(pageId => {
|
|
360
|
+
const offset = AniviewMath.getPageOffset(pageId, layout, contextDims, defaultPage, rowOverlaps, colOverlaps);
|
|
361
|
+
return {
|
|
362
|
+
id: pageId,
|
|
363
|
+
x: offset.x,
|
|
364
|
+
y: offset.y,
|
|
365
|
+
row: Math.floor(pageId / rowLength),
|
|
366
|
+
col: pageId % rowLength
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
const triggerPageChange = (pageId: number) => {
|
|
372
|
+
'worklet';
|
|
373
|
+
if (onPageChange) {
|
|
374
|
+
runOnJS(onPageChange)(pageId);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const startX = makeMutable(0);
|
|
379
|
+
const startY = makeMutable(0);
|
|
380
|
+
const internalIsSnapping = makeMutable(false);
|
|
381
|
+
const isSnappingVal = isSnapping || internalIsSnapping;
|
|
382
|
+
const activeAxis = makeMutable(0); // 0: none, 1: horizontal, 2: vertical
|
|
383
|
+
const wasDisabled = makeMutable(false);
|
|
384
|
+
|
|
385
|
+
let pan = Gesture.Pan();
|
|
386
|
+
if (simultaneousHandlers) {
|
|
387
|
+
pan = pan.simultaneousWithExternalGesture(simultaneousHandlers);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return pan
|
|
391
|
+
.minDistance(10)
|
|
392
|
+
.onBegin(() => {
|
|
393
|
+
'worklet';
|
|
394
|
+
isSnappingVal.value = false;
|
|
395
|
+
activeAxis.value = 0;
|
|
396
|
+
wasDisabled.value = false;
|
|
397
|
+
|
|
398
|
+
if (isNaN(x.value)) x.value = 0;
|
|
399
|
+
if (isNaN(y.value)) y.value = 0;
|
|
400
|
+
|
|
401
|
+
startX.value = x.value;
|
|
402
|
+
startY.value = y.value;
|
|
403
|
+
})
|
|
404
|
+
.onUpdate((gestureEvent) => {
|
|
405
|
+
'worklet';
|
|
406
|
+
// Continuous sync to prevent 'jumps' when taking over from children
|
|
407
|
+
const currentResyncX = x.value + gestureEvent.translationX;
|
|
408
|
+
const currentResyncY = y.value + gestureEvent.translationY;
|
|
409
|
+
|
|
410
|
+
if (gestureEnabled && gestureEnabled.value === false) {
|
|
411
|
+
startX.value = currentResyncX;
|
|
412
|
+
startY.value = currentResyncY;
|
|
413
|
+
wasDisabled.value = true;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Catch transition from disabled to enabled
|
|
418
|
+
if (wasDisabled.value) {
|
|
419
|
+
startX.value = currentResyncX;
|
|
420
|
+
startY.value = currentResyncY;
|
|
421
|
+
wasDisabled.value = false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
isSnappingVal.value = true;
|
|
425
|
+
|
|
426
|
+
const dx = Math.abs(gestureEvent.translationX);
|
|
427
|
+
const dy = Math.abs(gestureEvent.translationY);
|
|
428
|
+
const TRANSLATION_THRESHOLD = 3;
|
|
429
|
+
|
|
430
|
+
const isHBlocked = lockMask && (lockMask.value & 1);
|
|
431
|
+
const isVBlocked = lockMask && (lockMask.value & 2);
|
|
432
|
+
|
|
433
|
+
// Lock to an axis after a small movement
|
|
434
|
+
if (activeAxis.value === 0 && (dx > TRANSLATION_THRESHOLD || dy > TRANSLATION_THRESHOLD)) {
|
|
435
|
+
activeAxis.value = dx > dy ? 1 : 2;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let newX = startX.value;
|
|
439
|
+
let newY = startY.value;
|
|
440
|
+
|
|
441
|
+
// Apply movement only to the active axis AND if not blocked
|
|
442
|
+
if (activeAxis.value === 1 || activeAxis.value === 0) {
|
|
443
|
+
if (isHBlocked) {
|
|
444
|
+
startX.value = x.value + gestureEvent.translationX;
|
|
445
|
+
} else {
|
|
446
|
+
newX = startX.value - gestureEvent.translationX;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!isSingleRow && (activeAxis.value === 2 || activeAxis.value === 0)) {
|
|
451
|
+
if (isVBlocked) {
|
|
452
|
+
startY.value = y.value + gestureEvent.translationY;
|
|
453
|
+
} else {
|
|
454
|
+
newY = startY.value - gestureEvent.translationY;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ALIGNMENT ENFORCEMENT for Vertical Swiping
|
|
459
|
+
if (activeAxis.value === 2) {
|
|
460
|
+
newX = startX.value; // Force horizontal to be centered
|
|
461
|
+
}
|
|
462
|
+
// ALIGNMENT ENFORCEMENT for Horizontal Swiping
|
|
463
|
+
if (activeAxis.value === 1) {
|
|
464
|
+
newY = startY.value; // Force vertical to be centered
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
// NaN Protection
|
|
469
|
+
if (isNaN(newX)) newX = startX.value;
|
|
470
|
+
if (isNaN(newY)) newY = startY.value;
|
|
471
|
+
|
|
472
|
+
// CLAMP: Prevent skipping multiple pages on fast swipes
|
|
473
|
+
// Limit movement to 1.2 pages max in either direction from start position
|
|
474
|
+
const maxSwipeDistance = screenWidth * 1.2;
|
|
475
|
+
const deltaX = newX - startX.value;
|
|
476
|
+
if (Math.abs(deltaX) > maxSwipeDistance) {
|
|
477
|
+
newX = startX.value + (deltaX > 0 ? maxSwipeDistance : -maxSwipeDistance);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Overscroll limits before resistance triggers
|
|
481
|
+
const localLimitX = screenWidth * 1.5;
|
|
482
|
+
const localLimitY = screenHeight * 1.5;
|
|
483
|
+
const lowX = startX.value - localLimitX;
|
|
484
|
+
const highX = startX.value + localLimitX;
|
|
485
|
+
|
|
486
|
+
// World Bounds Resistance
|
|
487
|
+
if (newX < bounds.minX) newX = bounds.minX + (newX - bounds.minX) * RESISTANCE;
|
|
488
|
+
else if (newX > bounds.maxX) newX = bounds.maxX + (newX - bounds.maxX) * RESISTANCE;
|
|
489
|
+
|
|
490
|
+
// Local Velocity Protection
|
|
491
|
+
if (newX < lowX) newX = lowX + (newX - lowX) * RESISTANCE;
|
|
492
|
+
else if (newX > highX) newX = highX + (newX - highX) * RESISTANCE;
|
|
493
|
+
|
|
494
|
+
if (!isSingleRow) {
|
|
495
|
+
const lowY = startY.value - localLimitY;
|
|
496
|
+
const highY = startY.value + localLimitY;
|
|
497
|
+
|
|
498
|
+
if (newY < bounds.minY) newY = bounds.minY + (newY - bounds.minY) * RESISTANCE;
|
|
499
|
+
else if (newY > bounds.maxY) newY = bounds.maxY + (newY - bounds.maxY) * RESISTANCE;
|
|
500
|
+
|
|
501
|
+
if (newY < lowY) newY = lowY + (newY - lowY) * RESISTANCE;
|
|
502
|
+
else if (newY > highY) newY = highY + (newY - highY) * RESISTANCE;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
x.value = newX;
|
|
506
|
+
y.value = newY;
|
|
507
|
+
})
|
|
508
|
+
.onEnd((gestureEvent) => {
|
|
509
|
+
'worklet';
|
|
510
|
+
if (gestureEnabled && gestureEnabled.value === false) return;
|
|
511
|
+
if (snapPointsProcessed.length === 0) return;
|
|
512
|
+
|
|
513
|
+
const VELOCITY_THRESHOLD = 200; // Lowered from 500 for high sensitivity
|
|
514
|
+
const distanceThresholdX = screenWidth * 0.08; // Lowered from 0.15 (8% of screen)
|
|
515
|
+
const distanceThresholdY = screenHeight * 0.08; // Lowered from 0.15 (8% of screen)
|
|
516
|
+
|
|
517
|
+
const stageStartX = startX.value;
|
|
518
|
+
const stageStartY = startY.value;
|
|
519
|
+
|
|
520
|
+
// Identify "Anchor Page" for directional snapping (closest point when gesture began)
|
|
521
|
+
let anchorRow = 0, anchorCol = 0, anchorId = -1;
|
|
522
|
+
let minStartDist = Infinity;
|
|
523
|
+
for (let index = 0; index < snapPointsProcessed.length; index++) {
|
|
524
|
+
const snapPoint = snapPointsProcessed[index];
|
|
525
|
+
const dist = Math.sqrt(Math.pow(stageStartX - snapPoint.x, 2) + Math.pow(stageStartY - snapPoint.y, 2));
|
|
526
|
+
if (dist < minStartDist) {
|
|
527
|
+
minStartDist = dist;
|
|
528
|
+
anchorRow = snapPoint.row;
|
|
529
|
+
anchorCol = snapPoint.col;
|
|
530
|
+
anchorId = snapPoint.id;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// STRICT: Only treat as "founded start" if within 30% of screen width from page center
|
|
534
|
+
// This prevents edge swipes from being misattributed to the neighboring page
|
|
535
|
+
const startFound = minStartDist < screenWidth * 0.3;
|
|
536
|
+
|
|
537
|
+
let targetX = -1;
|
|
538
|
+
let targetY = -1;
|
|
539
|
+
let targetId = -1;
|
|
540
|
+
|
|
541
|
+
const intentRight = gestureEvent.velocityX < -VELOCITY_THRESHOLD || gestureEvent.translationX < -distanceThresholdX;
|
|
542
|
+
const intentLeft = gestureEvent.velocityX > VELOCITY_THRESHOLD || gestureEvent.translationX > distanceThresholdX;
|
|
543
|
+
const intentDown = gestureEvent.velocityY < -VELOCITY_THRESHOLD || gestureEvent.translationY < -distanceThresholdY;
|
|
544
|
+
const intentUp = gestureEvent.velocityY > VELOCITY_THRESHOLD || gestureEvent.translationY > distanceThresholdY;
|
|
545
|
+
|
|
546
|
+
// Neighbor Snapping
|
|
547
|
+
if (startFound && (intentRight || intentLeft || (!isSingleRow && (intentDown || intentUp)))) {
|
|
548
|
+
let targetRow = anchorRow;
|
|
549
|
+
let targetCol = anchorCol;
|
|
550
|
+
|
|
551
|
+
// Only allow snapping on the current active axis
|
|
552
|
+
if (activeAxis.value === 1) {
|
|
553
|
+
if (intentRight) targetCol = anchorCol + 1;
|
|
554
|
+
else if (intentLeft) targetCol = anchorCol - 1;
|
|
555
|
+
} else if (activeAxis.value === 2 && !isSingleRow) {
|
|
556
|
+
if (intentDown) targetRow = anchorRow + 1;
|
|
557
|
+
else if (intentUp) targetRow = anchorRow - 1;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
for (let index = 0; index < snapPointsProcessed.length; index++) {
|
|
561
|
+
const snapPoint = snapPointsProcessed[index];
|
|
562
|
+
if (snapPoint.row === targetRow && snapPoint.col === targetCol) {
|
|
563
|
+
targetX = snapPoint.x;
|
|
564
|
+
targetY = snapPoint.y;
|
|
565
|
+
targetId = snapPoint.id;
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Distance-based Fallback (constrained by active axis)
|
|
572
|
+
if (targetX === -1) {
|
|
573
|
+
let minDistance = Infinity;
|
|
574
|
+
for (let index = 0; index < snapPointsProcessed.length; index++) {
|
|
575
|
+
const snapPoint = snapPointsProcessed[index];
|
|
576
|
+
|
|
577
|
+
// STAY ON AXIS: If we are swiping vertically, only consider pages in this column.
|
|
578
|
+
// If swiping horizontally, only consider pages in this row.
|
|
579
|
+
if (activeAxis.value === 1 && snapPoint.row !== anchorRow) continue;
|
|
580
|
+
if (activeAxis.value === 2 && snapPoint.col !== anchorCol) continue;
|
|
581
|
+
|
|
582
|
+
const dist = Math.sqrt(Math.pow(x.value - snapPoint.x, 2) + Math.pow(y.value - snapPoint.y, 2));
|
|
583
|
+
if (dist < minDistance) {
|
|
584
|
+
minDistance = dist;
|
|
585
|
+
targetX = snapPoint.x;
|
|
586
|
+
targetY = snapPoint.y;
|
|
587
|
+
targetId = snapPoint.id;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Final safety: if for some reason we still have no target, stay at start
|
|
593
|
+
if (targetId === -1) {
|
|
594
|
+
targetId = anchorId;
|
|
595
|
+
const snapPoint = snapPointsProcessed.find(p => p.id === anchorId);
|
|
596
|
+
if (snapPoint) {
|
|
597
|
+
targetX = snapPoint.x;
|
|
598
|
+
targetY = snapPoint.y;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (targetId !== -1 && targetId !== anchorId) {
|
|
603
|
+
triggerPageChange(targetId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --- EDGE SNAP HARDENING ---
|
|
607
|
+
// If we are snapping to a boundary, dampen the velocity to prevent "flick bounce".
|
|
608
|
+
const isBoundaryX = targetX <= bounds.minX || targetX >= bounds.maxX;
|
|
609
|
+
const isBoundaryY = targetY <= bounds.minY || targetY >= bounds.maxY;
|
|
610
|
+
const velocityDamping = 0.5;
|
|
611
|
+
|
|
612
|
+
x.value = withSpring(targetX, {
|
|
613
|
+
...SPRING_CONFIG,
|
|
614
|
+
velocity: isBoundaryX ? -gestureEvent.velocityX * velocityDamping : -gestureEvent.velocityX
|
|
615
|
+
}, (finished) => {
|
|
616
|
+
if (finished) isSnappingVal.value = false;
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
if (!isSingleRow) {
|
|
620
|
+
y.value = withSpring(targetY, {
|
|
621
|
+
...SPRING_CONFIG,
|
|
622
|
+
velocity: isBoundaryY ? -gestureEvent.velocityY * velocityDamping : -gestureEvent.velocityY
|
|
623
|
+
});
|
|
624
|
+
} else {
|
|
625
|
+
y.value = withSpring(targetY, SPRING_CONFIG);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
isSnappingVal.value = true;
|
|
629
|
+
})
|
|
630
|
+
.onFinalize((event, success) => {
|
|
631
|
+
'worklet';
|
|
632
|
+
// If gesture was cancelled/failed (button press) OR didn't move enough to lock axis (tap), do nothing.
|
|
633
|
+
if (!success || activeAxis.value === 0) return;
|
|
634
|
+
|
|
635
|
+
if (gestureEnabled && gestureEnabled.value === false) return;
|
|
636
|
+
if (isSnappingVal.value) return;
|
|
637
|
+
|
|
638
|
+
let minDistance = Infinity;
|
|
639
|
+
let targetX = x.value;
|
|
640
|
+
let targetY = y.value;
|
|
641
|
+
let targetId = -1;
|
|
642
|
+
const snapPointsCount = snapPointsProcessed.length;
|
|
643
|
+
|
|
644
|
+
for (let index = 0; index < snapPointsCount; index++) {
|
|
645
|
+
const snapPoint = snapPointsProcessed[index];
|
|
646
|
+
const dist = Math.sqrt(Math.pow(x.value - snapPoint.x, 2) + Math.pow(y.value - snapPoint.y, 2));
|
|
647
|
+
if (dist < minDistance) {
|
|
648
|
+
minDistance = dist;
|
|
649
|
+
targetX = snapPoint.x;
|
|
650
|
+
targetY = snapPoint.y;
|
|
651
|
+
targetId = snapPoint.id;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// --- CONFLICT RESOLUTION ---
|
|
656
|
+
// If the gesture resolution (Snap to targetId) conflicts with a pending programmatic move (lastTargetId),
|
|
657
|
+
// and the gesture was essentially stationary (snapped back to anchor), we yield to the external command.
|
|
658
|
+
if (lastTargetId) {
|
|
659
|
+
const currentProgrammaticId = resolveId(lastTargetId.value);
|
|
660
|
+
const stageStartX = startX.value;
|
|
661
|
+
const stageStartY = startY.value;
|
|
662
|
+
|
|
663
|
+
// Re-calculate start anchor for comparison
|
|
664
|
+
let anchorId = -1;
|
|
665
|
+
let minStartDist = Infinity;
|
|
666
|
+
for (let index = 0; index < snapPointsCount; index++) {
|
|
667
|
+
const snapPoint = snapPointsProcessed[index];
|
|
668
|
+
const dist = Math.sqrt(Math.pow(stageStartX - snapPoint.x, 2) + Math.pow(stageStartY - snapPoint.y, 2));
|
|
669
|
+
if (dist < minStartDist) {
|
|
670
|
+
minStartDist = dist;
|
|
671
|
+
anchorId = snapPoint.id;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// If gesture is staying put (target == anchor) BUT the program wants to be elsewhere -> Yield
|
|
676
|
+
if (targetId === anchorId && anchorId !== currentProgrammaticId) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Always force snap to resolve any mid-swipe "catch"
|
|
682
|
+
x.value = withSpring(targetX, SPRING_CONFIG, (finished) => {
|
|
683
|
+
if (finished) isSnappingVal.value = false;
|
|
684
|
+
});
|
|
685
|
+
y.value = withSpring(targetY, SPRING_CONFIG);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|