@astryxdesign/core 0.7.7-canary.36f8ea1 → 0.7.7-canary.550ff21
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/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +8 -1
- package/dist/DateInput/MonthScroller.d.ts.map +1 -1
- package/dist/DateInput/MonthScroller.js +72 -2
- package/dist/Field/PanelSearchInput.d.ts.map +1 -1
- package/dist/Field/PanelSearchInput.js +8 -1
- package/dist/Field/textEntryTraits.d.ts +6 -6
- package/dist/Field/textEntryTraits.js +6 -6
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +20 -9
- package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
- package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBarTouch.js +91 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +9 -1
- package/dist/astryx.css +7 -0
- package/dist/gestures/GestureArena.d.ts +21 -2
- package/dist/gestures/GestureArena.d.ts.map +1 -1
- package/dist/gestures/GestureArena.js +19 -2
- package/dist/gestures/GestureHost.d.ts +51 -1
- package/dist/gestures/GestureHost.d.ts.map +1 -1
- package/dist/gestures/GestureHost.js +148 -7
- package/dist/motion/animator.d.ts.map +1 -1
- package/dist/motion/animator.js +63 -5
- package/dist/motion/paging.d.ts +58 -0
- package/dist/motion/paging.d.ts.map +1 -0
- package/dist/motion/paging.js +128 -0
- package/dist/motion/uikit/index.d.ts +4 -3
- package/dist/motion/uikit/index.d.ts.map +1 -1
- package/dist/motion/uikit/index.js +4 -3
- package/dist/motion/uikit/paging.d.ts +160 -0
- package/dist/motion/uikit/paging.d.ts.map +1 -0
- package/dist/motion/uikit/paging.js +162 -0
- package/dist/motion/uikit/progressBar.d.ts +59 -0
- package/dist/motion/uikit/progressBar.d.ts.map +1 -0
- package/dist/motion/uikit/progressBar.js +82 -0
- package/dist/motion/uikit/scroll.d.ts +0 -19
- package/dist/motion/uikit/scroll.d.ts.map +1 -1
- package/dist/motion/uikit/scroll.js +2 -21
- package/dist/utils/selectionLock.d.ts +43 -0
- package/dist/utils/selectionLock.d.ts.map +1 -1
- package/dist/utils/selectionLock.js +394 -6
- package/package.json +4 -4
- package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
- package/src/DateInput/MonthScroller.tsx +63 -5
- package/src/Field/PanelSearchInput.tsx +6 -0
- package/src/Field/textEntryTraits.ts +6 -6
- package/src/ProgressBar/ProgressBar.doc.mjs +3 -3
- package/src/ProgressBar/ProgressBar.tsx +82 -11
- package/src/ProgressBar/ProgressBarTouch.ts +109 -0
- package/src/Typeahead/BaseTypeahead.tsx +7 -0
- package/src/gestures/GestureArena.ts +44 -4
- package/src/gestures/GestureHost.ts +184 -7
- package/src/gestures/README.md +19 -3
- package/src/motion/MEASUREMENTS.md +8 -1
- package/src/motion/README.md +6 -3
- package/src/motion/animator.ts +78 -6
- package/src/motion/paging.ts +138 -0
- package/src/motion/uikit/index.ts +4 -3
- package/src/motion/uikit/paging.ts +162 -0
- package/src/motion/uikit/progressBar.ts +82 -0
- package/src/motion/uikit/scroll.ts +2 -18
- package/src/utils/selectionLock.ts +501 -6
- package/dist/motion/selectPage.d.ts +0 -26
- package/dist/motion/selectPage.d.ts.map +0 -1
- package/dist/motion/selectPage.js +0 -55
- package/src/motion/selectPage.ts +0 -55
|
@@ -177,7 +177,13 @@ import {
|
|
|
177
177
|
} from '../utils/clickGuard';
|
|
178
178
|
import {subscribeInput} from '../utils/inputStream';
|
|
179
179
|
import {isPrimaryClick} from '../utils/primaryClick';
|
|
180
|
-
import {
|
|
180
|
+
import {
|
|
181
|
+
type DragSelectionReleaseOptions,
|
|
182
|
+
lockDragSelection,
|
|
183
|
+
locksSelectionFor,
|
|
184
|
+
selectionRangesOf,
|
|
185
|
+
startsOnSelectableText,
|
|
186
|
+
} from '../utils/selectionLock';
|
|
181
187
|
import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer';
|
|
182
188
|
import {gestureHosts} from './hostRegistry';
|
|
183
189
|
|
|
@@ -635,7 +641,35 @@ export class GestureHost {
|
|
|
635
641
|
/** Cancels the scheduled next-frame read (readCallerTouchActionInNextFrame). */
|
|
636
642
|
private cancelCallerRead: (() => void) | null = null;
|
|
637
643
|
/** The selection lock each contact holds, by host contact id. */
|
|
638
|
-
private readonly lockedBy = new Map<
|
|
644
|
+
private readonly lockedBy = new Map<
|
|
645
|
+
number,
|
|
646
|
+
(options?: DragSelectionReleaseOptions) => void
|
|
647
|
+
>();
|
|
648
|
+
/**
|
|
649
|
+
* The page's selection when each mouse or pen press began: a drag a
|
|
650
|
+
* recognizer claims keeps it.
|
|
651
|
+
*/
|
|
652
|
+
private readonly pressSelections = new Map<
|
|
653
|
+
number,
|
|
654
|
+
ReadonlyArray<AbstractRange>
|
|
655
|
+
>();
|
|
656
|
+
/**
|
|
657
|
+
* Mouse and pen contacts that began on selectable text. Once one moves, the
|
|
658
|
+
* browser keeps the drag for a selection, and no recognizer claims it.
|
|
659
|
+
*/
|
|
660
|
+
private readonly selectingContacts = new Set<number>();
|
|
661
|
+
/**
|
|
662
|
+
* Drag selection holds until their last task ends, a task after their lift
|
|
663
|
+
* (lockDragSelection); the last detach ends them at once.
|
|
664
|
+
*/
|
|
665
|
+
private readonly dragHolds = new Set<
|
|
666
|
+
(options?: DragSelectionReleaseOptions) => void
|
|
667
|
+
>();
|
|
668
|
+
/** Each claimed contact's drag hold, until its lift or a give-back. */
|
|
669
|
+
private readonly claimHolds = new Map<
|
|
670
|
+
number,
|
|
671
|
+
(options?: DragSelectionReleaseOptions) => void
|
|
672
|
+
>();
|
|
639
673
|
private readonly presentedHitTesting: 'auto' | 'always' | 'never';
|
|
640
674
|
/**
|
|
641
675
|
* Contacts sent to what is drawn instead of what the engine hit, by host
|
|
@@ -816,6 +850,11 @@ export class GestureHost {
|
|
|
816
850
|
// tap) ends here: an armed press stops listening (FR24).
|
|
817
851
|
onIdle: () => this.releaseArmedPress(),
|
|
818
852
|
onTransition: options.onTransition,
|
|
853
|
+
onClaim: (ids, claimer) => this.lockClaimedDrags(ids, claimer),
|
|
854
|
+
onRelease: ids => this.releaseClaimedDrags(ids),
|
|
855
|
+
refusesClaim: touch =>
|
|
856
|
+
this.selectingContacts.has(touch.id) &&
|
|
857
|
+
(touch.x !== touch.startX || touch.y !== touch.startY),
|
|
819
858
|
});
|
|
820
859
|
const view = doc.defaultView;
|
|
821
860
|
// A test may pass its own read; otherwise the one coarse-pointer gate,
|
|
@@ -1779,6 +1818,13 @@ export class GestureHost {
|
|
|
1779
1818
|
}
|
|
1780
1819
|
this.ownScrollFrames.clear();
|
|
1781
1820
|
this.ownedScrolls.clear();
|
|
1821
|
+
// A drag's hold whose press lifted in this task ends now, not a task
|
|
1822
|
+
// later: nothing is attached to meet its mouseup or click.
|
|
1823
|
+
for (const release of [...this.dragHolds]) {
|
|
1824
|
+
release({isCancel: true});
|
|
1825
|
+
}
|
|
1826
|
+
this.pressSelections.clear();
|
|
1827
|
+
this.selectingContacts.clear();
|
|
1782
1828
|
});
|
|
1783
1829
|
}
|
|
1784
1830
|
|
|
@@ -2506,6 +2552,94 @@ export class GestureHost {
|
|
|
2506
2552
|
}
|
|
2507
2553
|
}
|
|
2508
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* Hold the page's selection for a mouse or pen contact that a recognizer
|
|
2557
|
+
* claims. Examples are a pan's drag and a press a group claims at its own
|
|
2558
|
+
* pointerdown. While the contact is down, the claiming recognizers'
|
|
2559
|
+
* elements refuse a new selection. The selection the page had at the press
|
|
2560
|
+
* is put back over anything that replaced it. That includes the press's own
|
|
2561
|
+
* collapse and a selection the drag made before its claim. The claiming
|
|
2562
|
+
* recognizer's element holds from its claim, in any state: a waiting
|
|
2563
|
+
* recognizer claims while possible, and a swipe claims as ended. The
|
|
2564
|
+
* participants that already recognize hold too. A claim given back to the
|
|
2565
|
+
* browser lets its hold go (releaseClaimedDrags). A drag that starts on
|
|
2566
|
+
* selectable text never gets here, because no recognizer claims it
|
|
2567
|
+
* (`selectingContacts`). A tap claims only at its lift, so a click keeps
|
|
2568
|
+
* the selection it made. A finger keeps the touch-down lock alone.
|
|
2569
|
+
*/
|
|
2570
|
+
private lockClaimedDrags(
|
|
2571
|
+
ids: ReadonlyArray<number>,
|
|
2572
|
+
claimer: GestureRecognizer,
|
|
2573
|
+
): void {
|
|
2574
|
+
for (const id of ids) {
|
|
2575
|
+
if (this.claimHolds.has(id)) {
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const touch = this.arena.touches.find(each => each.id === id);
|
|
2579
|
+
// A claim made at the contact's own lift (a tap's) holds nothing: the
|
|
2580
|
+
// drag is over, and a click keeps the selection it made. A wheel's
|
|
2581
|
+
// contact keeps the page's selection, as a scroll does.
|
|
2582
|
+
if (
|
|
2583
|
+
touch == null ||
|
|
2584
|
+
touch.pointerType === 'touch' ||
|
|
2585
|
+
touch.scrollType != null ||
|
|
2586
|
+
touch.phase === 'ended' ||
|
|
2587
|
+
touch.phase === 'cancelled'
|
|
2588
|
+
) {
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
const elements = new Set<Element>();
|
|
2592
|
+
for (const recognizer of this.arena.participantsOf(id)) {
|
|
2593
|
+
if (
|
|
2594
|
+
(recognizer.state === 'began' || recognizer.state === 'changed') &&
|
|
2595
|
+
(recognizer.view as Node | null)?.nodeType === 1
|
|
2596
|
+
) {
|
|
2597
|
+
elements.add(recognizer.view as Element);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
if ((claimer.view as Node | null)?.nodeType === 1) {
|
|
2601
|
+
elements.add(claimer.view as Element);
|
|
2602
|
+
}
|
|
2603
|
+
if (elements.size === 0) {
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
const release = lockDragSelection(
|
|
2607
|
+
elements,
|
|
2608
|
+
this.doc.defaultView,
|
|
2609
|
+
this.pressSelections.get(id) ?? [],
|
|
2610
|
+
() => {
|
|
2611
|
+
this.dragHolds.delete(release);
|
|
2612
|
+
},
|
|
2613
|
+
);
|
|
2614
|
+
this.dragHolds.add(release);
|
|
2615
|
+
// A press lock the contact took at its press stays in `lockedBy`. The
|
|
2616
|
+
// hold sits beside it, and the contact's end releases both.
|
|
2617
|
+
if (!this.lockedBy.has(id)) {
|
|
2618
|
+
this.lockedBy.set(id, release);
|
|
2619
|
+
}
|
|
2620
|
+
this.claimHolds.set(id, release);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* Claimed contacts the arena gave back. Their drag holds end at once, so
|
|
2626
|
+
* the browser's drag selects again. A contact's press lock (lockSelection)
|
|
2627
|
+
* is not a drag hold and stays.
|
|
2628
|
+
*/
|
|
2629
|
+
private releaseClaimedDrags(ids: ReadonlyArray<number>): void {
|
|
2630
|
+
for (const id of ids) {
|
|
2631
|
+
const release = this.claimHolds.get(id);
|
|
2632
|
+
if (release == null) {
|
|
2633
|
+
continue;
|
|
2634
|
+
}
|
|
2635
|
+
this.claimHolds.delete(id);
|
|
2636
|
+
if (this.lockedBy.get(id) === release) {
|
|
2637
|
+
this.lockedBy.delete(id);
|
|
2638
|
+
}
|
|
2639
|
+
release({isCancel: true});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2509
2643
|
/**
|
|
2510
2644
|
* End the trackpad pan a recognizer still drives, when the page loses focus
|
|
2511
2645
|
* or hides, and return its contacts to cancel. The rest of its stream is
|
|
@@ -2583,10 +2717,44 @@ export class GestureHost {
|
|
|
2583
2717
|
);
|
|
2584
2718
|
}
|
|
2585
2719
|
|
|
2586
|
-
/**
|
|
2587
|
-
|
|
2588
|
-
|
|
2720
|
+
/**
|
|
2721
|
+
* At a mouse or pen press, mark one that lands on selectable text: once it
|
|
2722
|
+
* moves, it is the browser's drag, and selects text.
|
|
2723
|
+
* Text whose hold does something is locked at this press already
|
|
2724
|
+
* (lockSelection), so it is not selectable text.
|
|
2725
|
+
*/
|
|
2726
|
+
private noteSelectingPress(
|
|
2727
|
+
id: number,
|
|
2728
|
+
x: number,
|
|
2729
|
+
y: number,
|
|
2730
|
+
target: EventTarget | null,
|
|
2731
|
+
): void {
|
|
2732
|
+
if (
|
|
2733
|
+
!this.lockedBy.has(id) &&
|
|
2734
|
+
startsOnSelectableText(this.doc, x, y, target)
|
|
2735
|
+
) {
|
|
2736
|
+
this.selectingContacts.add(id);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/**
|
|
2741
|
+
* Undo a contact's selection locks, its press lock and its drag hold, at
|
|
2742
|
+
* once for a cancel, and forget its press's selection; idempotent. A
|
|
2743
|
+
* drag's hold keeps its last task after a lift (lockDragSelection).
|
|
2744
|
+
*/
|
|
2745
|
+
private unlockSelection(id: number, isCancel = false): void {
|
|
2746
|
+
const lock = this.lockedBy.get(id);
|
|
2747
|
+
lock?.({isCancel});
|
|
2748
|
+
// A press lock keeps `lockedBy` when the contact also has a drag hold.
|
|
2749
|
+
// Release the hold too.
|
|
2750
|
+
const hold = this.claimHolds.get(id);
|
|
2751
|
+
if (hold != null && hold !== lock) {
|
|
2752
|
+
hold({isCancel});
|
|
2753
|
+
}
|
|
2589
2754
|
this.lockedBy.delete(id);
|
|
2755
|
+
this.claimHolds.delete(id);
|
|
2756
|
+
this.pressSelections.delete(id);
|
|
2757
|
+
this.selectingContacts.delete(id);
|
|
2590
2758
|
}
|
|
2591
2759
|
|
|
2592
2760
|
// --- Touch events ----------------------------------------------------------
|
|
@@ -3349,6 +3517,11 @@ export class GestureHost {
|
|
|
3349
3517
|
if (target !== landed && target != null) {
|
|
3350
3518
|
this.retargeted.set(id, {x: event.clientX, y: event.clientY, target});
|
|
3351
3519
|
}
|
|
3520
|
+
// Before the arena admits it: a press a group claims at its own
|
|
3521
|
+
// pointerdown keeps the selection the page has now.
|
|
3522
|
+
if (event.pointerType !== 'touch') {
|
|
3523
|
+
this.pressSelections.set(id, selectionRangesOf(this.doc.defaultView));
|
|
3524
|
+
}
|
|
3352
3525
|
this.arena.began([
|
|
3353
3526
|
{
|
|
3354
3527
|
id,
|
|
@@ -3373,6 +3546,10 @@ export class GestureHost {
|
|
|
3373
3546
|
consumerElement,
|
|
3374
3547
|
);
|
|
3375
3548
|
}
|
|
3549
|
+
if (event.pointerType !== 'touch') {
|
|
3550
|
+
// The engine's own hit: the text its caret query and a drag select.
|
|
3551
|
+
this.noteSelectingPress(id, event.clientX, event.clientY, landed);
|
|
3552
|
+
}
|
|
3376
3553
|
};
|
|
3377
3554
|
|
|
3378
3555
|
private readonly onPointerMove = (event: PointerEvent): void => {
|
|
@@ -3524,7 +3701,7 @@ export class GestureHost {
|
|
|
3524
3701
|
const id = this.pointerIds.get(event.pointerId);
|
|
3525
3702
|
if (id != null) {
|
|
3526
3703
|
this.pointerIds.delete(event.pointerId);
|
|
3527
|
-
this.unlockSelection(id);
|
|
3704
|
+
this.unlockSelection(id, true);
|
|
3528
3705
|
this.retargeted.delete(id);
|
|
3529
3706
|
this.arena.cancelled([id], eventTime(event));
|
|
3530
3707
|
}
|
|
@@ -3765,7 +3942,7 @@ export class GestureHost {
|
|
|
3765
3942
|
private cancelEverything(time: number): void {
|
|
3766
3943
|
const ids = [...this.pointerIds.values()];
|
|
3767
3944
|
for (const id of ids) {
|
|
3768
|
-
this.unlockSelection(id);
|
|
3945
|
+
this.unlockSelection(id, true);
|
|
3769
3946
|
}
|
|
3770
3947
|
for (const identifier of [...this.touchIds.keys()]) {
|
|
3771
3948
|
// The browser may keep the touch alive: its lift must not click.
|
package/src/gestures/README.md
CHANGED
|
@@ -304,9 +304,25 @@ presses nothing.
|
|
|
304
304
|
controller's surfaces (`usePressFeedback` rows), keep the browser's
|
|
305
305
|
long-press selection; a tap never selects. A non-text input under the finger inside such an element (a checkbox, a radio, a switch's input) is locked too: WebKit's own stylesheet makes every input selectable, which no ancestor's lock reaches. A text field keeps its selection, since a held finger places its caret. A
|
|
306
306
|
finger or a pen takes the lock under any primary pointer, a touchscreen
|
|
307
|
-
laptop and a desktop pen tablet included; a mouse never does
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
laptop and a desktop pen tablet included; a mouse never does
|
|
308
|
+
(`spec:AST-017` FR59).
|
|
309
|
+
|
|
310
|
+
A mouse or pen drag that a recognizer claims takes no new text selection
|
|
311
|
+
and never clears the page's (`spec:AST-094` FR41): from the claim until a
|
|
312
|
+
task after the lift (the press's own `mouseup` and `click` come in its
|
|
313
|
+
task), the claiming elements and the document refuse a selection, and the
|
|
314
|
+
selection the page had when the press began is put back over whatever the
|
|
315
|
+
press or the drag made of it. The hold starts at the claim of whichever
|
|
316
|
+
recognizer makes it, whatever its state: a swipe recognizes as `ended`
|
|
317
|
+
while the button is still down, and a recognizer that waits on another's
|
|
318
|
+
failure claims while still `possible`. A claim given back to the browser
|
|
319
|
+
(that waiting recognizer failing or cancelled, with no other owner) lets
|
|
320
|
+
the hold go, and the drag selects again. A drag that starts on selectable text (a
|
|
321
|
+
character of text that allows a selection, or a text field) is not
|
|
322
|
+
claimed: no recognizer recognizes with it while it is down, so it selects
|
|
323
|
+
text, and content under a sheet's or a pager's pan stays selectable. A
|
|
324
|
+
press a recognizer claims at its own pointerdown, before it moves, is no
|
|
325
|
+
drag, and a tap claims only at its lift.
|
|
310
326
|
|
|
311
327
|
- **Claiming.** When a recognizer recognizes, it owns its touches. Each
|
|
312
328
|
element with a recognizer that can claim a moving touch (a pan, a swipe, a
|
|
@@ -43,7 +43,7 @@ installed); rows marked † also on iOS 18 for the pre-glass delta. iPhone
|
|
|
43
43
|
| M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` |
|
|
44
44
|
| M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` |
|
|
45
45
|
| M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` |
|
|
46
|
-
| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `
|
|
46
|
+
| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `paging` |
|
|
47
47
|
| M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` |
|
|
48
48
|
| M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` |
|
|
49
49
|
| M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` |
|
|
@@ -72,6 +72,13 @@ initial velocity's sign flipped, a curve that overshoots. Push and pop are
|
|
|
72
72
|
not Core Animation animations on iOS 26.2: UIKit writes presentation
|
|
73
73
|
modifiers every frame from the main thread.
|
|
74
74
|
|
|
75
|
+
## Paging (UIScrollView `isPagingEnabled`, UIPageViewController)
|
|
76
|
+
|
|
77
|
+
| ID | What | App and gesture | Extract | Method | Feeds |
|
|
78
|
+
| ------ | --------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------ | ------------------------------ |
|
|
79
|
+
| M-PG12 | End of a settle | Same pager: releases at rest from 10 %, 49 %, 60 % and 90 % of a page, logged at 120 fps | remaining distance and step velocity at the frame the offset lands exactly on the page | C | `paging.integrator.restSpeed` |
|
|
80
|
+
| M-PG13 | Settle velocity floor | Same pager: steady releases from 200 to 250 pt/s in 5 pt/s steps, at 25 % of a page | the slowest release whose `scrollViewWillEndDragging` velocity is not 0 | C | `paging.release.velocityFloor` |
|
|
81
|
+
|
|
75
82
|
## Gestures (UIKit gesture recognizers)
|
|
76
83
|
|
|
77
84
|
Thresholds the gesture system reads (`uikit/gestures.ts`). Safari on iOS runs
|
package/src/motion/README.md
CHANGED
|
@@ -7,7 +7,8 @@ source), the spring, decay, rubber-band and projection math UIKit uses, and a
|
|
|
7
7
|
compositor animator that plays it. Nothing here is exported from the package
|
|
8
8
|
entry point. Core's motion and gesture modules read the table by path;
|
|
9
9
|
components read touch motion through its internal motion roles (`roles.ts`),
|
|
10
|
-
so each keeps its measured value;
|
|
10
|
+
so each keeps its measured value; ProgressBar plays the progress table's two
|
|
11
|
+
roles through `resolveMotion`.
|
|
11
12
|
|
|
12
13
|
## Files
|
|
13
14
|
|
|
@@ -15,9 +16,11 @@ so each keeps its measured value; no shipped component reads the roles yet.
|
|
|
15
16
|
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
16
17
|
| `uikit/` | THE constants table: every spring, duration, deceleration rate, rubber-band constant and curve, with its source. |
|
|
17
18
|
| `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. |
|
|
18
|
-
| `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring,
|
|
19
|
+
| `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, `delaysContentTouches`, scroll to top. |
|
|
19
20
|
| `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. |
|
|
21
|
+
| `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. |
|
|
20
22
|
| `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. |
|
|
23
|
+
| `uikit/progressBar.ts` | UIProgressView's fill: the whole-track duration and curve (two roles), its minimum length and empty alpha. |
|
|
21
24
|
| `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). |
|
|
22
25
|
| `roles.ts` | The internal motion roles: the springs, durations and curves `spec:AST-094` FR4 names, registered by the shared tables, and a role's CSS form read back. |
|
|
23
26
|
| `roleValue.ts` | A role's CSS form, written: a spring's duration and bounce, a duration in ms, a curve's `cubic-bezier()`. |
|
|
@@ -28,8 +31,8 @@ so each keeps its measured value; no shipped component reads the roles yet.
|
|
|
28
31
|
| `coast.ts` | A release's momentum curves for `animator.decay`: the coast, the edge spring, a release past an edge. |
|
|
29
32
|
| `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. |
|
|
30
33
|
| `projection.ts` | Release targets: the rest point nearest the projected coast. |
|
|
34
|
+
| `paging.ts` | Paging: UIKit's paging settle as a playable curve, its velocity floor, the animated page scroll. |
|
|
31
35
|
| `resolveSnapPoint.ts` | Release policy: the snap point a release lands on (flick, back-out cancel, step limit), on `selectSnapPoint`. Internal. |
|
|
32
|
-
| `selectPage.ts` | Paging: at most one page per release, on `resolveSnapPoint`. |
|
|
33
36
|
| `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). |
|
|
34
37
|
| `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. |
|
|
35
38
|
| `presentation.ts` | Where each moving element is drawn relative to where the engine hit-tests it, reported by the animator while translate runs; read by the gesture host at touch-down. |
|
package/src/motion/animator.ts
CHANGED
|
@@ -1052,7 +1052,7 @@ export function createAnimator(
|
|
|
1052
1052
|
if (segment == null) {
|
|
1053
1053
|
return null;
|
|
1054
1054
|
}
|
|
1055
|
-
if (time >=
|
|
1055
|
+
if (time >= endOf(property, segment)) {
|
|
1056
1056
|
return null;
|
|
1057
1057
|
}
|
|
1058
1058
|
return segment;
|
|
@@ -1089,6 +1089,53 @@ export function createAnimator(
|
|
|
1089
1089
|
? null
|
|
1090
1090
|
: {latest, start: segment.start + latest - segment.group.provisional};
|
|
1091
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* When a waiting curve's call had its first frame, which put the curve on
|
|
1094
|
+
* screen. Undefined for a curve that does not wait, or whose call has had
|
|
1095
|
+
* no first frame.
|
|
1096
|
+
*/
|
|
1097
|
+
const shownSince = (
|
|
1098
|
+
property: MotionProperty,
|
|
1099
|
+
segment: Segment,
|
|
1100
|
+
): number | undefined =>
|
|
1101
|
+
segment.group?.resolved === false
|
|
1102
|
+
? segment.group.latest?.get(CHANNEL_OF[property])
|
|
1103
|
+
: undefined;
|
|
1104
|
+
|
|
1105
|
+
/** A curve's end, dated from its call's first frame while it waits. */
|
|
1106
|
+
const endOf = (property: MotionProperty, segment: Segment): number =>
|
|
1107
|
+
(datedFromLatest(segment, shownSince(property, segment))?.start ??
|
|
1108
|
+
segment.start) + segment.duration;
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Takes a curve on screen out of the call that still waits for the
|
|
1112
|
+
* browser's start. The curve keeps its course, dated from the call's first
|
|
1113
|
+
* frame. A call that has no channel left starts at that frame.
|
|
1114
|
+
*/
|
|
1115
|
+
const leaveWaitingCall = (
|
|
1116
|
+
segment: Segment,
|
|
1117
|
+
channel: Channel,
|
|
1118
|
+
latest: number,
|
|
1119
|
+
) => {
|
|
1120
|
+
const group = segment.group;
|
|
1121
|
+
if (group == null) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const index = group.segments.indexOf(segment);
|
|
1125
|
+
if (index >= 0) {
|
|
1126
|
+
group.segments.splice(index, 1);
|
|
1127
|
+
}
|
|
1128
|
+
segment.start += latest - group.provisional;
|
|
1129
|
+
segment.group = undefined;
|
|
1130
|
+
if (!CHANNELS[channel].some(p => segments.get(p)?.group === group)) {
|
|
1131
|
+
group.latest?.delete(channel);
|
|
1132
|
+
group.channels.delete(channel);
|
|
1133
|
+
}
|
|
1134
|
+
if (group.channels.size === 0 && group.segments.length === 0) {
|
|
1135
|
+
resolveGroup(group, latest);
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1092
1139
|
/**
|
|
1093
1140
|
* The value on screen at `time`. Until the browser reports its start, a
|
|
1094
1141
|
* held curve shows its first value. A frame that ran without that start
|
|
@@ -1316,12 +1363,25 @@ export function createAnimator(
|
|
|
1316
1363
|
// Settled curves become resting values.
|
|
1317
1364
|
for (const property of properties) {
|
|
1318
1365
|
const segment = segments.get(property);
|
|
1319
|
-
if (segment != null && time >=
|
|
1366
|
+
if (segment != null && time >= endOf(property, segment)) {
|
|
1320
1367
|
resting[property] = segment.target;
|
|
1321
1368
|
segments.delete(property);
|
|
1322
1369
|
}
|
|
1323
1370
|
}
|
|
1324
1371
|
const moving = properties.filter(p => segments.has(p));
|
|
1372
|
+
if (callGroup == null) {
|
|
1373
|
+
// A call that starts now rebuilds a channel the compositor has moving
|
|
1374
|
+
// since a first frame without a known start. The channel carries on
|
|
1375
|
+
// from that frame, with a start of its own, instead of starting its
|
|
1376
|
+
// curve again.
|
|
1377
|
+
for (const property of moving) {
|
|
1378
|
+
const segment = segments.get(property) as Segment;
|
|
1379
|
+
const latest = shownSince(property, segment);
|
|
1380
|
+
if (latest != null) {
|
|
1381
|
+
leaveWaitingCall(segment, channel, latest);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1325
1385
|
// An explicit call can rebuild a channel while one of its curves still
|
|
1326
1386
|
// waits for the browser's start. The animation then joins the current
|
|
1327
1387
|
// call, or else that waiting call, with no start of its own.
|
|
@@ -2017,10 +2077,14 @@ export function createAnimator(
|
|
|
2017
2077
|
for (const channel of start === 'commit' ? [] : channels) {
|
|
2018
2078
|
for (const property of CHANNELS[channel]) {
|
|
2019
2079
|
const segment = segments.get(property);
|
|
2080
|
+
// A call's first frame puts its channels on screen, even before the
|
|
2081
|
+
// browser reports the start. So such a channel is not at rest either.
|
|
2020
2082
|
if (
|
|
2021
2083
|
segment != null &&
|
|
2022
|
-
time <
|
|
2023
|
-
(segment.group == null ||
|
|
2084
|
+
time < endOf(property, segment) &&
|
|
2085
|
+
(segment.group == null ||
|
|
2086
|
+
segment.group.resolved ||
|
|
2087
|
+
shownSince(property, segment) != null)
|
|
2024
2088
|
) {
|
|
2025
2089
|
return null;
|
|
2026
2090
|
}
|
|
@@ -3739,9 +3803,11 @@ export function createAnimator(
|
|
|
3739
3803
|
}
|
|
3740
3804
|
owners.add(segment.owner);
|
|
3741
3805
|
// A curve still waiting for the browser's start stays in its call:
|
|
3742
|
-
// it starts with that call's commit.
|
|
3806
|
+
// it starts with that call's commit. A curve whose call had its first
|
|
3807
|
+
// frame is on screen already, so it leaves the call (leaveWaitingCall).
|
|
3808
|
+
const latest = shownSince(property, segment);
|
|
3743
3809
|
const group =
|
|
3744
|
-
segment.group != null && !segment.group.resolved
|
|
3810
|
+
latest == null && segment.group != null && !segment.group.resolved
|
|
3745
3811
|
? segment.group
|
|
3746
3812
|
: undefined;
|
|
3747
3813
|
const replace = (next: Segment) => {
|
|
@@ -3758,6 +3824,9 @@ export function createAnimator(
|
|
|
3758
3824
|
if (segment.spring != null) {
|
|
3759
3825
|
const from = valueAt(property, time) + delta;
|
|
3760
3826
|
const velocity = velocityAt(property, time);
|
|
3827
|
+
if (latest != null) {
|
|
3828
|
+
leaveWaitingCall(segment, CHANNEL_OF[property], latest);
|
|
3829
|
+
}
|
|
3761
3830
|
const curve = solveSpring(toSpring(segment.spring), {
|
|
3762
3831
|
from,
|
|
3763
3832
|
to: segment.target,
|
|
@@ -3778,6 +3847,9 @@ export function createAnimator(
|
|
|
3778
3847
|
spring: segment.spring,
|
|
3779
3848
|
});
|
|
3780
3849
|
} else {
|
|
3850
|
+
if (latest != null) {
|
|
3851
|
+
leaveWaitingCall(segment, CHANNEL_OF[property], latest);
|
|
3852
|
+
}
|
|
3781
3853
|
replace({
|
|
3782
3854
|
...segment,
|
|
3783
3855
|
target: segment.target + delta,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file paging.ts
|
|
5
|
+
* @input A release velocity, a settle's start, end and velocity, and the UIKit
|
|
6
|
+
* paging constants
|
|
7
|
+
* @output Exports `pagingSettleVelocity`, `solvePaging` (UIKit's paging settle
|
|
8
|
+
* as a curve the animator plays) and `solveAnimatedPageScroll`
|
|
9
|
+
* @position Pure math, with no DOM and no React
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update:
|
|
12
|
+
* - /packages/core/src/motion/paging.test.ts
|
|
13
|
+
* - /packages/core/src/motion/README.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {MotionCurve} from './animator';
|
|
17
|
+
import {paging} from './uikit/paging';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The velocity a paging settle starts at. It is the release velocity that every
|
|
21
|
+
* gesture reads, zeroed below `velocityFloor`. The page choice reads the
|
|
22
|
+
* release velocity itself.
|
|
23
|
+
*/
|
|
24
|
+
export function pagingSettleVelocity(velocity: number): number {
|
|
25
|
+
return Math.abs(velocity) < paging.release.velocityFloor ? 0 : velocity;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A paging settle: the curve and where it ends. */
|
|
29
|
+
export interface PagingCurve extends MotionCurve {
|
|
30
|
+
readonly from: number;
|
|
31
|
+
readonly to: number;
|
|
32
|
+
/** Integration steps the settle took. */
|
|
33
|
+
readonly steps: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* UIKit's paging settle from `from` (px), moving at `velocity` (px/s), onto
|
|
38
|
+
* `to`. It steps `paging.integrator` `stepsPerSecond` times a second and ends
|
|
39
|
+
* exactly on `to`. The same motion returns an overscrolled pager to its first
|
|
40
|
+
* or last page.
|
|
41
|
+
*
|
|
42
|
+
* Positions are content offsets along the paging axis, 0 at page 0 and growing
|
|
43
|
+
* toward later pages. Velocities are px/s of that offset.
|
|
44
|
+
*
|
|
45
|
+
* No closed form reproduces the per-frame stepping, so the settle integrates
|
|
46
|
+
* once into a table, and the curve reads the table. A release needs at most
|
|
47
|
+
* about 400 steps, well under a millisecond of work.
|
|
48
|
+
*
|
|
49
|
+
* The release velocity enters in px per millisecond per step, as UIKit hands it
|
|
50
|
+
* to the integrator. At 960 steps a second, the content starts at 96 % of the
|
|
51
|
+
* finger's speed, as in UIKit.
|
|
52
|
+
*/
|
|
53
|
+
export function solvePaging({
|
|
54
|
+
from,
|
|
55
|
+
velocity,
|
|
56
|
+
to,
|
|
57
|
+
}: {
|
|
58
|
+
from: number;
|
|
59
|
+
velocity: number;
|
|
60
|
+
to: number;
|
|
61
|
+
}): PagingCurve {
|
|
62
|
+
const {springPull, friction, stepsPerSecond, restDistance, restSpeed} =
|
|
63
|
+
paging.integrator;
|
|
64
|
+
const xs: number[] = [from];
|
|
65
|
+
const vs: number[] = [velocity / 1000];
|
|
66
|
+
let x = from;
|
|
67
|
+
let v = velocity / 1000;
|
|
68
|
+
// ~10 s of steps: far beyond any settle (≈ 0.35 s from rest).
|
|
69
|
+
const maxSteps = stepsPerSecond * 10;
|
|
70
|
+
let steps = 0;
|
|
71
|
+
while (steps < maxSteps) {
|
|
72
|
+
if (Math.abs(to - x) < restDistance && Math.abs(v) < restSpeed) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
v = (v + (to - x) * springPull) * friction;
|
|
76
|
+
x += v;
|
|
77
|
+
steps += 1;
|
|
78
|
+
xs.push(x);
|
|
79
|
+
vs.push(v);
|
|
80
|
+
}
|
|
81
|
+
xs[xs.length - 1] = to;
|
|
82
|
+
vs[vs.length - 1] = 0;
|
|
83
|
+
const duration = steps / stepsPerSecond;
|
|
84
|
+
const last = xs.length - 1;
|
|
85
|
+
const read = (table: number[], t: number): number => {
|
|
86
|
+
if (!(t > 0)) {
|
|
87
|
+
return table[0];
|
|
88
|
+
}
|
|
89
|
+
const s = t * stepsPerSecond;
|
|
90
|
+
if (s >= last) {
|
|
91
|
+
return table[last];
|
|
92
|
+
}
|
|
93
|
+
const i = Math.floor(s);
|
|
94
|
+
const f = s - i;
|
|
95
|
+
return table[i] + (table[i + 1] - table[i]) * f;
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
from,
|
|
99
|
+
to,
|
|
100
|
+
steps,
|
|
101
|
+
duration,
|
|
102
|
+
at: t => read(xs, t),
|
|
103
|
+
// px per step × steps per second
|
|
104
|
+
velocity: t => read(vs, t) * stepsPerSecond,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `setContentOffset(_:animated:)`: `paging.animatedScroll.duration` on a
|
|
110
|
+
* sinusoidal ease-in-out, whatever the distance. UIKit starts it from the
|
|
111
|
+
* current offset and drops any velocity, as here.
|
|
112
|
+
*/
|
|
113
|
+
export function solveAnimatedPageScroll({
|
|
114
|
+
from,
|
|
115
|
+
to,
|
|
116
|
+
}: {
|
|
117
|
+
from: number;
|
|
118
|
+
to: number;
|
|
119
|
+
}): MotionCurve {
|
|
120
|
+
const {duration} = paging.animatedScroll;
|
|
121
|
+
const distance = to - from;
|
|
122
|
+
return {
|
|
123
|
+
duration,
|
|
124
|
+
at: t => {
|
|
125
|
+
const u = Math.min(1, Math.max(0, t / duration));
|
|
126
|
+
return from + (distance * (1 - Math.cos(Math.PI * u))) / 2;
|
|
127
|
+
},
|
|
128
|
+
velocity: t => {
|
|
129
|
+
if (t <= 0 || t >= duration) {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
((distance * Math.PI) / (2 * duration)) *
|
|
134
|
+
Math.sin((Math.PI * t) / duration)
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* @file index.ts
|
|
5
5
|
* @input The domain files of the UIKit constants table
|
|
6
6
|
* @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`,
|
|
7
|
-
* …), `scroll`, `timing`, `navigation`, the `slider` domain
|
|
8
|
-
* gesture thresholds (one `UIKIT_*` group per recognizer, their
|
|
9
|
-
* `UIKIT_GESTURES`, and the haptic tables)
|
|
7
|
+
* …), `scroll`, `timing`, `navigation`, `progressBar`, the `slider` domain
|
|
8
|
+
* and the gesture thresholds (one `UIKIT_*` group per recognizer, their
|
|
9
|
+
* union `UIKIT_GESTURES`, and the haptic tables)
|
|
10
10
|
* @position Entry of the UIKit constants table (motion/uikit). Internal to
|
|
11
11
|
* core: not exported from the package entry point
|
|
12
12
|
*
|
|
@@ -39,6 +39,7 @@ export {
|
|
|
39
39
|
type HapticFeedbackKind,
|
|
40
40
|
} from './gestures';
|
|
41
41
|
export {navigation, registerNavigationTable} from './navigation';
|
|
42
|
+
export {progressBar, registerProgressBarTable} from './progressBar';
|
|
42
43
|
export {registerScrollTable, scroll} from './scroll';
|
|
43
44
|
export {slider} from './slider';
|
|
44
45
|
export {
|