@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.
Files changed (67) hide show
  1. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  2. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  3. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  4. package/dist/DateInput/MonthScroller.js +72 -2
  5. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  6. package/dist/Field/PanelSearchInput.js +8 -1
  7. package/dist/Field/textEntryTraits.d.ts +6 -6
  8. package/dist/Field/textEntryTraits.js +6 -6
  9. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  10. package/dist/ProgressBar/ProgressBar.js +20 -9
  11. package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
  12. package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
  13. package/dist/ProgressBar/ProgressBarTouch.js +91 -0
  14. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  15. package/dist/Typeahead/BaseTypeahead.js +9 -1
  16. package/dist/astryx.css +7 -0
  17. package/dist/gestures/GestureArena.d.ts +21 -2
  18. package/dist/gestures/GestureArena.d.ts.map +1 -1
  19. package/dist/gestures/GestureArena.js +19 -2
  20. package/dist/gestures/GestureHost.d.ts +51 -1
  21. package/dist/gestures/GestureHost.d.ts.map +1 -1
  22. package/dist/gestures/GestureHost.js +148 -7
  23. package/dist/motion/animator.d.ts.map +1 -1
  24. package/dist/motion/animator.js +63 -5
  25. package/dist/motion/paging.d.ts +58 -0
  26. package/dist/motion/paging.d.ts.map +1 -0
  27. package/dist/motion/paging.js +128 -0
  28. package/dist/motion/uikit/index.d.ts +4 -3
  29. package/dist/motion/uikit/index.d.ts.map +1 -1
  30. package/dist/motion/uikit/index.js +4 -3
  31. package/dist/motion/uikit/paging.d.ts +160 -0
  32. package/dist/motion/uikit/paging.d.ts.map +1 -0
  33. package/dist/motion/uikit/paging.js +162 -0
  34. package/dist/motion/uikit/progressBar.d.ts +59 -0
  35. package/dist/motion/uikit/progressBar.d.ts.map +1 -0
  36. package/dist/motion/uikit/progressBar.js +82 -0
  37. package/dist/motion/uikit/scroll.d.ts +0 -19
  38. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  39. package/dist/motion/uikit/scroll.js +2 -21
  40. package/dist/utils/selectionLock.d.ts +43 -0
  41. package/dist/utils/selectionLock.d.ts.map +1 -1
  42. package/dist/utils/selectionLock.js +394 -6
  43. package/package.json +4 -4
  44. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  45. package/src/DateInput/MonthScroller.tsx +63 -5
  46. package/src/Field/PanelSearchInput.tsx +6 -0
  47. package/src/Field/textEntryTraits.ts +6 -6
  48. package/src/ProgressBar/ProgressBar.doc.mjs +3 -3
  49. package/src/ProgressBar/ProgressBar.tsx +82 -11
  50. package/src/ProgressBar/ProgressBarTouch.ts +109 -0
  51. package/src/Typeahead/BaseTypeahead.tsx +7 -0
  52. package/src/gestures/GestureArena.ts +44 -4
  53. package/src/gestures/GestureHost.ts +184 -7
  54. package/src/gestures/README.md +19 -3
  55. package/src/motion/MEASUREMENTS.md +8 -1
  56. package/src/motion/README.md +6 -3
  57. package/src/motion/animator.ts +78 -6
  58. package/src/motion/paging.ts +138 -0
  59. package/src/motion/uikit/index.ts +4 -3
  60. package/src/motion/uikit/paging.ts +162 -0
  61. package/src/motion/uikit/progressBar.ts +82 -0
  62. package/src/motion/uikit/scroll.ts +2 -18
  63. package/src/utils/selectionLock.ts +501 -6
  64. package/dist/motion/selectPage.d.ts +0 -26
  65. package/dist/motion/selectPage.d.ts.map +0 -1
  66. package/dist/motion/selectPage.js +0 -55
  67. 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 {locksSelectionFor} from '../utils/selectionLock';
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<number, () => void>();
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
- /** Undo `lockSelection` for a contact that ended; idempotent. */
2587
- private unlockSelection(id: number): void {
2588
- this.lockedBy.get(id)?.();
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.
@@ -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, so mouse
308
- selection is unchanged (`spec:AST-017` FR59); content under a sheet's or a
309
- pager's pan stays selectable.
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 | `scroll.paging` |
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
@@ -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; no shipped component reads the roles yet.
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, paging, `delaysContentTouches`, scroll to top. |
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. |
@@ -1052,7 +1052,7 @@ export function createAnimator(
1052
1052
  if (segment == null) {
1053
1053
  return null;
1054
1054
  }
1055
- if (time >= segment.start + segment.duration) {
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 >= segment.start + segment.duration) {
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 < segment.start + segment.duration &&
2023
- (segment.group == null || segment.group.resolved)
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 and the
8
- * gesture thresholds (one `UIKIT_*` group per recognizer, their union
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 {